Compare commits

..
2608 changed files with 306599 additions and 467118 deletions
+2 -2
View File
@@ -8,7 +8,7 @@ Each skill is a directory with a `SKILL.md` entrypoint, optional reference docs,
| Skill | Description | | Skill | Description |
|-------|-------------| |-------|-------------|
| [dms-plugin-dev](dms-plugin-dev/) | Develop plugins for Dux Shell - covers all 4 plugin types (widget, daemon, launcher, desktop), manifest creation, QML components, settings UI, data persistence, theme integration, and PopoutService usage. | | [dms-plugin-dev](dms-plugin-dev/) | Develop plugins for DankMaterialShell - covers all 4 plugin types (widget, daemon, launcher, desktop), manifest creation, QML components, settings UI, data persistence, theme integration, and PopoutService usage. |
## Installation ## Installation
@@ -28,7 +28,7 @@ ln -s ../../.agents/skills/dms-plugin-dev .claude/skills/dms-plugin-dev
**Personal** (all your projects): **Personal** (all your projects):
```bash ```bash
ln -s /path/to/dms/.agents/skills/dms-plugin-dev ~/.claude/skills/dms-plugin-dev ln -s /path/to/DankMaterialShell/.agents/skills/dms-plugin-dev ~/.claude/skills/dms-plugin-dev
``` ```
After linking, the skill appears in Claude Code's `/` menu as `/dms-plugin-dev`, and Claude loads it automatically when you ask about DMS plugin development. After linking, the skill appears in Claude Code's `/` menu as `/dms-plugin-dev`, and Claude loads it automatically when you ask about DMS plugin development.
+30 -141
View File
@@ -1,71 +1,65 @@
--- ---
name: dms-plugin-dev name: dms-plugin-dev
description: > description: >
Develop plugins for Dux Shell (DMS), a QML-based Linux desktop shell built on Develop plugins for DankMaterialShell (DMS), a QML-based Linux desktop shell built on
Quickshell. Supports widget (bar, dock + Control Center), daemon (background service), Quickshell. Supports five plugin types: widget (bar + Control Center), daemon (background
launcher (search + actions), desktop (draggable desktop widgets), dash (dash tab), service), launcher (search + actions), desktop (draggable desktop widgets), and composite
dashCard (dash overview card), and composite (multi-surface) plugins. Covers manifest (multi-surface). Covers manifest creation, QML component development, startup checks,
creation, QML component development, startup checks,
settings UI, data persistence, theme integration, PopoutService usage, IPC runtime settings UI, data persistence, theme integration, PopoutService usage, IPC runtime
discovery, and external command execution. Use when the user wants to create, modify, discovery, and external command execution. Use when the user wants to create, modify,
or debug a DMS plugin, or asks about the DMS plugin API. or debug a DMS plugin, or asks about the DMS plugin API.
compatibility: Designed for Claude Code (or similar products) compatibility: Designed for Claude Code (or similar products)
metadata: metadata:
author: Dux Shell author: DankMaterialShell
version: "1.2" version: "1.1"
domain: qml-desktop-development domain: qml-desktop-development
framework: Dux Shell framework: DankMaterialShell
languages: qml, javascript languages: qml, javascript
allowed-tools: Bash Read Write Edit allowed-tools: Bash Read Write Edit
--- ---
# Dux Shell Plugin Development # DankMaterialShell Plugin Development
## Overview ## Overview
DMS plugins extend the desktop shell with bar and dock widgets, background services, launcher DMS plugins extend the desktop shell with custom widgets, background services, launcher
integrations, desktop widgets, and dash tabs and cards. Plugins are QML components discovered from integrations, and desktop widgets. Plugins are QML components discovered from
`~/.config/dms/plugins/`. `~/.config/DankMaterialShell/plugins/`.
**Minimum plugin structure:** **Minimum plugin structure:**
``` ```
~/.config/dms/plugins/YourPlugin/ ~/.config/DankMaterialShell/plugins/YourPlugin/
plugin.json # Required: manifest with metadata plugin.json # Required: manifest with metadata
YourComponent.qml # Required: main QML component YourComponent.qml # Required: main QML component
YourSettings.qml # Optional: settings UI YourSettings.qml # Optional: settings UI
*.js # Optional: JavaScript utilities *.js # Optional: JavaScript utilities
``` ```
**Plugin registry:** Community plugins are available at https://plugins.duxlabs.dev/ **Plugin registry:** Community plugins are available at https://plugins.danklinux.com/
**Plugin types:** **Five plugin types:**
| Type | Purpose | Base Component | Bar/dock pills | CC integration | | Type | Purpose | Base Component | Bar pills | CC integration |
|-------------|--------------------------------|----------------------------|----------------|----------------| |-------------|--------------------------------|----------------------------|-----------|----------------|
| `widget` | Bar or dock widget + popout | `PluginComponent` | Yes | Yes | | `widget` | Bar widget + popout | `PluginComponent` | Yes | Yes |
| `daemon` | Background service | `PluginComponent` (no UI) | No | Optional | | `daemon` | Background service | `PluginComponent` (no UI) | No | Optional |
| `launcher` | Searchable items in launcher | `Item` | No | No | | `launcher` | Searchable items in launcher | `Item` | No | No |
| `desktop` | Draggable desktop widget | `DesktopPluginComponent` | No | No | | `desktop` | Draggable desktop widget | `DesktopPluginComponent` | No | No |
| `dash` | Tab in the dash popout | `DashTabComponent` | No | No | | `composite` | Multi-surface plugin | One component per surface | Optional | Optional |
| `dashCard` | Card in the dash overview grid | `DashCardComponent` | No | No |
| `composite` | Multi-surface plugin | One component per surface | Optional | Optional |
## Step 1: Determine Plugin Type ## Step 1: Determine Plugin Type
Choose the type based on what the plugin does: Choose the type based on what the plugin does:
- **Shows in the bar or a dock?** - Use `widget`. Displays a pill in DankBar or a dock (same - **Shows in the bar?** - Use `widget`. Displays a pill in DankBar, optionally opens a popout,
component), optionally opens a popout or an attached dock panel, optionally integrates with optionally integrates with Control Center.
Control Center.
- **Runs in background only?** - Use `daemon`. No visible UI, reacts to events (wallpaper - **Runs in background only?** - Use `daemon`. No visible UI, reacts to events (wallpaper
changes, notifications, battery level, etc.). changes, notifications, battery level, etc.).
- **Provides searchable/actionable items?** - Use `launcher`. Items appear in the DMS launcher - **Provides searchable/actionable items?** - Use `launcher`. Items appear in the DMS launcher
with trigger-based filtering (e.g., type `=` for calculator, `:` for emoji). with trigger-based filtering (e.g., type `=` for calculator, `:` for emoji).
- **Shows on the desktop background?** - Use `desktop`. Draggable, resizable widget on the - **Shows on the desktop background?** - Use `desktop`. Draggable, resizable widget on the
desktop layer. desktop layer.
- **Full page in the dash?** - Use `dash`. A tab in the dash popout, id `plugin_<pluginId>`.
- **Small tile on the dash overview?** - Use `dashCard`. A resizable card in the overview grid.
- **Needs multiple surfaces?** - Use `composite`. A single plugin that registers any combination - **Needs multiple surfaces?** - Use `composite`. A single plugin that registers any combination
of the above (e.g., a daemon + bar widget + desktop widget). Each surface gets its own of the above (e.g., a daemon + bar widget + desktop widget). Each surface gets its own
QML component file. QML component file.
@@ -132,11 +126,6 @@ Create `plugin.json` in your plugin directory. See [plugin-manifest-reference.md
} }
``` ```
**Standalone dash tab or card:** same single file form, `"type": "dash"` or `"type": "dashCard"`
plus `"component"`. A tab and a card together (or with a bar widget) use `components` with the
keys `dash` and `dashCard`. Both accept an optional `dash` block for labels, card size and
options. See [dash-plugin-guide.md](references/dash-plugin-guide.md).
**Key rules:** **Key rules:**
- `id` must be camelCase, matching pattern `^[a-zA-Z][a-zA-Z0-9]*$` - `id` must be camelCase, matching pattern `^[a-zA-Z][a-zA-Z0-9]*$`
- `version` must be semver (e.g., `1.0.0`) - `version` must be semver (e.g., `1.0.0`)
@@ -291,62 +280,6 @@ PluginComponent {
See [daemon-plugin-guide.md](references/daemon-plugin-guide.md) for event-driven patterns and process execution. See [daemon-plugin-guide.md](references/daemon-plugin-guide.md) for event-driven patterns and process execution.
### Dash Tab
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
import qs.Modules.DankDash
DashTabComponent {
id: root
property date now: new Date()
implicitHeight: DashMetrics.tabMinHeight
Timer {
interval: 1000
repeat: true
running: root.live
onTriggered: root.now = new Date()
}
StyledText {
anchors.centerIn: parent
text: root.now.toLocaleTimeString(I18n.locale())
color: Theme.surfaceText
}
}
```
### Dash Card
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
DashCardComponent {
id: root
tone: options.tone ?? ""
StyledText {
anchors.centerIn: parent
text: I18n.trFor("myPlugin", "Hello")
color: root.contentColor
}
}
```
Gate timers and animations on `live`; the dash content stays alive after the popout closes.
Cards must lay out for every size in their manifest range. Use the card colors (`accentColor`,
`contentColor`, `mutedColor`) so tones work. See [dash-plugin-guide.md](references/dash-plugin-guide.md).
### Composite ### Composite
For composite plugins, create a separate QML file per surface. Each surface uses the same For composite plugins, create a separate QML file per surface. Each surface uses the same
@@ -362,7 +295,8 @@ MyCompositePlugin/
Settings.qml # Shared settings for all surfaces Settings.qml # Shared settings for all surfaces
``` ```
`pluginService.availablePlugins[pluginId].surfaces` lists the surfaces a plugin registered. Use `pluginService.pluginHasSurface(pluginId, "widget")` to check whether a specific surface
is registered for a plugin at runtime.
## Step 4: Add Startup Check (Optional) ## Step 4: Add Startup Check (Optional)
@@ -480,7 +414,7 @@ Always use `Theme.*` properties from `qs.Common` - never hardcode colors or size
- Radius: `Theme.cornerRadius`, `Theme.cornerRadiusSmall`, `Theme.cornerRadiusLarge` - Radius: `Theme.cornerRadius`, `Theme.cornerRadiusSmall`, `Theme.cornerRadiusLarge`
- Icons: `Theme.iconSizeSmall` (16), `Theme.iconSize` (24), `Theme.iconSizeLarge` (32) - Icons: `Theme.iconSizeSmall` (16), `Theme.iconSize` (24), `Theme.iconSizeLarge` (32)
**Common widgets from `qs.DCommon.Widgets`:** `StyledText`, `StyledRect`, `DIcon`, `DButton`, `DToggle`, `DTextField`, `DSlider`, `DGridView`, `CachingImage`. The old `Dank*` names in `qs.Widgets` are deprecated. **Common widgets from `qs.Widgets`:** `StyledText`, `StyledRect`, `DankIcon`, `DankButton`, `DankToggle`, `DankTextField`, `DankSlider`, `DankGridView`, `CachingImage`.
See [theme-reference.md](references/theme-reference.md) for the complete property list. See [theme-reference.md](references/theme-reference.md) for the complete property list.
@@ -497,6 +431,7 @@ PluginComponent {
PopoutComponent { PopoutComponent {
headerText: "My Plugin" headerText: "My Plugin"
detailsText: "Optional subtitle" detailsText: "Optional subtitle"
showCloseButton: true
Column { Column {
width: parent.width width: parent.width
@@ -515,7 +450,7 @@ PluginComponent {
} }
``` ```
**PopoutComponent properties:** `headerText`, `detailsText`, `closePopout()` (auto-injected), `headerHeight` (readonly), `detailsHeight` (readonly). **PopoutComponent properties:** `headerText`, `detailsText`, `showCloseButton`, `closePopout()` (auto-injected), `headerHeight` (readonly), `detailsHeight` (readonly).
Calculate available content height: `popoutHeight - headerHeight - detailsHeight - spacing` Calculate available content height: `popoutHeight - headerHeight - detailsHeight - spacing`
@@ -579,43 +514,12 @@ Quickshell.execDetached(["dms", "cl", "copy", textToCopy])
**Do NOT use** `globalThis.clipboard` or browser JavaScript APIs - they don't exist in the QML runtime. **Do NOT use** `globalThis.clipboard` or browser JavaScript APIs - they don't exist in the QML runtime.
## Translations (Optional)
Ship per-locale files in a `translations/` directory; DMS loads the one matching the active locale and re-reads it on locale changes:
```
MyPlugin/
└── translations/
├── es.json
└── zh_CN.json
```
File names follow DMS locale naming (`es.json`, `pt.json`, `zh_CN.json`); resolution tries the exact tag, then the hyphenated form, then the bare language. No `en.json` - the QML terms are the English strings.
Each file maps the English term to its translation, using the term as its own context bucket:
```json
{
"Cycle Speed": {
"Cycle Speed": "Velocidad de ciclo"
}
}
```
In QML, call `I18n.trFor` with the plugin id as a literal string matching `plugin.json`:
```qml
text: I18n.trFor("myPlugin", "Cycle Speed")
```
Lookup order: plugin file, then the global DMS catalog, then the English term. `ExampleEmojiPlugin` in `quickshell/PLUGINS/` demonstrates the full setup. Registry plugins can apply for central POEditor translation via the registry's CONTRIBUTING guide.
## Step 11: Validate and Test ## Step 11: Validate and Test
1. Validate `plugin.json` against the schema at [assets/plugin-schema.json](assets/plugin-schema.json) 1. Validate `plugin.json` against the schema at [assets/plugin-schema.json](assets/plugin-schema.json)
2. Run the shell with verbose output: `qs -v -p $CONFIGPATH/quickshell/dms/shell.qml` 2. Run the shell with verbose output: `qs -v -p $CONFIGPATH/quickshell/dms/shell.qml`
3. Open Settings > Plugins > Scan for Plugins 3. Open Settings > Plugins > Scan for Plugins
4. Enable your plugin and add it to the DankBar layout, a dock, or the dash (tabs show up on their own; add cards from the dash edit mode) 4. Enable your plugin and add it to the DankBar layout
**Runtime plugin discovery via IPC:** **Runtime plugin discovery via IPC:**
@@ -653,10 +557,6 @@ Plugin IDs are validated against `^[a-zA-Z0-9_\-:]{1,64}$`.
11. **Using `requires` instead of `dependencies`** - `requires` is deprecated; use `dependencies` 11. **Using `requires` instead of `dependencies`** - `requires` is deprecated; use `dependencies`
12. **Providing both `component` and `components`** - Use one or the other, not both 12. **Providing both `component` and `components`** - Use one or the other, not both
13. **Missing `trigger` on composite with launcher surface** - Still required when `components` has a `launcher` key 13. **Missing `trigger` on composite with launcher surface** - Still required when `components` has a `launcher` key
14. **Using `"type": "widget"` for a dash tab** - Dash surfaces need `"type": "dash"` / `"dashCard"` or a `components` key
15. **Timers in dash tabs or cards that ignore `live`** - The dash content outlives the popout; gate on `live`
16. **Reading `dash.options` from a bar widget** - `options` only reach dash surfaces; share values through `settings` and `pluginData`
17. **Raw theme colors in a dash card** - Use `accentColor` / `contentColor` / `mutedColor` so the card follows its tone
## Quick Reference: Imports ## Quick Reference: Imports
@@ -680,16 +580,6 @@ import QtQuick
import qs.Common import qs.Common
``` ```
**Dash tab / card:**
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
import qs.Modules.DankDash // DashMetrics, DashWidgetGrid
import qs.Modules.DankDash.Overview // Card, for tiles inside a tab
```
**For clipboard/exec:** `import Quickshell` **For clipboard/exec:** `import Quickshell`
**For processes:** `import Quickshell.Io` **For processes:** `import Quickshell.Io`
**For networking:** `import Quickshell.Networking` **For networking:** `import Quickshell.Networking`
@@ -708,8 +598,7 @@ import qs.Modules.DankDash.Overview // Card, for tiles inside a tab
Load these on demand for detailed API documentation: Load these on demand for detailed API documentation:
- [plugin-manifest-reference.md](references/plugin-manifest-reference.md) - Complete plugin.json field reference and JSON schema - [plugin-manifest-reference.md](references/plugin-manifest-reference.md) - Complete plugin.json field reference and JSON schema
- [widget-plugin-guide.md](references/widget-plugin-guide.md) - PluginComponent, bar and dock pills, attached dock panels, popouts, click actions, CC integration - [widget-plugin-guide.md](references/widget-plugin-guide.md) - PluginComponent, bar pills, popouts, click actions, CC integration
- [dash-plugin-guide.md](references/dash-plugin-guide.md) - DashTabComponent, DashCardComponent, DashWidgetGrid, dash options, card sizing
- [launcher-plugin-guide.md](references/launcher-plugin-guide.md) - getItems/executeItem, triggers, icon types, context menus, tile view - [launcher-plugin-guide.md](references/launcher-plugin-guide.md) - getItems/executeItem, triggers, icon types, context menus, tile view
- [desktop-plugin-guide.md](references/desktop-plugin-guide.md) - DesktopPluginComponent, sizing, edit mode, position persistence - [desktop-plugin-guide.md](references/desktop-plugin-guide.md) - DesktopPluginComponent, sizing, edit mode, position persistence
- [daemon-plugin-guide.md](references/daemon-plugin-guide.md) - Event-driven background services, process execution - [daemon-plugin-guide.md](references/daemon-plugin-guide.md) - Event-driven background services, process execution
@@ -1,8 +1,8 @@
{ {
"$schema": "http://json-schema.org/draft-07/schema#", "$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://duxlabs.dev/schemas/plugin.json", "$id": "https://danklinux.com/schemas/plugin.json",
"title": "Dux Shell Plugin Manifest", "title": "DankMaterialShell Plugin Manifest",
"description": "Schema for Dux Shell plugin.json manifest files", "description": "Schema for DankMaterialShell plugin.json manifest files",
"type": "object", "type": "object",
"required": [ "required": [
"id", "id",
@@ -42,7 +42,7 @@
"type": { "type": {
"type": "string", "type": "string",
"description": "Plugin type. Use 'composite' (or any value) together with 'components' to provide multiple surfaces from one plugin.", "description": "Plugin type. Use 'composite' (or any value) together with 'components' to provide multiple surfaces from one plugin.",
"enum": ["widget", "daemon", "launcher", "desktop", "dash", "dashCard", "composite"] "enum": ["widget", "daemon", "launcher", "desktop", "composite"]
}, },
"capabilities": { "capabilities": {
"type": "array", "type": "array",
@@ -80,83 +80,11 @@
"type": "string", "type": "string",
"description": "Launcher provider component (requires 'trigger')", "description": "Launcher provider component (requires 'trigger')",
"pattern": "^\\./.*\\.qml$" "pattern": "^\\./.*\\.qml$"
},
"dash": {
"type": "string",
"description": "Dashboard tab component (DashTabComponent)",
"pattern": "^\\./.*\\.qml$"
},
"dashCard": {
"type": "string",
"description": "Dashboard overview card component (DashCardComponent)",
"pattern": "^\\./.*\\.qml$"
} }
}, },
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1 "minProperties": 1
}, },
"dash": {
"type": "object",
"description": "Presentation for the dashboard surfaces. Falls back to 'icon' and 'name'.",
"properties": {
"icon": {
"type": "string",
"description": "Material icon for the dashboard tab"
},
"title": {
"type": "string",
"description": "Tab label"
},
"card": {
"type": "object",
"description": "Overview card presentation and default size in grid units. Widths are clamped to the user's column count (3 to 8).",
"properties": {
"icon": { "type": "string" },
"title": { "type": "string" },
"w": { "type": "integer", "minimum": 1, "maximum": 8 },
"h": { "type": "integer", "minimum": 1 },
"minW": { "type": "integer", "minimum": 1, "maximum": 8 },
"minH": { "type": "integer", "minimum": 1 },
"maxW": { "type": "integer", "minimum": 1, "maximum": 8 },
"maxH": { "type": "integer", "minimum": 1 }
},
"additionalProperties": false
},
"options": {
"type": "array",
"description": "User options shown in the dashboard options sheet and Settings > Dashboard; values reach the tab and card as `options.<key>`",
"items": {
"type": "object",
"required": ["key", "type"],
"properties": {
"key": { "type": "string", "not": { "const": "widgets" }, "description": "Option key, read as options.<key>. 'widgets' is reserved for DashWidgetGrid layouts" },
"text": { "type": "string", "description": "Row label, translated through the plugin catalog" },
"description": { "type": "string", "description": "Secondary text under a toggle row, translated through the plugin catalog" },
"type": { "type": "string", "enum": ["toggle", "choice", "number"] },
"def": { "description": "Default value: boolean for toggle, choice value for choice, number for number" },
"choices": {
"type": "array",
"items": {
"type": "object",
"required": ["value"],
"properties": {
"value": { "type": "string" },
"text": { "type": "string" }
},
"additionalProperties": false
}
},
"min": { "type": "number" },
"max": { "type": "number" },
"step": { "type": "number", "exclusiveMinimum": 0 },
"unit": { "type": "string" }
},
"additionalProperties": false
}
}
},
"additionalProperties": false
},
"trigger": { "trigger": {
"type": "string", "type": "string",
"description": "Trigger string for launcher activation (required for launcher type)" "description": "Trigger string for launcher activation (required for launcher type)"
@@ -1,33 +0,0 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
import qs.Modules.DankDash
DashCardComponent {
id: root
readonly property bool wide: width >= DashMetrics.gridRowUnit * 2
tone: options.tone ?? ""
Row {
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
anchors.verticalCenter: parent.verticalCenter
name: "extension"
size: Theme.iconSizeLarge
color: root.accentColor
}
StyledText {
anchors.verticalCenter: parent.verticalCenter
visible: root.wide
text: I18n.trFor("myDashCard", "Hello")
font.pixelSize: Theme.fontSizeLarge
color: root.contentColor
}
}
}
@@ -1,27 +0,0 @@
{
"id": "myDashCard",
"name": "My Dash Card",
"description": "A custom dash overview card",
"version": "1.0.0",
"author": "Your Name",
"type": "dashCard",
"capabilities": ["dash-card"],
"component": "./Card.qml",
"icon": "extension",
"dash": {
"card": { "w": 2, "h": 1, "minW": 1, "minH": 1, "maxW": 3, "maxH": 2 },
"options": [
{
"key": "tone",
"text": "Tone",
"type": "choice",
"def": "",
"choices": [
{ "value": "", "text": "Surface" },
{ "value": "primary", "text": "Primary" }
]
}
]
},
"requires_dms": ">=1.7.0"
}
@@ -1,37 +0,0 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
import qs.Modules.DankDash
DashTabComponent {
id: root
focusTarget: button
implicitHeight: Math.max(DashMetrics.tabMinHeight, content.implicitHeight + Theme.spacingL * 2)
function restoreFocus() {
button.forceActiveFocus(Qt.OtherFocusReason);
}
Column {
id: content
anchors.centerIn: parent
spacing: Theme.spacingM
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
visible: !root.options.compact
text: I18n.trFor("myDashTab", "Hello from a dash tab")
font.pixelSize: Theme.fontSizeXLarge
color: Theme.surfaceText
}
DankButton {
id: button
anchors.horizontalCenter: parent.horizontalCenter
text: I18n.trFor("myDashTab", "Open overview")
onClicked: root.tabRequested("overview")
}
}
}
@@ -1,18 +0,0 @@
{
"id": "myDashTab",
"name": "My Dash Tab",
"description": "A custom dash tab",
"version": "1.0.0",
"author": "Your Name",
"type": "dash",
"capabilities": ["dash-tab"],
"component": "./Tab.qml",
"icon": "extension",
"dash": {
"title": "My Tab",
"options": [
{ "key": "compact", "text": "Compact layout", "type": "toggle", "def": false }
]
},
"requires_dms": ">=1.7.0"
}
@@ -59,6 +59,7 @@ PluginComponent {
// popoutContent: Component { // popoutContent: Component {
// PopoutComponent { // PopoutComponent {
// headerText: "My Widget" // headerText: "My Widget"
// showCloseButton: true
// //
// Column { // Column {
// width: parent.width // width: parent.width
@@ -43,7 +43,7 @@ PluginSettings {
pluginId: "exampleVariants" pluginId: "exampleVariants"
// Variant creation UI // Variant creation UI
DButton { DankButton {
text: "Add New Instance" text: "Add New Instance"
onClicked: { onClicked: {
var id = "variant_" + Date.now() var id = "variant_" + Date.now()
@@ -1,179 +0,0 @@
# Dash Plugin Guide
The dash popout (`dms ipc call dash open`) holds tabs and an overview grid of cards. A plugin can add a tab (`dash` surface), a card (`dashCard` surface), or both. Built-in tabs and cards use the same registry (`quickshell/Modules/DankDash/DashRegistry.qml`).
Requires DMS 1.7 or later: set `"requires_dms": ">=1.7.0"`.
## Picking the manifest form
| Plugin provides | Manifest |
|-----------------|----------|
| Tab only | `"type": "dash"`, `"component": "./Tab.qml"` |
| Card only | `"type": "dashCard"`, `"component": "./Card.qml"` |
| Tab and card | `"type": "composite"`, `"components": { "dash": ..., "dashCard": ... }` |
| Dash plus bar/dock widget, daemon, ... | `"type": "composite"` with every surface in `components` |
Add the optional `dash` block for labels, card size and options (see plugin-manifest-reference.md, Dash Block).
## Rules that trip people up
- Only enabled plugins appear in the dash. There is no "disabled" placeholder.
- A plugin tab shows in the tab bar as soon as the plugin loads. Cards are never placed automatically; users add them in edit mode (three-dot menu, Edit, Add widget).
- Tab id is `plugin_<pluginId>`. Use it with `dms ipc call dash open plugin_<pluginId>` and `popoutService.toggleDankDash("plugin_" + pluginId, x, y, width, section, screen)`.
- A tab is created when it becomes current and destroyed when the user leaves it. The dash content stays alive after the popout closes, so gate every Timer, animation and service ref on `live`.
- Widths are grid columns (user picks 3 to 8, default 6). Rows are 96 px (`DashMetrics.gridRowUnit`). Cards must lay out for every size in `minW..maxW` x `minH..maxH`.
- `dash.options` values reach only the dash surfaces as `options.<key>`. A bar widget or daemon in the same plugin cannot read them; use a `settings` component and `pluginData` for values every surface needs.
- `widgets` is a reserved option key.
- Use `I18n.trFor("<plugin id literal>", "...")` for every string.
## Tab: DashTabComponent
```qml
import QtQuick
import qs.Common
import qs.Widgets
import qs.DCommon.Widgets
import qs.Modules.Plugins
import qs.Modules.DankDash
DashTabComponent {
id: root
focusTarget: button
implicitHeight: Math.max(DashMetrics.tabMinHeight, content.implicitHeight + Theme.spacingL * 2)
menuActions: [
{
label: I18n.trFor("myPlugin", "Refresh"),
iconName: "refresh",
action: () => root.refresh()
}
]
function refresh() {
}
function restoreFocus() {
button.forceActiveFocus(Qt.OtherFocusReason);
}
function handleKeyEvent(event) {
if (event.key !== Qt.Key_R)
return false;
refresh();
return true;
}
Timer {
interval: 1000
repeat: true
running: root.live
onTriggered: root.refresh()
}
Column {
id: content
anchors.centerIn: parent
spacing: Theme.spacingM
StyledText {
text: root.options.compact ? "" : I18n.trFor("myPlugin", "Hello")
color: Theme.surfaceText
}
DButton {
id: button
text: I18n.trFor("myPlugin", "Refresh")
onClicked: root.refresh()
}
}
}
```
Injected: `pluginId`, `pluginService`, `popoutService`, `entryId`, `dashHost` (`dashVisible`, `requestTab(id)`, `editMode`), `editMode`, `live`. Also `targetScreen`, `active` and `rowBudget` when the tab declares them.
Base provides: `options`, `pluginData`, `getData(key, def)`, `setData(key, value)`.
Optional: `implicitHeight`, `focusTarget`, `restoreFocus()`, `handleKeyEvent(event)` (return true to consume; Ctrl+Tab is reserved), `blocksTabNavigation`, `menuActions`, `signal tabRequested(string id)`, `signal navFocusRequested`.
For tiles inside a tab, use `Card` from `qs.Modules.DankDash.Overview` so colors, radius and tones match the overview.
### Editable widgets in a tab
Assign a `DashWidgetGrid` (from `qs.Modules.DankDash`) to `widgetGrid` to get Edit, Add widget, Reset and Clear All:
```qml
DashTabComponent {
id: tab
widgetGrid: grid
implicitHeight: grid.implicitHeight
focusTarget: grid.focusTarget
DashWidgetGrid {
id: grid
width: parent.width
entryId: tab.entryId
live: tab.live
editMode: tab.editMode
definitions: [
{ id: "main", text: I18n.trFor("myPlugin", "Main"), icon: "star", component: mainWidget, w: 2, h: 2, minW: 1, minH: 1, maxW: 4, maxH: 3 }
]
}
Component {
id: mainWidget
Card {}
}
}
```
The grid is 4 columns (2 when narrower than `Theme.smallBreakpoint`). `enabled: false` on a definition leaves it in the Add menu. The layout is saved in `dashOptions[entryId].widgets`.
## Card: DashCardComponent
```qml
import QtQuick
import Quickshell
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
import qs.Modules.DankDash
DashCardComponent {
id: root
readonly property bool wide: width >= DashMetrics.gridRowUnit * 2
readonly property bool tall: height >= DashMetrics.gridRowUnit * 2
tone: options.tone ?? ""
SystemClock {
id: clock
enabled: root.live
precision: SystemClock.Minutes
}
StyledText {
anchors.centerIn: parent
text: clock.date.toLocaleTimeString(I18n.locale(), "hh:mm")
font.pixelSize: root.tall ? Theme.fontSizeDisplay : Theme.fontSizeXLarge
color: root.accentColor
}
}
```
Base (`Card`) provides: `pad`, `title`, `tone` (`""`, `"primary"`, `"secondary"`, `"tertiary"`), `containerColor`, `contentColor`, `accentColor`, `onAccentColor`, `mutedColor`, `chipColor`, `tinted`, `clickable`, `signal clicked`, `options`, `pluginData`, `getData`, `setData`.
Injected: `pluginId`, `pluginService`, `popoutService`, `entryId`; bound: `live` (overview visible), `interactive` (false in edit mode).
Clicks: when the plugin also has a tab, a click on a `clickable` card opens it. Set `opensTab: false` to handle the click yourself. Non-clickable cards are skipped by keyboard navigation unless `focusTarget` points at a child control.
Use the card colors, never raw `Theme.primary` or `Theme.surfaceText`, so tones work.
## Testing
```bash
dms ipc call plugins reload myPlugin
dms ipc call dash open plugin_myPlugin
```
Working examples in `quickshell/PLUGINS/`: `DashTabExample` (standalone tab), `DashCardExample` (standalone card), `DashCounterExample` (tab + card + bar/dock widget).
@@ -174,7 +174,7 @@ Retrieve a plugin's installation directory at runtime:
var dir = pluginService.getPluginPath(pluginId) var dir = pluginService.getPluginPath(pluginId)
``` ```
Returns the absolute path to the plugin's directory (e.g., `~/.config/dms/plugins/MyPlugin`), or an empty string if the plugin is not found. Useful for loading bundled assets (images, data files) relative to the plugin's location. Returns the absolute path to the plugin's directory (e.g., `~/.config/DankMaterialShell/plugins/MyPlugin`), or an empty string if the plugin is not found. Useful for loading bundled assets (images, data files) relative to the plugin's location.
## Important Notes ## Important Notes
@@ -9,7 +9,7 @@
| `description` | string | Short description (shown in UI) | Non-empty | | `description` | string | Short description (shown in UI) | Non-empty |
| `version` | string | Semantic version | Pattern `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$` | | `version` | string | Semantic version | Pattern `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$` |
| `author` | string | Creator name or email | Non-empty | | `author` | string | Creator name or email | Non-empty |
| `type` | string | Plugin type | One of: `widget`, `daemon`, `launcher`, `desktop`, `dash`, `dashCard`, `composite` | | `type` | string | Plugin type | One of: `widget`, `daemon`, `launcher`, `desktop`, `composite` |
| `capabilities` | array | Plugin capabilities | At least 1 string item | | `capabilities` | array | Plugin capabilities | At least 1 string item |
One of `component` or `components` is required (not both): One of `component` or `components` is required (not both):
@@ -17,7 +17,7 @@ One of `component` or `components` is required (not both):
| Field | Type | Description | Validation | | Field | Type | Description | Validation |
|-------|------|-------------|------------| |-------|------|-------------|------------|
| `component` | string | Path to main QML file (single-surface plugins) | Must start with `./`, end with `.qml` | | `component` | string | Path to main QML file (single-surface plugins) | Must start with `./`, end with `.qml` |
| `components` | object | Map of surface name to QML path (multi-surface plugins) | At least 1 entry; keys: `widget`, `desktop`, `daemon`, `launcher`, `dash`, `dashCard` | | `components` | object | Map of surface name to QML path (multi-surface plugins) | At least 1 entry; keys: `widget`, `desktop`, `daemon`, `launcher` |
## Conditional Requirements ## Conditional Requirements
@@ -38,7 +38,6 @@ One of `component` or `components` is required (not both):
| `requires` | array | Deprecated alias for `dependencies` | | `requires` | array | Deprecated alias for `dependencies` |
| `permissions` | array | Required permissions | | `permissions` | array | Required permissions |
| `trigger` | string | Launcher trigger string (required for launcher type) | | `trigger` | string | Launcher trigger string (required for launcher type) |
| `dash` | object | Dash tab label, overview card size range and dash options. See Dash Block below. |
## Permissions ## Permissions
@@ -60,8 +59,6 @@ Capabilities are free-form strings that describe what the plugin does. Common va
- `monitoring` - system/service monitoring - `monitoring` - system/service monitoring
- `launcher` - launcher search provider - `launcher` - launcher search provider
- `desktop-widget` - desktop background widget - `desktop-widget` - desktop background widget
- `dash-tab` - dash tab
- `dash-card` - dash overview card
- `ai` - AI/LLM integration - `ai` - AI/LLM integration
- `slideout` - uses slideout panel - `slideout` - uses slideout panel
@@ -122,68 +119,7 @@ The `components` field maps surface names to QML paths, allowing a single plugin
} }
``` ```
Valid surface keys: `widget`, `desktop`, `daemon`, `launcher`, `dash`, `dashCard`. Provide any subset. Each surface is loaded independently in the appropriate registry. Valid surface keys: `widget`, `desktop`, `daemon`, `launcher`. Provide any subset. Each surface is loaded independently in the appropriate registry.
## Dash Block
Plugins with a `dash` or `dashCard` surface can describe them in a `dash` object. Everything is optional.
```json
"dash": {
"icon": "timer",
"title": "Stopwatch",
"card": { "title": "Progress", "icon": "hourglass_bottom", "w": 2, "h": 1, "minW": 1, "minH": 1, "maxW": 4, "maxH": 2 },
"options": [
{ "key": "compact", "text": "Compact layout", "type": "toggle", "def": false, "description": "Hide the secondary line" },
{ "key": "style", "text": "Style", "type": "choice", "def": "bars", "choices": [{ "value": "bars", "text": "Bars" }, { "value": "dots", "text": "Dots" }] },
{ "key": "limit", "text": "Items", "type": "number", "def": 5, "min": 1, "max": 20, "step": 1 }
]
}
```
| Field | Description |
|-------|-------------|
| `icon`, `title` | Tab icon and label. Fall back to the top-level `icon` and `name` |
| `card.title`, `card.icon` | Card name and icon in the Add widget menu. Fall back to the tab label and icon |
| `card.w`, `card.h` | Default size in grid cells. Columns are 3 to 8 (default 6), rows are 96 px |
| `card.minW` .. `card.maxH` | Resize range. Widths clamp to the user's column count; heights have no fixed limit |
| `options` | Rows in the dash options sheet and Settings > Dashboard. Read as `options.<key>` in the tab and card. Types: `toggle`, `choice`, `number`. `widgets` is a reserved key |
## Dash Examples
Standalone tab and standalone card use the single file form:
```json
{
"id": "dashTabExample",
"name": "Stopwatch Example",
"description": "A standalone dash tab",
"version": "1.0.0",
"author": "Developer Name",
"type": "dash",
"capabilities": ["dash-tab"],
"component": "./StopwatchTab.qml",
"dash": { "title": "Stopwatch" },
"requires_dms": ">=1.7.0"
}
```
```json
{
"id": "dashCardExample",
"name": "Progress Example",
"description": "A standalone overview card",
"version": "1.0.0",
"author": "Developer Name",
"type": "dashCard",
"capabilities": ["dash-card"],
"component": "./ProgressCard.qml",
"dash": { "card": { "w": 2, "h": 1, "maxW": 4, "maxH": 2 } },
"requires_dms": ">=1.7.0"
}
```
A tab plus a card, or dash surfaces plus a bar widget, use `components` with `"type": "composite"`.
## Complete Example ## Complete Example
@@ -83,12 +83,11 @@ Theme.popupBackground() // Popup background with proper opacity
```qml ```qml
import qs.Widgets import qs.Widgets
import qs.DCommon.Widgets
Row { Row {
spacing: Theme.spacingS spacing: Theme.spacingS
DIcon { DankIcon {
name: "icon_name" name: "icon_name"
color: Theme.onSurface color: Theme.onSurface
font.pixelSize: Theme.iconSize font.pixelSize: Theme.iconSize
@@ -152,7 +151,7 @@ StyledRect {
anchors.centerIn: parent anchors.centerIn: parent
spacing: Theme.spacingS spacing: Theme.spacingS
DIcon { DankIcon {
name: "star" name: "star"
color: Theme.surfaceText color: Theme.surfaceText
font.pixelSize: Theme.iconSize font.pixelSize: Theme.iconSize
@@ -192,17 +191,17 @@ font.pixelSize: 14 // Use Theme.fontSizeMedium
|--------|-------------| |--------|-------------|
| `StyledText` | Themed text with proper color defaults | | `StyledText` | Themed text with proper color defaults |
| `StyledRect` | Themed rectangle | | `StyledRect` | Themed rectangle |
| `DIcon` | Material Symbols icon renderer | | `DankIcon` | Material Symbols icon renderer |
| `DankNFIcon` | Nerd Font icon renderer | | `DankNFIcon` | Nerd Font icon renderer |
| `DButton` | Themed button | | `DankButton` | Themed button |
| `DToggle` | Toggle switch | | `DankToggle` | Toggle switch |
| `DTextField` | Text input field | | `DankTextField` | Text input field |
| `DSlider` | Slider control | | `DankSlider` | Slider control |
| `DDropdown` | Dropdown menu | | `DankDropdown` | Dropdown menu |
| `DGridView` | Grid layout view | | `DankGridView` | Grid layout view |
| `DListView` | List layout view | | `DankListView` | List layout view |
| `DFlickable` | Scrollable container | | `DankFlickable` | Scrollable container |
| `DTabBar` | Tab bar navigation | | `DankTabBar` | Tab bar navigation |
| `DankCollapsibleSection` | Collapsible content section | | `DankCollapsibleSection` | Collapsible content section |
| `DankTooltip` | Hover tooltip | | `DankTooltip` | Hover tooltip |
| `DankNumberStepper` | Number +/- control | | `DankNumberStepper` | Number +/- control |
@@ -1,6 +1,6 @@
# Widget Plugin Guide # Widget Plugin Guide
Widgets display pills in DankBar and in docks (same component, no extra code), optionally open popouts, and can integrate with the Control Center. Widgets are bar plugins that display pills in DankBar, optionally open popouts, and can integrate with the Control Center.
## Base Component ## Base Component
@@ -37,8 +37,6 @@ These are automatically set by the plugin host:
| `pluginId` | string | This plugin's ID | | `pluginId` | string | This plugin's ID |
| `pluginService` | object | PluginService reference | | `pluginService` | object | PluginService reference |
| `pluginData` | object | Reactive plugin settings data | | `pluginData` | object | Reactive plugin settings data |
| `surfaceContext` | object | The host surface: `kind` (`"bar"` or `"dock"`), `id`, `screen`, `isVertical`, `thickness`, `live`, `editMode`, `inlineExpansion` |
| `widgetInstanceId` | string | This occurrence; the same widget can appear on several bars and docks |
## Bar Pills ## Bar Pills
@@ -59,7 +57,7 @@ horizontalBarPill: Component {
anchors.centerIn: parent anchors.centerIn: parent
spacing: Theme.spacingS spacing: Theme.spacingS
DIcon { DankIcon {
name: "star" name: "star"
color: Theme.surfaceText color: Theme.surfaceText
font.pixelSize: Theme.iconSize font.pixelSize: Theme.iconSize
@@ -92,7 +90,7 @@ verticalBarPill: Component {
anchors.centerIn: parent anchors.centerIn: parent
spacing: Theme.spacingS spacing: Theme.spacingS
DIcon { DankIcon {
name: "star" name: "star"
color: Theme.surfaceText color: Theme.surfaceText
font.pixelSize: Theme.iconSizeSmall font.pixelSize: Theme.iconSizeSmall
@@ -117,6 +115,7 @@ PluginComponent {
PopoutComponent { PopoutComponent {
headerText: "My Plugin" headerText: "My Plugin"
detailsText: "Optional subtitle" detailsText: "Optional subtitle"
showCloseButton: true
Column { Column {
width: parent.width width: parent.width
@@ -139,6 +138,7 @@ PluginComponent {
|----------|------|---------|-------------| |----------|------|---------|-------------|
| `headerText` | string | `""` | Main header (bold, large). Hidden if empty. | | `headerText` | string | `""` | Main header (bold, large). Hidden if empty. |
| `detailsText` | string | `""` | Subtitle below header. Hidden if empty. | | `detailsText` | string | `""` | Subtitle below header. Hidden if empty. |
| `showCloseButton` | bool | `false` | Show X button in top-right corner. |
| `closePopout` | function | (injected) | Call to close the popout programmatically. | | `closePopout` | function | (injected) | Call to close the popout programmatically. |
| `headerHeight` | int | (readonly) | Height of header area (0 if hidden). | | `headerHeight` | int | (readonly) | Height of header area (0 if hidden). |
| `detailsHeight` | int | (readonly) | Height of details area (0 if hidden). | | `detailsHeight` | int | (readonly) | Height of details area (0 if hidden). |
@@ -241,49 +241,16 @@ PluginComponent {
**Bar reveal optimization:** The visibility timer automatically pauses while the bar is hidden (auto-hide mode) and resumes checks when the bar is revealed. This is handled via the internal `_barRevealed` property - no plugin code needed. Plugins using `visibilityCommand` with `visibilityInterval` benefit from this automatically. **Bar reveal optimization:** The visibility timer automatically pauses while the bar is hidden (auto-hide mode) and resumes checks when the bar is revealed. This is handled via the internal `_barRevealed` property - no plugin code needed. Plugins using `visibilityCommand` with `visibilityInterval` benefit from this automatically.
## Docks
Users add any loaded widget plugin to a dock from the dock's edit mode or Settings > Dock > Apps & widgets. Nothing in the manifest is needed.
- Define both `horizontalBarPill` and `verticalBarPill`; docks sit on any edge.
- Size icons with `root.iconSize` and text with `Theme.barTextSize(barThickness, barConfig?.fontScale, barConfig?.maximizeWidgetText)`. The dock hands its own thickness and scale in those properties.
- Gate timers and animations on `surfaceLive` (host visible and `visibilityCommand` passing). Docks auto-hide.
- Don't assume one instance. Use `widgetInstanceId` if an instance needs its own state.
Optional attached panel: set `attachedContent: Component { ... }`. When the dock's "Open widgets" setting is Inline (`surfaceContext.inlineExpansion`), a click grows the dock and shows the panel next to the apps. With Popout (the default) the normal `popoutContent` opens instead; a widget with no popout still expands inline. Bars always use `popoutContent`. A `pillClickAction` always wins over both.
```qml
PluginComponent {
id: root
attachedContent: Component {
Column {
padding: Theme.spacingM
spacing: Theme.spacingM
DButton {
text: I18n.trFor("myPlugin", "Close")
onClicked: root.surfaceContext.dismissExpansion()
}
}
}
}
```
The panel spans the dock's length and grows up to 320 px away from the edge (at most half the screen). The plugin cannot size it. `attachedActive` is true while this widget owns the panel. Escape closes it. See `quickshell/PLUGINS/AttachedPanelExample` and `quickshell/PLUGINS/SURFACE_WIDGETS.md`.
## Popout Namespace ## Popout Namespace
For plugins with multiple popout instances, use `layerNamespacePlugin` to isolate popout state: For plugins with multiple popout instances, use `layerNamespacePlugin` to isolate popout state:
```qml ```qml
PluginComponent { PluginComponent {
layerNamespacePlugin: "my-plugin" layerNamespacePlugin: true
} }
``` ```
The shell prefixes it, so the popout's namespace becomes `dms:plugins:my-plugin`.
## Reading Plugin Data ## Reading Plugin Data
Access saved settings reactively via the injected `pluginData`: Access saved settings reactively via the injected `pluginData`:
@@ -311,7 +278,6 @@ import QtQuick
import Quickshell import Quickshell
import qs.Common import qs.Common
import qs.Widgets import qs.Widgets
import qs.DCommon.Widgets
import qs.Services import qs.Services
import qs.Modules.Plugins import qs.Modules.Plugins
@@ -367,8 +333,9 @@ PluginComponent {
popoutContent: Component { popoutContent: Component {
PopoutComponent { PopoutComponent {
headerText: "Emoji Picker" headerText: "Emoji Picker"
showCloseButton: true
DGridView { DankGridView {
width: parent.width width: parent.width
height: 300 height: 300
cellWidth: 50 cellWidth: 50
+1 -1
View File
@@ -3,7 +3,7 @@
github: [avengemedia] github: [avengemedia]
patreon: # Replace with a single Patreon username patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username open_collective: # Replace with a single Open Collective username
ko_fi: duxlabs ko_fi: danklinux
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username liberapay: # Replace with a single Liberapay username
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
## Dux Shell Bug Report ## DankMaterialShell Bug Report
Limit your report to one issue per submission unless similarly related Limit your report to one issue per submission unless similarly related
- type: dropdown - type: dropdown
id: compositor id: compositor
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
## Dux Shell Feature Request ## DankMaterialShell Feature Request
- type: textarea - type: textarea
id: feature_description id: feature_description
attributes: attributes:
+2 -2
View File
@@ -1,12 +1,12 @@
name: Support Request name: Support Request
description: Help with installation, usage, or general questions about Dux Shell description: Help with installation, usage, or general questions about DankMaterialShell
labels: labels:
- support - support
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
## Dux Shell Support Request ## DankMaterialShell Support Request
- type: dropdown - type: dropdown
id: compositor id: compositor
attributes: attributes:
+1 -1
View File
@@ -28,4 +28,4 @@
- [ ] New user-facing strings are wrapped in `I18n.tr()` with translator context, reusing existing terms where possible - [ ] New user-facing strings are wrapped in `I18n.tr()` with translator context, reusing existing terms where possible
- [ ] Go changes: ran `make fmt`, added/updated tests, `make test` passes, and `go mod tidy` is clean - [ ] Go changes: ran `make fmt`, added/updated tests, `make test` passes, and `go mod tidy` is clean
- [ ] QML changes: ran `make lint-qml` with no new warnings - [ ] QML changes: ran `make lint-qml` with no new warnings
- [ ] I have opened a corresponding pull request in dlx-docs to document any new behaviors: https://github.com/duxlabs-dev/dux-docs - [ ] I have opened a corresponding pull request in dlx-docs to document any new behaviors: https://github.com/AvengeMedia/DankLinux-Docs
-23
View File
@@ -1,23 +0,0 @@
# Categories for GitHub's auto-generated release notes
changelog:
exclude:
authors:
- github-actions[bot]
- dependabot[bot]
labels:
- internal
categories:
- title: Breaking Changes
labels: [breaking]
- title: Features
labels: [feature]
- title: Fixes
labels: [fix]
- title: Packaging
labels: [packaging]
- title: Internationalization
labels: [i18n]
- title: Documentation
labels: [docs]
- title: Other Changes
labels: ["*"]
-150
View File
@@ -1,150 +0,0 @@
#!/usr/bin/env bash
# Prunes retired XBPS packages from the R2 bucket so it stays inside the
# Cloudflare free tier. Only objects under ARCHIVE_PREFIX are eligible; live
# repositories (<repo>/current/) are never touched.
#
# publish-void-r2.sh writes retired packages to archive/<repo>/<published_at>/.
# Snapshots are dropped oldest first:
# 1. snapshots older than KEEP_DAYS, keeping KEEP_MIN_SNAPSHOTS per repo
# 2. then more until the whole bucket is <= MAX_BUCKET_GB
set -euo pipefail
require_env() {
local name
for name in "$@"; do
if [[ -z "${!name:-}" ]]; then
echo "error: $name is required" >&2
exit 2
fi
done
}
require_env R2_BUCKET R2_ENDPOINT
ARCHIVE_PREFIX="${ARCHIVE_PREFIX:-archive/}"
MAX_BUCKET_GB="${MAX_BUCKET_GB:-8}"
KEEP_DAYS="${KEEP_DAYS:-30}"
KEEP_MIN_SNAPSHOTS="${KEEP_MIN_SNAPSHOTS:-2}"
DRY_RUN="${DRY_RUN:-false}"
case "$ARCHIVE_PREFIX" in
*/) ;;
*) echo "error: ARCHIVE_PREFIX must end with /" >&2; exit 2 ;;
esac
aws_r2() {
aws --endpoint-url "$R2_ENDPOINT" "$@"
}
human() {
numfmt --to=iec-i --suffix=B "$1"
}
objects="$(aws_r2 s3api list-objects-v2 \
--bucket "$R2_BUCKET" \
--output json \
--query 'Contents[].{key:Key,size:Size}')"
[[ -n "$objects" && "$objects" != null ]] || objects='[]'
total="$(jq 'map(.size) | add // 0' <<<"$objects")"
budget="$(awk -v g="$MAX_BUCKET_GB" 'BEGIN { printf "%d", g * 1073741824 }')"
now="$(date -u +%s)"
snapshots="$(jq -c --arg p "$ARCHIVE_PREFIX" '
map(select(.key | startswith($p)))
| map(.rel = (.key[($p | length):] | split("/")))
| map(select((.rel | length) >= 3))
| group_by(.rel[0:2])
| map({
repo: .[0].rel[0],
stamp: .[0].rel[1],
prefix: ($p + (.[0].rel[0:2] | join("/")) + "/"),
size: (map(.size) | add),
count: length
})
| sort_by(.stamp)
' <<<"$objects")"
to_delete="$(jq -c \
--argjson now "$now" \
--argjson keepDays "$KEEP_DAYS" \
--argjson keepMin "$KEEP_MIN_SNAPSHOTS" \
--argjson total "$total" \
--argjson budget "$budget" '
def epoch:
.stamp
| sub("T(?<h>[0-9]{2})-(?<m>[0-9]{2})-(?<s>[0-9]{2})Z$"; "T\(.h):\(.m):\(.s)Z")
| try fromdateiso8601 catch null;
(group_by(.repo) | map({key: .[0].repo, value: length}) | from_entries) as $perRepo
| reduce .[] as $s ({kept: $perRepo, total: $total, delete: []};
($s | epoch) as $e
| (.kept[$s.repo] > $keepMin and $e != null and ($now - $e) > $keepDays * 86400) as $expired
| (.total > $budget) as $over
| if $expired or $over then
.delete += [$s + {reason: (if $over then "budget" else "age" end)}]
| .total -= $s.size
| .kept[$s.repo] -= 1
else . end)
| .delete
' <<<"$snapshots")"
echo "bucket: $R2_BUCKET ($(human "$total") in $(jq length <<<"$objects") objects)"
echo "budget: $(human "$budget") (MAX_BUCKET_GB=$MAX_BUCKET_GB)"
echo "archive: $(jq length <<<"$snapshots") snapshots, $(human "$(jq 'map(.size) | add // 0' <<<"$snapshots")")"
echo "policy: KEEP_DAYS=$KEEP_DAYS KEEP_MIN_SNAPSHOTS=$KEEP_MIN_SNAPSHOTS DRY_RUN=$DRY_RUN"
deleted=0
freed=0
while IFS=$'\t' read -r prefix size count reason; do
[[ -n "$prefix" ]] || continue
# Belt and braces: never delete outside the archive or anything live.
case "$prefix" in
"$ARCHIVE_PREFIX"*) ;;
*) echo "error: refusing to delete $prefix (outside $ARCHIVE_PREFIX)" >&2; exit 1 ;;
esac
case "$prefix" in
*/current/*) echo "error: refusing to delete live prefix $prefix" >&2; exit 1 ;;
esac
if [[ "$DRY_RUN" == "true" ]]; then
echo "would delete $prefix ($count objects, $(human "$size"), $reason)"
else
echo "deleting $prefix ($count objects, $(human "$size"), $reason)"
aws_r2 s3 rm "s3://${R2_BUCKET}/${prefix}" --recursive --only-show-errors
fi
deleted=$((deleted + 1))
freed=$((freed + size))
done < <(jq -r '.[] | [.prefix, .size, .count, .reason] | @tsv' <<<"$to_delete")
remaining=$((total - freed))
echo "pruned: $deleted snapshots, $(human "$freed") freed, $(human "$remaining") remaining"
if (( remaining > budget )); then
echo "::warning::bucket is still $(human "$remaining") after pruning (budget $(human "$budget")); live repositories exceed the budget"
fi
note=""
if [[ "$DRY_RUN" == "true" ]]; then
note=" (dry run)"
fi
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
{
echo "### R2 prune: \`$R2_BUCKET\`"
echo
echo "| | |"
echo "|---|---|"
echo "| Before | $(human "$total") |"
echo "| Budget | $(human "$budget") |"
echo "| Snapshots pruned | $deleted$note |"
echo "| Freed | $(human "$freed") |"
echo "| After | $(human "$remaining") |"
if (( deleted > 0 )); then
echo
echo "| Snapshot | Objects | Bytes | Reason |"
echo "|---|---|---|---|"
jq -r '.[] | "| `\(.prefix)` | \(.count) | \(.size) | \(.reason) |"' <<<"$to_delete"
fi
} >> "$GITHUB_STEP_SUMMARY"
fi
-170
View File
@@ -1,170 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "usage: $0 download|publish" >&2
exit 2
}
require_env() {
local name
for name in "$@"; do
if [[ -z "${!name:-}" ]]; then
echo "error: $name is required" >&2
exit 2
fi
done
}
require_env R2_BUCKET R2_ENDPOINT R2_PREFIX REPOSITORY_DIR
aws_r2() {
aws --endpoint-url "$R2_ENDPOINT" "$@"
}
download() {
mkdir -p "$REPOSITORY_DIR/current" "$REPOSITORY_DIR/previous-current"
aws_r2 s3 sync \
"s3://${R2_BUCKET}/${R2_PREFIX}/current/" \
"$REPOSITORY_DIR/current/" \
--only-show-errors
cp -a "$REPOSITORY_DIR/current/." "$REPOSITORY_DIR/previous-current/"
}
build_manifest() {
local packages='[]'
local file filename pkgver name version revision sha size
for file in "$REPOSITORY_DIR"/current/*.xbps; do
filename="$(basename "$file")"
pkgver="$(xbps-uhelper binpkgver "$filename")"
name="$(xbps-uhelper getpkgname "$pkgver")"
version="$(xbps-uhelper getpkgversion "$pkgver")"
revision="$(xbps-uhelper getpkgrevision "$pkgver")"
version="${version%_"${revision}"}"
sha="$(sha256sum "$file" | cut -d' ' -f1)"
size="$(stat -c '%s' "$file")"
packages="$(jq \
--arg name "$name" \
--arg version "$version" \
--arg revision "$revision" \
--arg filename "$filename" \
--arg sha256 "$sha" \
--argjson size "$size" \
'. + [{name: $name, version: $version, revision: $revision, filename: $filename, sha256: $sha256, size: $size}]' \
<<<"$packages")"
done
jq -n \
--arg repository "$R2_PREFIX" \
--arg source_commit "$SOURCE_COMMIT" \
--arg published_at "$PUBLISHED_AT" \
--argjson packages "$packages" \
'{schema: 1, repository: $repository, source_commit: $source_commit, published_at: $published_at, packages: $packages}' \
> "$REPOSITORY_DIR/current/manifest.json"
}
verify_immutable_packages() {
local file previous
for file in "$REPOSITORY_DIR"/current/*.xbps "$REPOSITORY_DIR"/current/*.sig2; do
previous="$REPOSITORY_DIR/previous-current/$(basename "$file")"
if [[ -f "$previous" ]] && ! cmp -s "$previous" "$file"; then
echo "error: refusing to replace immutable object $(basename "$file")" >&2
echo "bump the XBPS revision or version before publishing a changed build" >&2
exit 1
fi
done
}
archive_retired() {
local old filename
local archive_prefix="archive/${R2_PREFIX}/${PUBLISHED_AT//:/-}"
shopt -s nullglob
for old in "$REPOSITORY_DIR"/previous-current/*.xbps "$REPOSITORY_DIR"/previous-current/*.sig2; do
filename="$(basename "$old")"
if [[ ! -e "$REPOSITORY_DIR/current/$filename" ]]; then
aws_r2 s3 cp \
"$old" \
"s3://${R2_BUCKET}/${archive_prefix}/${filename}" \
--cache-control 'private,no-store' \
--only-show-errors
fi
done
}
upload_current() {
local file filename old
# Versioned package objects must exist before repodata can reference them.
for file in "$REPOSITORY_DIR"/current/*.xbps "$REPOSITORY_DIR"/current/*.sig2; do
filename="$(basename "$file")"
aws_r2 s3 cp \
"$file" \
"s3://${R2_BUCKET}/${R2_PREFIX}/current/${filename}" \
--cache-control 'public,max-age=31536000,immutable' \
--only-show-errors
done
aws_r2 s3 cp \
"$REPOSITORY_DIR/current/x86_64-repodata" \
"s3://${R2_BUCKET}/${R2_PREFIX}/current/x86_64-repodata" \
--cache-control 'no-cache' \
--only-show-errors
# The manifest is the publication marker and is always uploaded last.
aws_r2 s3 cp \
"$REPOSITORY_DIR/current/manifest.json" \
"s3://${R2_BUCKET}/${R2_PREFIX}/current/manifest.json" \
--cache-control 'no-cache' \
--only-show-errors
# Once the new index and marker are live, remove objects no longer referenced.
shopt -s nullglob
for old in "$REPOSITORY_DIR"/previous-current/*; do
filename="$(basename "$old")"
if [[ ! -e "$REPOSITORY_DIR/current/$filename" ]]; then
aws_r2 s3 rm \
"s3://${R2_BUCKET}/${R2_PREFIX}/current/${filename}" \
--only-show-errors
fi
done
}
publish() {
require_env SOURCE_COMMIT
PUBLISHED_AT="${PUBLISHED_AT:-$(date -u +'%Y-%m-%dT%H:%M:%SZ')}"
export PUBLISHED_AT
shopt -s nullglob
local packages=("$REPOSITORY_DIR"/current/*.xbps)
if (( ${#packages[@]} == 0 )); then
echo "error: refusing to publish an empty XBPS repository" >&2
exit 1
fi
[[ -s "$REPOSITORY_DIR/current/x86_64-repodata" ]] || {
echo "error: x86_64-repodata is missing or empty" >&2
exit 1
}
for file in "${packages[@]}"; do
[[ -s "${file}.sig2" ]] || {
echo "error: signature is missing for $(basename "$file")" >&2
exit 1
}
done
verify_immutable_packages
build_manifest
archive_retired
upload_current
}
case "${1:-}" in
download) download ;;
publish) publish ;;
*) usage ;;
esac
-140
View File
@@ -1,140 +0,0 @@
name: Claude Review
# `/claude review` on a PR by a member of an allowed team. Review only, no edits or pushes.
# Secrets: CLAUDE_CODE_OAUTH_TOKEN (claude setup-token), CLAUDE_REVIEW_ORG_READ_TOKEN (PAT, org Members: read)
on:
issue_comment:
types: [created]
permissions: {}
jobs:
gate:
if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/claude review')
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
outputs:
allowed: ${{ steps.team.outputs.allowed }}
steps:
- name: Check team membership
id: team
env:
GH_TOKEN: ${{ secrets.CLAUDE_REVIEW_ORG_READ_TOKEN }}
ORG: ${{ github.repository_owner }}
USER: ${{ github.event.comment.user.login }}
TEAMS: dms-collaborators owners
run: |
allowed=false
for team in $TEAMS; do
state=$(gh api "orgs/$ORG/teams/$team/memberships/$USER" --jq .state 2>/dev/null || true)
[ "$state" = active ] && allowed=true && break
done
echo "allowed=$allowed" >> "$GITHUB_OUTPUT"
- name: React
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ALLOWED: ${{ steps.team.outputs.allowed }}
COMMENT_ID: ${{ github.event.comment.id }}
run: |
[ "$ALLOWED" = true ] && content=eyes || content=-1
gh api -X POST "repos/${{ github.repository }}/issues/comments/$COMMENT_ID/reactions" -f "content=$content"
review:
needs: gate
if: needs.gate.outputs.allowed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: claude-review-${{ github.event.issue.number }}
cancel-in-progress: false
permissions:
contents: read
pull-requests: write
issues: write
id-token: write
steps:
- uses: actions/checkout@v6
with:
ref: refs/pull/${{ github.event.issue.number }}/head
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
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
Review pull request #${{ github.event.issue.number }} in ${{ github.repository }}, requested by @${{ github.event.comment.user.login }}:
---
${{ github.event.comment.body }}
---
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.
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.
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:
## Claude review
<one line verdict>
- <finding, file:line> per inline comment posted this run
Checked: <one line>. Model: claude-opus-5-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.
- 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.
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.
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.
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:*)"
--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 }}
run: |
gh pr comment ${{ github.event.issue.number }} --body "Claude review failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
-57
View File
@@ -1,57 +0,0 @@
name: Sync flake.lock to dux-qml-common
on:
workflow_dispatch:
push:
paths:
- "dux-qml-common"
branches:
- master
- "stable-*"
permissions:
contents: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}
- name: Install Nix
uses: cachix/install-nix-action@v31
- name: Point flake input at the submodule commit
run: |
set -euo pipefail
submodule_rev=$(git ls-tree HEAD dux-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"
- name: Commit and push flake.lock update
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
set -euo pipefail
if git diff --quiet flake.lock; then
echo "No changes to flake.lock"
exit 0
fi
git config user.name "dux-ci[bot]"
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com"
git add flake.lock
git commit -m "nix: sync flake.lock to dux-qml-common submodule"
git pull --rebase origin ${{ github.ref_name }}
git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:${{ github.ref_name }}
+2 -55
View File
@@ -7,17 +7,14 @@ on:
permissions: permissions:
contents: write contents: write
issues: write
jobs: jobs:
update-stable: update-stable:
# skip prerelease tags
if: ${{ !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Create GitHub App token - name: Create GitHub App token
id: app_token id: app_token
uses: actions/create-github-app-token@v3 uses: actions/create-github-app-token@v1
with: with:
app-id: ${{ secrets.APP_ID }} app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }} private-key: ${{ secrets.APP_PRIVATE_KEY }}
@@ -31,54 +28,4 @@ jobs:
- name: Push to stable branch - name: Push to stable branch
env: env:
GH_TOKEN: ${{ steps.app_token.outputs.token }} GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: | run: git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:refs/heads/stable --force
set -euo pipefail
# don't roll stable backwards
if git fetch origin stable --quiet 2>/dev/null; then
stable_tag=$(git describe --tags --abbrev=0 FETCH_HEAD 2>/dev/null || echo "v0.0.0")
newest=$(printf '%s\n%s\n' "$stable_tag" "${GITHUB_REF_NAME}" | sort -V | tail -1)
if [ "$newest" != "${GITHUB_REF_NAME}" ]; then
echo "skipping: ${GITHUB_REF_NAME} is older than stable (${stable_tag})"
exit 0
fi
fi
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" HEAD:refs/heads/stable --force
cut-release-branch:
# create stable-X.Y at each vX.Y.0 tag
if: ${{ !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}
- name: Create release branch
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
set -euo pipefail
if [[ ! "${GITHUB_REF_NAME}" =~ ^v([0-9]+)\.([0-9]+)\.0$ ]]; then
echo "not a vX.Y.0 tag, no release branch to cut"
exit 0
fi
branch="stable-${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
if git ls-remote --exit-code origin "refs/heads/${branch}" >/dev/null 2>&1; then
echo "${branch} already exists"
exit 0
fi
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" "HEAD:refs/heads/${branch}"
echo "created ${branch} at ${GITHUB_REF_NAME}"
GH_TOKEN="${{ secrets.GITHUB_TOKEN }}" gh label create "port ${branch}" \
--repo "${{ github.repository }}" \
--description "Auto-port merged PR to ${branch}" --color 0e8a16 ||
echo "label 'port ${branch}' may already exist"
+8 -49
View File
@@ -8,7 +8,7 @@ on:
- "core/**" - "core/**"
- ".github/workflows/go-ci.yml" - ".github/workflows/go-ci.yml"
pull_request: pull_request:
branches: [master, main, "stable-*"] branches: [master, main]
paths: paths:
- "core/**" - "core/**"
- ".github/workflows/go-ci.yml" - ".github/workflows/go-ci.yml"
@@ -27,70 +27,29 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
with:
submodules: recursive
- name: Install flatpak - name: Install flatpak
run: sudo apt-get update && sudo apt-get install -y flatpak run: sudo apt update && sudo apt install -y flatpak
- name: Cache flatpak runtime - name: Add flathub
uses: actions/cache@v4 run: sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
with:
path: ~/.local/share/flatpak
key: flatpak-org.freedesktop.Platform-24.08
- name: Install a flatpak for tests - name: Add a flatpak that mutagen could support
run: | run: sudo flatpak install -y org.freedesktop.Platform/x86_64/24.08 app.zen_browser.zen
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak info org.freedesktop.Platform//24.08 >/dev/null 2>&1 || \
flatpak install --user -y org.freedesktop.Platform//24.08
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version-file: ./core/go.mod go-version-file: ./core/go.mod
cache-dependency-path: ./core/go.sum
- name: Test - name: Test
run: go test -race -v ./... run: go test -v ./...
- name: Build dms - name: Build dms
run: go build -v ./cmd/dms run: go build -v ./cmd/dms
- name: Build dms (embedded shell)
run: make build
- name: Build dms (distropkg) - name: Build dms (distropkg)
run: go build -v -tags 'distro_binary withshell' ./cmd/dms run: go build -v -tags distro_binary ./cmd/dms
- name: Build dankinstall - name: Build dankinstall
run: go build -v ./cmd/dankinstall run: go build -v ./cmd/dankinstall
build-freebsd:
runs-on: ubuntu-latest
defaults:
run:
working-directory: core
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: ./core/go.mod
cache-dependency-path: ./core/go.sum
- name: Build all packages
env:
GOOS: freebsd
CGO_ENABLED: 0
run: go build -v ./...
- name: Build dms (distropkg, embedded shell)
run: |
make sync-shell
GOOS=freebsd CGO_ENABLED=0 go build -v -tags 'distro_binary withshell' ./cmd/dms
+3 -1
View File
@@ -2,7 +2,7 @@ name: Nix flake and NixOS tests
on: on:
pull_request: pull_request:
branches: [master, main, "stable-*"] branches: [master, main]
paths: paths:
- "flake.*" - "flake.*"
- "distro/nix/**" - "distro/nix/**"
@@ -33,6 +33,8 @@ jobs:
- name: Run NixOS service start test - name: Run NixOS service start test
run: nix build .#nixosTests.x86_64-linux.nixos-service-start-module -L run: nix build .#nixosTests.x86_64-linux.nixos-service-start-module -L
- name: Run greeter niri test
run: nix build .#nixosTests.x86_64-linux.greeter-niri-module -L
- name: Run home-manager module test - name: Run home-manager module test
run: nix build .#nixosTests.x86_64-linux.home-manager-module -L run: nix build .#nixosTests.x86_64-linux.home-manager-module -L
-107
View File
@@ -1,107 +0,0 @@
name: Point release
# Cuts vX.Y.Z from stable-X.Y: runs the port audit (warn-only), bumps
# quickshell/VERSION, tags, and dispatches the Release workflow. Distro
# builds are dispatched separately.
on:
workflow_dispatch:
inputs:
version:
description: "Point release version (e.g. 1.5.1)"
required: true
type: string
notify_issues:
description: "Comment on open related/fixes issues asking reporters to retest"
required: false
type: boolean
default: true
permissions:
contents: write
actions: write
concurrency:
group: point-release
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
env:
VERSION: ${{ inputs.version }}
steps:
- name: Validate version and derive branch
id: derive
run: |
set -euo pipefail
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::version must be X.Y.Z (got '$VERSION')"; exit 1
fi
echo "branch=stable-${VERSION%.*}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Checkout release branch
uses: actions/checkout@v6
with:
ref: ${{ steps.derive.outputs.branch }}
fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}
- name: Preflight — verify Release workflow is dispatchable
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh api "repos/${{ github.repository }}/actions/workflows/release.yml" \
--jq '.state' | grep -qx active ||
{ echo "::error::release.yml is not dispatchable; aborting before any push"; exit 1; }
- name: Port audit (informational)
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
bash scripts/port-audit.sh "${{ steps.derive.outputs.branch }}" ||
echo "::warning::port audit failed; continuing"
- name: Bump VERSION, tag, and push
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: ${{ steps.derive.outputs.tag }}
BRANCH: ${{ steps.derive.outputs.branch }}
run: |
set -euo pipefail
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"
echo "${TAG}" > quickshell/VERSION
git add quickshell/VERSION
git commit -m "bump VERSION to ${TAG}"
git tag "${TAG}"
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" "HEAD:${BRANCH}" "refs/tags/${TAG}"
- name: Dispatch Release workflow
env:
GH_TOKEN: ${{ github.token }}
run: |
gh workflow run release.yml --ref "${{ steps.derive.outputs.tag }}" \
-f tag="${{ steps.derive.outputs.tag }}" \
-f notify_issues="${{ inputs.notify_issues }}"
- name: Next steps
run: |
{
echo "## ${{ steps.derive.outputs.tag }} tagged on ${{ steps.derive.outputs.branch }} — Release workflow dispatched"
echo ""
echo "Distro builds are manual: run the per-distro workflows (COPR/OBS/PPA/XBPS) once the release is published."
} >> "$GITHUB_STEP_SUMMARY"
-30
View File
@@ -1,30 +0,0 @@
name: Port audit
# On-demand report of master commits not yet ported to a release branch.
# Updates the "Port status: <branch>" tracking issue and the step summary.
on:
workflow_dispatch:
inputs:
target:
description: "Release branch to audit (default: newest stable-*)"
required: false
type: string
permissions:
contents: read
issues: write
jobs:
audit:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Run audit
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash scripts/port-audit.sh ${{ inputs.target }} --issue
-101
View File
@@ -1,101 +0,0 @@
name: Port to release branch
# Ports flagged commits from master onto stable-X.Y branches:
# - "port 1.5" flag in a commit message pushed to master — own line or
# mid-line; separators : / - or space; "stable-1.5" also accepted;
# comma lists ("Port: 1.5, 1.4") work in the own-line form
# - "port stable-1.5" label on a merged PR
# Conflicts are reported to the "Port status: <branch>" tracking issue.
on:
push:
branches: [master]
pull_request_target:
types: [closed, labeled]
permissions:
contents: write
issues: write
concurrency:
group: port-engine
cancel-in-progress: false
jobs:
port:
name: Port flagged commits
runs-on: ubuntu-latest
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
# Base-repo code only; PR head code is never checked out or executed.
- name: Checkout
uses: actions/checkout@v6
with:
ref: master
fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}
- name: Port trailer-flagged commits
if: github.event_name == 'push'
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
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"
for sha in $(jq -r '.[].id' <<<"$COMMITS"); do
git cat-file -e "$sha" 2>/dev/null || continue
# skip merge commits (handled by the label path)
[ "$(git rev-list --no-walk --count --min-parents=2 "$sha")" -eq 0 ] || continue
# own-line form (supports comma lists), validated version-shaped
t1=$(git log -1 --format=%B "$sha" |
{ grep -iE '^Port[:/ -]' || true; } | sed -E 's|^port[-:/ ]+||I' | tr ',' '\n' |
sed 's/[[:space:]]//g; /^$/d' | sed -E 's,^stable-,,I' |
{ grep -E '^[0-9]+\.[0-9]+(\.[0-9]+)?$' || true; })
# mid-line form, e.g. "fix thing fixes #2802 port 1.5"
t2=$(git log -1 --format=%B "$sha" |
{ grep -oiE '\bport[-: /]+(stable-)?[0-9]+\.[0-9]+(\.[0-9]+)?\b' || true; } |
sed -E 's|^port[-:/ ]+||I' | sed -E 's,^stable-,,I')
targets=$(printf '%s\n%s\n' "$t1" "$t2" | sed '/^$/d' | sort -u)
for ver in $targets; do
echo "::group::port $sha -> stable-$ver"
bash scripts/port.sh "stable-$ver" "$sha"
echo "::endgroup::"
done
done
- name: Port label-flagged PR
if: >
github.event_name == 'pull_request_target' &&
github.event.pull_request.merged == true &&
(github.event.action == 'closed' ||
(github.event.action == 'labeled' && startsWith(github.event.label.name, 'port ')))
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
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"
targets=$(jq -r '.[] | select(startswith("port ")) | sub("^port +"; "")' <<<"$LABELS" |
sed -E 's,^stable-,,' | sort -u)
[ -n "$targets" ] || { echo "no port labels, nothing to do"; exit 0; }
[ -n "$MERGE_SHA" ] || { echo "::error::PR has no merge commit sha"; exit 1; }
for ver in $targets; do
echo "::group::port PR #${PORT_SOURCE_PR} ($MERGE_SHA) -> stable-$ver"
bash scripts/port.sh "stable-$ver" "$MERGE_SHA"
echo "::endgroup::"
done
+1 -10
View File
@@ -3,15 +3,13 @@ name: Pre-commit Checks
on: on:
push: push:
pull_request: pull_request:
branches: [master, main, "stable-*"] branches: [master, main]
jobs: jobs:
pre-commit-check: pre-commit-check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
with:
submodules: recursive
- name: Install flatpak - name: Install flatpak
run: sudo apt update && sudo apt install -y flatpak run: sudo apt update && sudo apt install -y flatpak
@@ -26,13 +24,6 @@ jobs:
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version-file: core/go.mod go-version-file: core/go.mod
cache-dependency-path: core/go.sum
- name: Download Go modules
working-directory: core
run: for attempt in 1 2 3; do go mod download && exit 0; sleep 15; done; exit 1
- name: run pre-commit hooks - name: run pre-commit hooks
uses: j178/prek-action@v2 uses: j178/prek-action@v2
env:
SKIP: qml-tests
-66
View File
@@ -1,66 +0,0 @@
name: Prune Void R2 archive
# Keeps the danklinux-void bucket inside the Cloudflare R2 free tier by
# deleting the oldest archive/<repo>/<timestamp>/ snapshots written by
# publish-void-r2.sh. Live repositories (<repo>/current/) are never touched.
on:
schedule:
- cron: "30 6 * * *"
workflow_dispatch:
inputs:
dry_run:
description: 'Report what would be deleted without deleting'
type: boolean
required: false
default: false
max_bucket_gb:
description: 'Total bucket size to prune down to (GB)'
type: string
required: false
default: '8'
keep_days:
description: 'Delete archive snapshots older than this many days'
type: string
required: false
default: '30'
keep_min_snapshots:
description: 'Always keep at least this many snapshots per repo (age rule only)'
type: string
required: false
default: '2'
permissions:
contents: read
# Serialize with the XBPS publisher so a snapshot is never pruned mid-publish.
concurrency:
group: void-xbps-${{ github.ref }}
cancel-in-progress: false
jobs:
prune:
name: Prune archive snapshots
runs-on: ubuntu-latest
if: github.repository == 'duxlabs-dev/dux-shell'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: true
R2_BUCKET: danklinux-void
R2_ENDPOINT: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com
ARCHIVE_PREFIX: archive/
MAX_BUCKET_GB: ${{ github.event.inputs.max_bucket_gb || '8' }}
KEEP_DAYS: ${{ github.event.inputs.keep_days || '30' }}
KEEP_MIN_SNAPSHOTS: ${{ github.event.inputs.keep_min_snapshots || '2' }}
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
- name: Prune archive
run: bash .github/scripts/prune-void-r2.sh
-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 }}
+56 -198
View File
@@ -7,50 +7,21 @@ on:
description: "Tag to release (e.g., v1.0.1)" description: "Tag to release (e.g., v1.0.1)"
required: true required: true
type: string type: string
notify_issues:
description: "Comment on open related/fixes issues asking reporters to retest"
required: false
type: boolean
default: false
permissions: permissions:
contents: write contents: write
actions: write actions: write
issues: write
concurrency: concurrency:
group: release-${{ inputs.tag }} group: release-${{ inputs.tag }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
verify-version:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.tag }}
- name: Verify quickshell/VERSION matches the tag
env:
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
version=$(cat quickshell/VERSION)
if [ "${version#v}" != "${TAG#v}" ]; then
echo "::error::quickshell/VERSION is '${version}' but the tag is '${TAG}'"; exit 1
fi
build-core: build-core:
needs: [verify-version]
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
include: arch: [amd64, arm64]
- { goos: linux, arch: amd64 }
- { goos: linux, arch: arm64 }
- { goos: freebsd, arch: amd64 }
- { goos: freebsd, arch: arm64 }
defaults: defaults:
run: run:
@@ -58,8 +29,6 @@ jobs:
env: env:
TAG: ${{ inputs.tag }} TAG: ${{ inputs.tag }}
# linux assets keep their historical arch-only names
ASSET: ${{ matrix.goos == 'linux' && matrix.arch || format('{0}-{1}', matrix.goos, matrix.arch) }}
steps: steps:
- name: Checkout - name: Checkout
@@ -67,16 +36,13 @@ jobs:
with: with:
ref: ${{ inputs.tag }} ref: ${{ inputs.tag }}
fetch-depth: 0 fetch-depth: 0
submodules: recursive
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version-file: ./core/go.mod go-version-file: ./core/go.mod
cache-dependency-path: core/go.sum
- name: Format check - name: Format check
if: matrix.goos == 'linux'
run: | run: |
if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then
echo "The following files are not formatted:" echo "The following files are not formatted:"
@@ -85,43 +51,38 @@ jobs:
fi fi
- name: Run tests - name: Run tests
if: matrix.goos == 'linux'
run: go test -v ./... run: go test -v ./...
- name: Build dankinstall (${{ env.ASSET }}) - name: Build dankinstall (${{ matrix.arch }})
if: matrix.goos == 'linux'
env: env:
GOOS: ${{ matrix.goos }} GOOS: linux
CGO_ENABLED: 0 CGO_ENABLED: 0
GOARCH: ${{ matrix.arch }} GOARCH: ${{ matrix.arch }}
run: | run: |
set -eux set -eux
cd cmd/dankinstall cd cmd/dankinstall
go build -trimpath -ldflags "-s -w -X main.Version=${TAG}" \ go build -trimpath -ldflags "-s -w -X main.Version=${TAG}" \
-o ../../dankinstall-${{ env.ASSET }} -o ../../dankinstall-${{ matrix.arch }}
cd ../.. cd ../..
gzip -9 -k dankinstall-${{ env.ASSET }} gzip -9 -k dankinstall-${{ matrix.arch }}
sha256sum dankinstall-${{ env.ASSET }}.gz > dankinstall-${{ env.ASSET }}.gz.sha256 sha256sum dankinstall-${{ matrix.arch }}.gz > dankinstall-${{ matrix.arch }}.gz.sha256
- name: Sync embedded shell - name: Build dms (${{ matrix.arch }})
run: make sync-shell
- name: Build dms (${{ env.ASSET }})
env: env:
GOOS: ${{ matrix.goos }} GOOS: linux
CGO_ENABLED: 0 CGO_ENABLED: 0
GOARCH: ${{ matrix.arch }} GOARCH: ${{ matrix.arch }}
run: | run: |
set -eux set -eux
cd cmd/dms cd cmd/dms
go build -trimpath -tags withshell -ldflags "-s -w -X main.Version=${TAG}" \ go build -trimpath -ldflags "-s -w -X main.Version=${TAG}" \
-o ../../dms-${{ env.ASSET }} -o ../../dms-${{ matrix.arch }}
cd ../.. cd ../..
gzip -9 -k dms-${{ env.ASSET }} gzip -9 -k dms-${{ matrix.arch }}
sha256sum dms-${{ env.ASSET }}.gz > dms-${{ env.ASSET }}.gz.sha256 sha256sum dms-${{ matrix.arch }}.gz > dms-${{ matrix.arch }}.gz.sha256
- name: Generate shell completions - name: Generate shell completions
if: matrix.goos == 'linux' && matrix.arch == 'amd64' if: matrix.arch == 'amd64'
run: | run: |
set -eux set -eux
chmod +x dms-amd64 chmod +x dms-amd64
@@ -129,58 +90,46 @@ jobs:
./dms-amd64 completion fish > completion.fish ./dms-amd64 completion fish > completion.fish
./dms-amd64 completion zsh > completion.zsh ./dms-amd64 completion zsh > completion.zsh
- name: Build dms-distropkg (${{ env.ASSET }}) - name: Build dms-distropkg (${{ matrix.arch }})
env: env:
GOOS: ${{ matrix.goos }} GOOS: linux
CGO_ENABLED: 0 CGO_ENABLED: 0
GOARCH: ${{ matrix.arch }} GOARCH: ${{ matrix.arch }}
run: | run: |
set -eux set -eux
cd cmd/dms cd cmd/dms
go build -trimpath -tags 'distro_binary withshell' -ldflags "-s -w -X main.Version=${TAG}" \ go build -trimpath -tags distro_binary -ldflags "-s -w -X main.Version=${TAG}" \
-o ../../dms-distropkg-${{ env.ASSET }} -o ../../dms-distropkg-${{ matrix.arch }}
cd ../.. cd ../..
gzip -9 -k dms-distropkg-${{ env.ASSET }} gzip -9 -k dms-distropkg-${{ matrix.arch }}
sha256sum dms-distropkg-${{ env.ASSET }}.gz > dms-distropkg-${{ env.ASSET }}.gz.sha256 sha256sum dms-distropkg-${{ matrix.arch }}.gz > dms-distropkg-${{ matrix.arch }}.gz.sha256
- name: Upload artifacts (${{ env.ASSET }}) - name: Upload artifacts (${{ matrix.arch }})
if: matrix.goos == 'linux' && matrix.arch == 'arm64' if: matrix.arch == 'arm64'
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v5
with: with:
name: core-assets-${{ env.ASSET }} name: core-assets-${{ matrix.arch }}
path: | path: |
core/dankinstall-${{ env.ASSET }}.gz core/dankinstall-${{ matrix.arch }}.gz
core/dankinstall-${{ env.ASSET }}.gz.sha256 core/dankinstall-${{ matrix.arch }}.gz.sha256
core/dms-${{ env.ASSET }}.gz core/dms-${{ matrix.arch }}.gz
core/dms-${{ env.ASSET }}.gz.sha256 core/dms-${{ matrix.arch }}.gz.sha256
core/dms-distropkg-${{ env.ASSET }}.gz core/dms-distropkg-${{ matrix.arch }}.gz
core/dms-distropkg-${{ env.ASSET }}.gz.sha256 core/dms-distropkg-${{ matrix.arch }}.gz.sha256
if-no-files-found: error
- name: Upload artifacts (${{ env.ASSET }}, no installer)
if: matrix.goos != 'linux'
uses: actions/upload-artifact@v5
with:
name: core-assets-${{ env.ASSET }}
path: |
core/dms-${{ env.ASSET }}.gz
core/dms-${{ env.ASSET }}.gz.sha256
core/dms-distropkg-${{ env.ASSET }}.gz
core/dms-distropkg-${{ env.ASSET }}.gz.sha256
if-no-files-found: error if-no-files-found: error
- name: Upload artifacts with completions - name: Upload artifacts with completions
if: matrix.goos == 'linux' && matrix.arch == 'amd64' if: matrix.arch == 'amd64'
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v5
with: with:
name: core-assets-${{ env.ASSET }} name: core-assets-${{ matrix.arch }}
path: | path: |
core/dankinstall-${{ env.ASSET }}.gz core/dankinstall-${{ matrix.arch }}.gz
core/dankinstall-${{ env.ASSET }}.gz.sha256 core/dankinstall-${{ matrix.arch }}.gz.sha256
core/dms-${{ env.ASSET }}.gz core/dms-${{ matrix.arch }}.gz
core/dms-${{ env.ASSET }}.gz.sha256 core/dms-${{ matrix.arch }}.gz.sha256
core/dms-distropkg-${{ env.ASSET }}.gz core/dms-distropkg-${{ matrix.arch }}.gz
core/dms-distropkg-${{ env.ASSET }}.gz.sha256 core/dms-distropkg-${{ matrix.arch }}.gz.sha256
core/completion.bash core/completion.bash
core/completion.fish core/completion.fish
core/completion.zsh core/completion.zsh
@@ -208,8 +157,8 @@ jobs:
# GH_TOKEN: ${{ steps.app_token.outputs.token }} # GH_TOKEN: ${{ steps.app_token.outputs.token }}
# run: | # run: |
# set -euo pipefail # set -euo pipefail
# git config user.name "dux-ci[bot]" # git config user.name "dms-ci[bot]"
# git config user.email "344434142+dux-ci[bot]@users.noreply.github.com" # git config user.email "dms-ci[bot]@users.noreply.github.com"
# version="${GITHUB_REF#refs/tags/}" # version="${GITHUB_REF#refs/tags/}"
# echo "Updating to version: $version" # echo "Updating to version: $version"
@@ -236,19 +185,16 @@ jobs:
with: with:
ref: ${{ inputs.tag }} ref: ${{ inputs.tag }}
fetch-depth: 0 fetch-depth: 0
submodules: recursive
- name: Fetch updated tag after version bump - name: Fetch updated tag after version bump
run: | run: |
git fetch origin --force tag ${TAG} git fetch origin --force tag ${TAG}
git checkout ${TAG} git checkout ${TAG}
git submodule update --init --recursive
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version-file: ./core/go.mod go-version-file: ./core/go.mod
cache-dependency-path: core/go.sum
- name: Download core artifacts - name: Download core artifacts
uses: actions/download-artifact@v5 uses: actions/download-artifact@v5
@@ -259,30 +205,20 @@ jobs:
- name: Generate Changelog - name: Generate Changelog
id: changelog id: changelog
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
set -e set -e
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "") PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "")
CHANGELOG="" if [ -z "$PREVIOUS_TAG" ]; then
if [ -n "$PREVIOUS_TAG" ]; then CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /' | head -50)
# PR-based notes with author credits; falls back to raw git log below else
CHANGELOG=$(python3 scripts/release-notes.py "${PREVIOUS_TAG}..${TAG}" --format github --bare 2>/dev/null || true) CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" "${PREVIOUS_TAG}..${TAG}" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /')
fi
if [ -z "$CHANGELOG" ]; then
echo "release-notes.py unavailable or empty, using git log fallback"
if [ -z "$PREVIOUS_TAG" ]; then
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /' | head -50)
else
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" "${PREVIOUS_TAG}..${TAG}" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /')
fi
fi fi
cat > RELEASE_BODY.md << 'EOF' cat > RELEASE_BODY.md << 'EOF'
## Installation ## Installation
```bash ```bash
curl -fsSL https://install.duxlabs.dev | sh curl -fsSL https://install.danklinux.com | sh
``` ```
## Assets ## Assets
@@ -290,23 +226,16 @@ jobs:
### Complete Packages ### Complete Packages
- **`dms-full-amd64.tar.gz`** - Complete package for x86_64 systems (CLI binaries + QML source + shell completions + installation guide) - **`dms-full-amd64.tar.gz`** - Complete package for x86_64 systems (CLI binaries + QML source + shell completions + installation guide)
- **`dms-full-arm64.tar.gz`** - Complete package for ARM64 systems (CLI binaries + QML source + shell completions + installation guide) - **`dms-full-arm64.tar.gz`** - Complete package for ARM64 systems (CLI binaries + QML source + shell completions + installation guide)
- **`dms-full-freebsd-amd64.tar.gz`** - Complete package for FreeBSD x86_64 systems
- **`dms-full-freebsd-arm64.tar.gz`** - Complete package for FreeBSD ARM64 systems
### Individual Components ### Individual Components
- **`dms-cli-amd64.gz`** - DMS CLI binary for x86_64 systems - **`dms-cli-amd64.gz`** - DMS CLI binary for x86_64 systems
- **`dms-cli-arm64.gz`** - DMS CLI binary for ARM64 systems - **`dms-cli-arm64.gz`** - DMS CLI binary for ARM64 systems
- **`dms-cli-freebsd-amd64.gz`** - DMS CLI binary for FreeBSD x86_64 systems
- **`dms-cli-freebsd-arm64.gz`** - DMS CLI binary for FreeBSD ARM64 systems
- **`dms-distropkg-amd64.gz`** - DMS CLI binary built with distro_package tag for AMD64 systems - **`dms-distropkg-amd64.gz`** - DMS CLI binary built with distro_package tag for AMD64 systems
- **`dms-distropkg-arm64.gz`** - DMS CLI binary built with distro_package tag for ARM64 systems - **`dms-distropkg-arm64.gz`** - DMS CLI binary built with distro_package tag for ARM64 systems
- **`dms-distropkg-freebsd-amd64.gz`** - DMS CLI binary built with distro_package tag for FreeBSD x86_64 systems
- **`dms-distropkg-freebsd-arm64.gz`** - DMS CLI binary built with distro_package tag for FreeBSD ARM64 systems
- **`dankinstall-amd64.gz`** - Installer binary for x86_64 systems - **`dankinstall-amd64.gz`** - Installer binary for x86_64 systems
- **`dankinstall-arm64.gz`** - Installer binary for ARM64 systems - **`dankinstall-arm64.gz`** - Installer binary for ARM64 systems
- **`dms-cli-<version>.tar.gz`** - Go source code with vendored modules (for distro packaging) - **`dms-cli-<version>.tar.gz`** - Go source code with vendored modules (for distro packaging)
- **`dms-qml.tar.gz`** - QML source code only - **`dms-qml.tar.gz`** - QML source code only
- **`dms-source.tar.gz`** - Full repository source with bundled DCommon (for distro packaging)
### Checksums ### Checksums
- **`*.sha256`** - SHA256 checksums for verifying download integrity - **`*.sha256`** - SHA256 checksums for verifying download integrity
@@ -354,22 +283,6 @@ jobs:
# Copy completions # Copy completions
cp _core_assets/completion.* _release_assets/ 2>/dev/null || true cp _core_assets/completion.* _release_assets/ 2>/dev/null || true
# Replace the DCommon symlink with real submodule content for packaging
rm quickshell/DCommon
cp -r dux-qml-common/DCommon quickshell/DCommon
# 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='_release_assets' \
--exclude='_core_assets' \
--exclude='RELEASE_BODY.md' \
--transform "s,^\.,DankMaterialShell-${VERSION_NUM},S" \
-czf _release_assets/dms-source.tar.gz .
(cd _release_assets && sha256sum dms-source.tar.gz > dms-source.tar.gz.sha256)
# Create QML source package (exclude build artifacts and git files) # Create QML source package (exclude build artifacts and git files)
# Copy root LICENSE and CONTRIBUTING.md to quickshell/ for packaging # Copy root LICENSE and CONTRIBUTING.md to quickshell/ for packaging
cp LICENSE CONTRIBUTING.md quickshell/ cp LICENSE CONTRIBUTING.md quickshell/
@@ -380,16 +293,14 @@ jobs:
# Tar the CONTENTS of quickshell/, not the directory itself # Tar the CONTENTS of quickshell/, not the directory itself
(cd quickshell && tar --exclude='.git' \ (cd quickshell && tar --exclude='.git' \
--exclude='.github' \ --exclude='.github' \
--exclude='./tests' \
--exclude='*.tar.gz' \ --exclude='*.tar.gz' \
--exclude-from=../scripts/shell-test-excludes.txt \
-czf ../_release_assets/dms-qml.tar.gz .) -czf ../_release_assets/dms-qml.tar.gz .)
# Generate checksum for QML package # Generate checksum for QML package
(cd _release_assets && sha256sum dms-qml.tar.gz > dms-qml.tar.gz.sha256) (cd _release_assets && sha256sum dms-qml.tar.gz > dms-qml.tar.gz.sha256)
# Create full packages for each os/architecture # Create full packages for each architecture
for arch in amd64 arm64 freebsd-amd64 freebsd-arm64; do for arch in amd64 arm64; do
mkdir -p _temp_full/dms mkdir -p _temp_full/dms
mkdir -p _temp_full/bin mkdir -p _temp_full/bin
mkdir -p _temp_full/completions mkdir -p _temp_full/completions
@@ -422,7 +333,7 @@ jobs:
# Create installation guide # Create installation guide
cat > _temp_full/INSTALL.md << 'EOFINSTALL' cat > _temp_full/INSTALL.md << 'EOFINSTALL'
# Dux Shell Installation # DankMaterialShell Installation
## Requirements ## Requirements
@@ -432,16 +343,18 @@ jobs:
## Installation Steps ## Installation Steps
The Quickshell UI is embedded in the `dms` binary. The bundled `dms/` 1. **Install quickshell assets:**
tree is optional — pass it with `-c` or `DMS_SHELL_DIR` to run a ```bash
modified copy instead of the embedded UI. mkdir -p ~/.config/quickshell
cp -r dms ~/.config/quickshell/
```
1. **Install the DMS CLI binaries:** 2. **Install the DMS CLI binaries:**
```bash ```bash
sudo install -m 755 bin/dms /usr/local/bin/dms sudo install -m 755 bin/dms /usr/local/bin/dms
``` ```
2. **Install shell completions (optional):** 3. **Install shell completions (optional):**
```bash ```bash
# Bash # Bash
sudo install -m 644 completions/completion.bash /usr/share/bash-completion/completions/dms sudo install -m 644 completions/completion.bash /usr/share/bash-completion/completions/dms
@@ -453,15 +366,15 @@ jobs:
sudo install -m 644 completions/completion.zsh /usr/share/zsh/site-functions/_dms sudo install -m 644 completions/completion.zsh /usr/share/zsh/site-functions/_dms
``` ```
3. **Start the shell:** 4. **Start the shell:**
```bash ```bash
dms run dms run
``` ```
## Configuration ## Configuration
- Settings are stored in `~/.config/dms/settings.json` - Settings are stored in `~/.config/DankMaterialShell/settings.json`
- Plugins go in `~/.config/dms/plugins/` - Plugins go in `~/.config/DankMaterialShell/plugins/`
- See the documentation in the `dms/` directory for more details - See the documentation in the `dms/` directory for more details
## Troubleshooting ## Troubleshooting
@@ -504,58 +417,3 @@ jobs:
prerelease: ${{ contains(env.TAG, '-') }} prerelease: ${{ contains(env.TAG, '-') }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Ask reporters on still-open "related/fixes #N" issues to retest.
# Opt-in via notify_issues (Point release defaults it on). Skipped for
# prereleases. Preview: scripts/notify-issues.sh <prev> <tag> --dry-run
- name: Notify open issues to retest
if: ${{ inputs.notify_issues && !contains(env.TAG, '-') }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)
if [ -z "$PREVIOUS_TAG" ]; then
echo "no previous tag; skipping issue notify"
exit 0
fi
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.
fanout:
name: Pin ${{ matrix.repo }}
runs-on: ubuntu-latest
needs: [release]
if: ${{ !contains(inputs.tag, '-') }}
strategy:
fail-fast: false
matrix:
repo:
- dux-gop
- dux-go
- dux-qml-common
- dux-calendar
- dux-search
- dux-greeter
- dux-files
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ matrix.repo }}
- name: Dispatch pin-release
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
REPO: ${{ github.repository_owner }}/${{ matrix.repo }}
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
gh workflow run pin-release.yml --repo "$REPO" -f version="$TAG"
echo "dispatched pin-release ${TAG} to ${REPO}" >> "$GITHUB_STEP_SUMMARY"
+11 -3
View File
@@ -4,12 +4,14 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
package: package:
description: 'Package to build' description: 'Package to build (dms, dms-greeter, or both)'
required: false required: false
default: 'dms' default: 'dms'
type: choice type: choice
options: options:
- dms - dms
- dms-greeter
- both
version: version:
description: 'Versioning (e.g., 1.0.3, leave empty for latest release)' description: 'Versioning (e.g., 1.0.3, leave empty for latest release)'
required: false required: false
@@ -29,7 +31,11 @@ jobs:
id: set-packages id: set-packages
run: | run: |
PACKAGE_INPUT="${{ github.event.inputs.package || 'dms' }}" PACKAGE_INPUT="${{ github.event.inputs.package || 'dms' }}"
echo "packages=[\"$PACKAGE_INPUT\"]" >> $GITHUB_OUTPUT if [ "$PACKAGE_INPUT" = "both" ]; then
echo 'packages=["dms","dms-greeter"]' >> $GITHUB_OUTPUT
else
echo "packages=[\"$PACKAGE_INPUT\"]" >> $GITHUB_OUTPUT
fi
build-and-upload: build-and-upload:
needs: determine-packages needs: determine-packages
@@ -78,7 +84,7 @@ jobs:
echo "📦 Downloading DMS QML source for v${VERSION}..." echo "📦 Downloading DMS QML source for v${VERSION}..."
# Download DMS QML source # Download DMS QML source
wget "https://github.com/duxlabs-dev/dux-shell/releases/download/v${VERSION}/dms-qml.tar.gz" || { wget "https://github.com/AvengeMedia/DankMaterialShell/releases/download/v${VERSION}/dms-qml.tar.gz" || {
echo "❌ Failed to download dms-qml.tar.gz for v${VERSION}" echo "❌ Failed to download dms-qml.tar.gz for v${VERSION}"
exit 1 exit 1
} }
@@ -157,6 +163,8 @@ jobs:
PACKAGE="${{ matrix.package }}" PACKAGE="${{ matrix.package }}"
if [ "$PACKAGE" = "dms" ]; then if [ "$PACKAGE" = "dms" ]; then
COPR_PROJECT="avengemedia/dms" COPR_PROJECT="avengemedia/dms"
elif [ "$PACKAGE" = "dms-greeter" ]; then
COPR_PROJECT="avengemedia/danklinux"
else else
echo "❌ Unknown package: $PACKAGE" echo "❌ Unknown package: $PACKAGE"
exit 1 exit 1
+60 -15
View File
@@ -9,6 +9,7 @@ on:
type: choice type: choice
options: options:
- dms - dms
- dms-greeter
- dms-git - dms-git
- all - all
default: "dms" default: "dms"
@@ -23,7 +24,7 @@ jobs:
check-updates: check-updates:
name: Check for updates name: Check for updates
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == 'duxlabs-dev/dux-shell' if: github.repository == 'AvengeMedia/DankMaterialShell'
outputs: outputs:
has_updates: ${{ steps.check.outputs.has_updates }} has_updates: ${{ steps.check.outputs.has_updates }}
@@ -35,7 +36,6 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: recursive
- name: Check for updates - name: Check for updates
id: check id: check
@@ -61,7 +61,7 @@ jobs:
# Helper function to check dms stable tag # Helper function to check dms stable tag
# Sets LATEST_TAG variable in parent scope if update needed # Sets LATEST_TAG variable in parent scope if update needed
check_dms_stable() { check_dms_stable() {
LATEST_TAG=$(curl -s https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "") LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
local OBS_SPEC=$(curl -s -u "$OBS_USERNAME:$OBS_PASSWORD" "https://api.opensuse.org/source/home:AvengeMedia:dms/dms/dms.spec" 2>/dev/null || echo "") local OBS_SPEC=$(curl -s -u "$OBS_USERNAME:$OBS_PASSWORD" "https://api.opensuse.org/source/home:AvengeMedia:dms/dms/dms.spec" 2>/dev/null || echo "")
local OBS_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs || echo "") local OBS_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs || echo "")
@@ -74,12 +74,27 @@ jobs:
fi fi
} }
# Helper function to check dms-greeter stable tag
check_dms_greeter_stable() {
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:danklinux/dms-greeter/dms-greeter.spec" 2>/dev/null || echo "")
local OBS_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs | sed 's/^v//')
if [[ -n "$LATEST_TAG" && "$LATEST_TAG" == "v$OBS_VERSION" ]]; then
echo "📋 dms-greeter: Tag $LATEST_TAG already exists, skipping"
return 1 # No update needed
else
echo "📋 dms-greeter: New tag ${LATEST_TAG:-unknown} (OBS has ${OBS_VERSION:-none})"
return 0 # Update needed
fi
}
# Main logic # Main logic
REBUILD="${{ github.event.inputs.rebuild_release }}" REBUILD="${{ github.event.inputs.rebuild_release }}"
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]] && [[ -z "${{ github.event.inputs.package }}" ]]; then if [[ "${{ github.ref }}" =~ ^refs/tags/ ]] && [[ -z "${{ github.event.inputs.package }}" ]]; then
# Run from tag with no package specified - update stable package # Run from tag with no package specified - update both stable packages
echo "packages=dms" >> $GITHUB_OUTPUT echo "packages=dms dms-greeter" >> $GITHUB_OUTPUT
VERSION="${GITHUB_REF#refs/tags/}" VERSION="${GITHUB_REF#refs/tags/}"
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT echo "has_updates=true" >> $GITHUB_OUTPUT
@@ -105,7 +120,7 @@ jobs:
echo "🔄 Manual rebuild requested: $PKG (db$REBUILD)" echo "🔄 Manual rebuild requested: $PKG (db$REBUILD)"
elif [[ "$PKG" == "all" ]]; then elif [[ "$PKG" == "all" ]]; then
# Check the stable package and build list of those needing updates # Check each stable package and build list of those needing updates
PACKAGES_TO_UPDATE=() PACKAGES_TO_UPDATE=()
if check_dms_stable; then if check_dms_stable; then
PACKAGES_TO_UPDATE+=("dms") PACKAGES_TO_UPDATE+=("dms")
@@ -113,6 +128,10 @@ jobs:
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
fi fi
fi fi
if check_dms_greeter_stable; then
PACKAGES_TO_UPDATE+=("dms-greeter")
[[ -n "$LATEST_TAG" ]] && echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
fi
if [[ ${#PACKAGES_TO_UPDATE[@]} -gt 0 ]]; then if [[ ${#PACKAGES_TO_UPDATE[@]} -gt 0 ]]; then
echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT
@@ -121,7 +140,7 @@ jobs:
else else
echo "packages=" >> $GITHUB_OUTPUT echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT echo "has_updates=false" >> $GITHUB_OUTPUT
echo "✓ All packages up to date" echo "✓ Both packages up to date"
fi fi
elif [[ "$PKG" == "dms-git" ]]; then elif [[ "$PKG" == "dms-git" ]]; then
@@ -145,6 +164,18 @@ jobs:
echo "has_updates=false" >> $GITHUB_OUTPUT echo "has_updates=false" >> $GITHUB_OUTPUT
fi fi
elif [[ "$PKG" == "dms-greeter" ]]; then
if check_dms_greeter_stable; then
echo "packages=$PKG" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
if [[ -n "$LATEST_TAG" ]]; then
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
fi
else
echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT
fi
else else
# Unknown package - proceed anyway # Unknown package - proceed anyway
echo "packages=$PKG" >> $GITHUB_OUTPUT echo "packages=$PKG" >> $GITHUB_OUTPUT
@@ -168,7 +199,6 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: recursive
- name: Wait before OBS upload - name: Wait before OBS upload
run: sleep 3 run: sleep 3
@@ -189,10 +219,10 @@ jobs:
elif [[ -n "${{ github.event.inputs.package }}" ]]; then elif [[ -n "${{ github.event.inputs.package }}" ]]; then
# Manual workflow dispatch # Manual workflow dispatch
# Determine version for dms stable using the API # Determine version for dms stable and dms-greeter using the API
# GITHUB_REF is unreliable when "Use workflow from" a tag; API works from any ref # GITHUB_REF is unreliable when "Use workflow from" a tag; API works from any ref
if [[ "${{ github.event.inputs.package }}" == "dms" ]] || [[ "${{ github.event.inputs.package }}" == "all" ]]; then if [[ "${{ github.event.inputs.package }}" == "dms" ]] || [[ "${{ github.event.inputs.package }}" == "dms-greeter" ]] || [[ "${{ github.event.inputs.package }}" == "all" ]]; then
LATEST_TAG=$(curl -s https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "") LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
if [[ -n "$LATEST_TAG" ]]; then if [[ -n "$LATEST_TAG" ]]; then
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
echo "Using latest release from API: $LATEST_TAG" echo "Using latest release from API: $LATEST_TAG"
@@ -257,7 +287,7 @@ jobs:
echo " -- Avenge Media <[email protected]> $CHANGELOG_DATE" echo " -- Avenge Media <[email protected]> $CHANGELOG_DATE"
} > "distro/debian/dms-git/debian/changelog" } > "distro/debian/dms-git/debian/changelog"
- name: Update stable version (dms) - name: Update stable version (dms + dms-greeter)
if: steps.packages.outputs.version != '' if: steps.packages.outputs.version != ''
run: | run: |
VERSION="${{ steps.packages.outputs.version }}" VERSION="${{ steps.packages.outputs.version }}"
@@ -293,12 +323,25 @@ jobs:
fi fi
fi fi
# Update dms-greeter changelog when dms-greeter is in the upload list
if [[ "$PACKAGES" == *"dms-greeter"* ]] && [[ -f "distro/debian/dms-greeter/debian/changelog" ]]; then
CHANGELOG_DATE=$(date -R)
{
echo "dms-greeter (${VERSION_NO_V}db1) unstable; urgency=medium"
echo ""
echo " * Update to $VERSION stable release"
echo ""
echo " -- Avenge Media <[email protected]> $CHANGELOG_DATE"
} > "distro/debian/dms-greeter/debian/changelog"
echo "✓ Updated dms-greeter changelog to ${VERSION_NO_V}db1"
fi
# Update Debian _service files for packages in upload list (download_url paths) # Update Debian _service files for packages in upload list (download_url paths)
for service in distro/debian/*/_service; do for service in distro/debian/*/_service; do
if [[ -f "$service" ]]; then if [[ -f "$service" ]]; then
# Update tar_scm revision parameter (for dms-git) # Update tar_scm revision parameter (for dms-git)
sed -i "s|<param name=\"revision\">v[0-9.]*</param>|<param name=\"revision\">$VERSION</param>|" "$service" sed -i "s|<param name=\"revision\">v[0-9.]*</param>|<param name=\"revision\">$VERSION</param>|" "$service"
# Update download_url paths (for dms stable) # Update download_url paths (for dms, dms-greeter stable)
sed -i "s|/v[0-9.]\+/|/$VERSION/|g" "$service" sed -i "s|/v[0-9.]\+/|/$VERSION/|g" "$service"
sed -i "s|/tags/v[0-9.]\+\.tar\.gz|/tags/$VERSION.tar.gz|g" "$service" sed -i "s|/tags/v[0-9.]\+\.tar\.gz|/tags/$VERSION.tar.gz|g" "$service"
fi fi
@@ -308,7 +351,6 @@ jobs:
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version-file: ./core/go.mod go-version-file: ./core/go.mod
cache-dependency-path: core/go.sum
- name: Install OSC - name: Install OSC
run: | run: |
@@ -360,7 +402,7 @@ jobs:
UPLOADED_PACKAGES=() UPLOADED_PACKAGES=()
SKIPPED_PACKAGES=() SKIPPED_PACKAGES=()
# PACKAGES can be a space-separated list (from the "all" check) # PACKAGES can be space-separated list (e.g., "dms dms-greeter" from "all" check)
# Loop through each package and upload # Loop through each package and upload
for PKG in $PACKAGES; do for PKG in $PACKAGES; do
echo "" echo ""
@@ -463,6 +505,9 @@ jobs:
dms-git) dms-git)
echo "- $STATUS_ICON **dms-git** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms-git/dms-git)" >> $GITHUB_STEP_SUMMARY echo "- $STATUS_ICON **dms-git** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms-git/dms-git)" >> $GITHUB_STEP_SUMMARY
;; ;;
dms-greeter)
echo "- $STATUS_ICON **dms-greeter** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:danklinux/dms-greeter)" >> $GITHUB_STEP_SUMMARY
;;
esac esac
done done
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
+4 -4
View File
@@ -9,6 +9,7 @@ on:
type: choice type: choice
options: options:
- dms - dms
- dms-greeter
- dms-git - dms-git
- all - all
default: "dms" default: "dms"
@@ -23,7 +24,7 @@ jobs:
check-updates: check-updates:
name: Check package/series updates name: Check package/series updates
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == 'duxlabs-dev/dux-shell' if: github.repository == 'AvengeMedia/DankMaterialShell'
outputs: outputs:
has_updates: ${{ steps.check.outputs.has_updates }} has_updates: ${{ steps.check.outputs.has_updates }}
@@ -35,7 +36,6 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: recursive
- name: Install dependencies - name: Install dependencies
run: | run: |
@@ -94,13 +94,11 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: recursive
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version-file: ./core/go.mod go-version-file: ./core/go.mod
cache-dependency-path: core/go.sum
cache: false cache: false
- name: Install build dependencies - name: Install build dependencies
@@ -135,6 +133,7 @@ jobs:
case "$PACKAGE" in case "$PACKAGE" in
dms) PPA_NAME="dms" ;; dms) PPA_NAME="dms" ;;
dms-git) PPA_NAME="dms-git" ;; dms-git) PPA_NAME="dms-git" ;;
dms-greeter) PPA_NAME="danklinux" ;;
*) echo "::error::Unknown package $PACKAGE"; exit 1 ;; *) echo "::error::Unknown package $PACKAGE"; exit 1 ;;
esac esac
@@ -149,3 +148,4 @@ jobs:
echo "- **Target:** ${{ matrix.target }}" >> "$GITHUB_STEP_SUMMARY" echo "- **Target:** ${{ matrix.target }}" >> "$GITHUB_STEP_SUMMARY"
echo "- **DMS PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages" >> "$GITHUB_STEP_SUMMARY" echo "- **DMS PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages" >> "$GITHUB_STEP_SUMMARY"
echo "- **DMS-Git PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages" >> "$GITHUB_STEP_SUMMARY" echo "- **DMS-Git PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages" >> "$GITHUB_STEP_SUMMARY"
echo "- **DankLinux PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages" >> "$GITHUB_STEP_SUMMARY"
+63 -108
View File
@@ -3,15 +3,10 @@ name: Void Linux XBPS Repository
on: on:
schedule: schedule:
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown) - cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
# release trigger disabled; dispatch manually after a release release:
# release: types: [published]
# types: [published]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version:
description: 'Stable release tag/version to build (e.g. v1.5.0 or 1.5.0). Leave blank to use the selected ref/template version'
type: string
required: false
force_rebuild: force_rebuild:
description: 'Force rebuilding packages even if they already exist in the repository' description: 'Force rebuilding packages even if they already exist in the repository'
type: boolean type: boolean
@@ -27,66 +22,37 @@ on:
type: boolean type: boolean
required: false required: false
default: true default: true
build_greeter:
description: 'Build stable dms-greeter package'
type: boolean
required: false
default: true
permissions: permissions:
contents: read contents: write
concurrency:
group: void-xbps-${{ github.ref }}
cancel-in-progress: false
jobs: jobs:
preflight:
name: Check for unpublished changes
runs-on: ubuntu-latest
outputs:
should_build: ${{ steps.check.outputs.should_build }}
steps:
- name: Check published commit
id: check
env:
MANIFEST_URL: https://void.danklinux.com/dms/current/manifest.json
run: |
SHOULD_BUILD=true
if [ "${{ github.event_name }}" = "schedule" ]; then
PUBLISHED_COMMIT="$(curl -fsSL --retry 3 "$MANIFEST_URL" | jq -r '.source_commit // empty' 2>/dev/null || true)"
if [ "$PUBLISHED_COMMIT" = "${{ github.sha }}" ]; then
SHOULD_BUILD=false
echo "${{ github.sha }} is already published; skipping the scheduled build."
fi
fi
echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT"
build-and-deploy: build-and-deploy:
name: Build & Deploy XBPS packages name: Build & Deploy XBPS packages
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: preflight if: github.repository == 'AvengeMedia/DankMaterialShell'
if: github.repository == 'duxlabs-dev/dux-shell' && 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 }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: true
R2_BUCKET: danklinux-void
R2_ENDPOINT: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com
R2_PREFIX: dms
REPOSITORY_DIR: ${{ github.workspace }}/r2-repo
SOURCE_COMMIT: ${{ github.sha }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
ref: ${{ github.event.release.tag_name || (github.event.inputs.version && (startsWith(github.event.inputs.version, 'v') && github.event.inputs.version || format('v{0}', github.event.inputs.version))) || github.ref }}
fetch-depth: 0 fetch-depth: 0
submodules: recursive
- name: Record checked-out source commit - name: Checkout gh-pages branch
run: echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
- name: Download current R2 repository
run: | run: |
aws --version git clone --branch gh-pages https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git gh-pages-repo || {
bash .github/scripts/publish-void-r2.sh download echo "⚠️ gh-pages branch not found or empty, initializing a new one..."
mkdir gh-pages-repo
cd gh-pages-repo
git init
git checkout -b gh-pages
git remote add origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git
cd ..
}
- name: Install XBPS Static Utilities - name: Install XBPS Static Utilities
run: | run: |
@@ -101,6 +67,7 @@ jobs:
- name: Inject templates - name: Inject templates
run: | run: |
cp -R distro/void/srcpkgs/dms void-packages/srcpkgs/ cp -R distro/void/srcpkgs/dms void-packages/srcpkgs/
cp -R distro/void/srcpkgs/dms-greeter void-packages/srcpkgs/
cp -R distro/void/srcpkgs/dms-git void-packages/srcpkgs/ cp -R distro/void/srcpkgs/dms-git void-packages/srcpkgs/
- name: Enable unprivileged user namespaces (Ubuntu 24.04) - name: Enable unprivileged user namespaces (Ubuntu 24.04)
@@ -159,71 +126,45 @@ jobs:
- name: Configure repositories - name: Configure repositories
run: | run: |
# Append the repository to repos-remote templates so xbps-src translates it automatically # Append the repository to repos-remote templates so xbps-src translates it automatically
echo "repository=https://void.danklinux.com/danklinux/current" >> void-packages/etc/xbps.d/repos-remote.conf echo "repository=https://avengemedia.github.io/DankLinux/current" >> void-packages/etc/xbps.d/repos-remote.conf
echo "repository=https://void.danklinux.com/danklinux/current" >> void-packages/etc/xbps.d/repos-remote-x86_64-multilib.conf echo "repository=https://avengemedia.github.io/DankLinux/current" >> void-packages/etc/xbps.d/repos-remote-x86_64-multilib.conf
# Add any existing compiled packages to the build cache directory to avoid rebuilds # Add any existing compiled packages to the build cache directory to avoid rebuilds
if [ -d "r2-repo/current" ]; then if [ -d "gh-pages-repo/current" ]; then
mkdir -p void-packages/hostdir/binpkgs mkdir -p void-packages/hostdir/binpkgs
cp -L r2-repo/current/*.xbps void-packages/hostdir/binpkgs/ 2>/dev/null || true cp -L gh-pages-repo/current/*.xbps void-packages/hostdir/binpkgs/ 2>/dev/null || true
xbps-rindex -a void-packages/hostdir/binpkgs/*.xbps 2>/dev/null || true xbps-rindex -a void-packages/hostdir/binpkgs/*.xbps 2>/dev/null || true
fi fi
- name: Build packages - name: Build packages
run: | run: |
mkdir -p r2-repo/current mkdir -p gh-pages-repo/current
BUILD_DMS="${{ github.event.inputs.build_dms || 'true' }}" BUILD_DMS="${{ github.event.inputs.build_dms || 'true' }}"
BUILD_GREETER="${{ github.event.inputs.build_greeter || 'true' }}"
BUILD_GIT="${{ github.event.inputs.build_git || 'true' }}" BUILD_GIT="${{ github.event.inputs.build_git || 'true' }}"
FORCE_REBUILD="${{ github.event.inputs.force_rebuild || 'false' }}" FORCE_REBUILD="${{ github.event.inputs.force_rebuild || 'false' }}"
if [ "${{ github.event_name }}" = "schedule" ]; then if [ "${{ github.event_name }}" = "schedule" ]; then
BUILD_DMS="false" BUILD_DMS="false"
BUILD_GREETER="false"
BUILD_GIT="true" BUILD_GIT="true"
fi fi
if [ "${{ github.event_name }}" = "release" ]; then if [ "${{ github.event_name }}" = "release" ]; then
BUILD_DMS="true" BUILD_DMS="true"
BUILD_GREETER="true"
BUILD_GIT="false" BUILD_GIT="false"
fi fi
RELEASE_TAG=""
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_TAG="${{ github.event.release.tag_name }}"
elif [ -n "${{ github.event.inputs.version || '' }}" ]; then
RELEASE_TAG="${{ github.event.inputs.version }}"
elif [ "${{ github.ref_type }}" = "tag" ]; then
RELEASE_TAG="${{ github.ref_name }}"
fi
if [ -n "$RELEASE_TAG" ]; then
RELEASE_VER="${RELEASE_TAG#v}"
ARCHIVE_TAG="v${RELEASE_VER}"
echo "Stable release tag: $ARCHIVE_TAG"
else
RELEASE_VER=""
ARCHIVE_TAG=""
echo "Stable release tag: (template version)"
fi
echo "=== Starting Builds ===" echo "=== Starting Builds ==="
echo "DMS stable build enabled: $BUILD_DMS" echo "DMS stable build enabled: $BUILD_DMS"
echo "Greeter stable build enabled: $BUILD_GREETER"
echo "Git build enabled: $BUILD_GIT" echo "Git build enabled: $BUILD_GIT"
echo "Force rebuild: $FORCE_REBUILD" echo "Force rebuild: $FORCE_REBUILD"
cd void-packages cd void-packages
if [ -n "$RELEASE_VER" ] && [ "$BUILD_DMS" = "true" ]; then
echo "🔧 Updating stable template for $ARCHIVE_TAG"
TARBALL="$(mktemp)"
curl -fsSL -o "$TARBALL" "https://github.com/${{ github.repository }}/releases/download/${ARCHIVE_TAG}/dms-source.tar.gz"
RELEASE_CHECKSUM="$(sha256sum "$TARBALL" | cut -d' ' -f1)"
rm -f "$TARBALL"
sed -i "s/^version=.*/version=${RELEASE_VER}/" srcpkgs/dms/template
sed -i "s/^checksum=.*/checksum=${RELEASE_CHECKSUM}/" srcpkgs/dms/template
fi
# 1. Build dms-git (development package) # 1. Build dms-git (development package)
if [ "$BUILD_GIT" = "true" ]; then if [ "$BUILD_GIT" = "true" ]; then
# Calculate dynamic git version (tag.commits.hash) # Calculate dynamic git version (tag.commits.hash)
@@ -235,9 +176,9 @@ jobs:
# into $wrksrc (create_wrksrc=yes handles the rest). # into $wrksrc (create_wrksrc=yes handles the rest).
SRC_CACHE="hostdir/sources/dms-git-${GIT_VER}" SRC_CACHE="hostdir/sources/dms-git-${GIT_VER}"
mkdir -p "$SRC_CACHE" mkdir -p "$SRC_CACHE"
tar -czhf "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" \ tar -czf "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" \
--exclude=void-packages \ --exclude=void-packages \
--exclude=r2-repo \ --exclude=gh-pages-repo \
--exclude=.git \ --exclude=.git \
--exclude=danklinux \ --exclude=danklinux \
-C .. . -C .. .
@@ -251,13 +192,13 @@ jobs:
EXPECTED_GIT_FILE="dms-git-${GIT_VER}_1.x86_64.xbps" EXPECTED_GIT_FILE="dms-git-${GIT_VER}_1.x86_64.xbps"
if [ -f "../r2-repo/current/$EXPECTED_GIT_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then if [ -f "../gh-pages-repo/current/$EXPECTED_GIT_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
echo "✅ $EXPECTED_GIT_FILE already exists, skipping build." echo "✅ $EXPECTED_GIT_FILE already exists, skipping build."
else else
echo "🔨 Compiling dms-git..." echo "🔨 Compiling dms-git..."
./xbps-src pkg dms-git ./xbps-src pkg dms-git
rm -f "../r2-repo/current/${EXPECTED_GIT_FILE}" rm -f "../gh-pages-repo/current/${EXPECTED_GIT_FILE}"
cp -L "hostdir/binpkgs/${EXPECTED_GIT_FILE}" ../r2-repo/current/ cp -L hostdir/binpkgs/dms-git-*.xbps ../gh-pages-repo/current/
fi fi
fi fi
@@ -267,21 +208,38 @@ jobs:
STABLE_REV=$(grep -E '^revision=' srcpkgs/dms/template | cut -d= -f2 | tr -d '"') STABLE_REV=$(grep -E '^revision=' srcpkgs/dms/template | cut -d= -f2 | tr -d '"')
EXPECTED_DMS_FILE="dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps" EXPECTED_DMS_FILE="dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps"
if [ -f "../r2-repo/current/$EXPECTED_DMS_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then if [ -f "../gh-pages-repo/current/$EXPECTED_DMS_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
echo "✅ $EXPECTED_DMS_FILE already exists, skipping build." echo "✅ $EXPECTED_DMS_FILE already exists, skipping build."
else else
echo "🔨 Compiling dms ($STABLE_VER)..." echo "🔨 Compiling dms ($STABLE_VER)..."
./xbps-src pkg dms ./xbps-src pkg dms
rm -f "../r2-repo/current/${EXPECTED_DMS_FILE}" rm -f "../gh-pages-repo/current/${EXPECTED_DMS_FILE}"
cp -L hostdir/binpkgs/dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps ../r2-repo/current/ cp -L hostdir/binpkgs/dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps ../gh-pages-repo/current/
fi
fi
# 3. Build stable dms-greeter package
if [ "$BUILD_GREETER" = "true" ]; then
GREETER_VER=$(grep -E '^version=' srcpkgs/dms-greeter/template | cut -d= -f2 | tr -d '"')
GREETER_REV=$(grep -E '^revision=' srcpkgs/dms-greeter/template | cut -d= -f2 | tr -d '"')
EXPECTED_GREETER_FILE="dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps"
if [ -f "../gh-pages-repo/current/$EXPECTED_GREETER_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
echo "✅ $EXPECTED_GREETER_FILE already exists, skipping build."
else
echo "🔨 Compiling dms-greeter ($GREETER_VER)..."
./xbps-src pkg dms-greeter
rm -f "../gh-pages-repo/current/${EXPECTED_GREETER_FILE}"
cp -L hostdir/binpkgs/dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps ../gh-pages-repo/current/
fi fi
fi fi
- name: Index and sign repository - name: Index and sign repository
run: | run: |
cd r2-repo/current cd gh-pages-repo/current
rm -f *.sig # Clean up any stale or dangling signature files to prevent O_CREAT ENOENT errors
rm -f *.sig2 *.sig
# Guard: nothing to index if no .xbps files exist # Guard: nothing to index if no .xbps files exist
if ! ls *.xbps 1>/dev/null 2>&1; then if ! ls *.xbps 1>/dev/null 2>&1; then
@@ -291,14 +249,6 @@ jobs:
# Regenerate repo index # Regenerate repo index
xbps-rindex -a $(pwd)/*.xbps xbps-rindex -a $(pwd)/*.xbps
xbps-rindex --remove-obsoletes $(pwd)
# Remove signatures left behind by obsolete packages. Signatures for
# unchanged immutable packages are retained byte-for-byte.
for sig in *.sig2; do
[ -e "$sig" ] || break
[ -f "${sig%.sig2}" ] || rm -f "$sig"
done
# Sign repository # Sign repository
echo "${{ secrets.XBPS_PRIVATE_KEY }}" > /tmp/xbps_privkey.pem echo "${{ secrets.XBPS_PRIVATE_KEY }}" > /tmp/xbps_privkey.pem
@@ -309,5 +259,10 @@ jobs:
rm -f /tmp/xbps_privkey.pem rm -f /tmp/xbps_privkey.pem
- name: Publish repository to R2 - name: Deploy to gh-pages branch
run: bash .github/scripts/publish-void-r2.sh publish run: |
cd gh-pages-repo
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add current/
git diff --quiet && git diff --staged --quiet || (git commit -m "Update XBPS packages [skip ci]" && git push origin gh-pages)
+18 -5
View File
@@ -8,7 +8,6 @@ on:
- "core/go.sum" - "core/go.sum"
branches: branches:
- master - master
- "stable-*"
permissions: permissions:
contents: write contents: write
@@ -19,7 +18,7 @@ jobs:
steps: steps:
- name: Create GitHub App token - name: Create GitHub App token
id: app_token id: app_token
uses: actions/create-github-app-token@v3 uses: actions/create-github-app-token@v1
with: with:
app-id: ${{ secrets.APP_ID }} app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }} private-key: ${{ secrets.APP_PRIVATE_KEY }}
@@ -34,7 +33,21 @@ jobs:
uses: cachix/install-nix-action@v31 uses: cachix/install-nix-action@v31
- name: Update vendorHash in flake.nix - name: Update vendorHash in flake.nix
run: scripts/update-vendor-hash.sh run: |
set -euo pipefail
echo "Attempting nix build to get new vendorHash..."
if output=$(nix build .#dms-shell 2>&1); then
echo "Build succeeded, no hash update needed"
exit 0
fi
new_hash=$(echo "$output" | grep -oP "got:\s+\K\S+" | head -n1 || true)
[ -n "$new_hash" ] || { echo "Could not extract new vendorHash"; echo "$output"; exit 1; }
current_hash=$(grep -oP 'vendorHash = "\K[^"]+' flake.nix)
[ "$current_hash" = "$new_hash" ] && { echo "vendorHash already up to date"; exit 0; }
sed -i "s|vendorHash = \"$current_hash\"|vendorHash = \"$new_hash\"|" flake.nix
echo "Verifying build with new vendorHash..."
nix build .#dms-shell
echo "vendorHash updated successfully!"
- name: Commit and push vendorHash update - name: Commit and push vendorHash update
env: env:
@@ -42,8 +55,8 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
if ! git diff --quiet flake.nix; then if ! git diff --quiet flake.nix; then
git config user.name "dux-ci[bot]" git config user.name "dms-ci[bot]"
git config user.email "344434142+dux-ci[bot]@users.noreply.github.com" git config user.email "dms-ci[bot]@users.noreply.github.com"
git add flake.nix git add flake.nix
git commit -m "nix: update vendorHash for go.mod changes" || exit 0 git commit -m "nix: update vendorHash for go.mod changes" || exit 0
git pull --rebase origin ${{ github.ref_name }} git pull --rebase origin ${{ github.ref_name }}
+1 -8
View File
@@ -64,7 +64,7 @@ AGENTS-temp.md
niri-colors.generated.kdl niri-colors.generated.kdl
ghostty-colors.generated.conf ghostty-colors.generated.conf
# Notepad files (should be in ~/.local/state/dms/) # Notepad files (should be in ~/.local/state/DankMaterialShell/)
untitled-*.txt untitled-*.txt
file:* file:*
notepad-files/ notepad-files/
@@ -117,7 +117,6 @@ core.*
.envrc .envrc
.direnv/ .direnv/
quickshell/dms-plugins quickshell/dms-plugins
quickshell/dms-plugins-external
__pycache__ __pycache__
.vscode/ .vscode/
@@ -127,9 +126,3 @@ __pycache__
distro/void/temp/ distro/void/temp/
distro/void/hostdir/ distro/void/hostdir/
distro/void/masterdir*/ distro/void/masterdir*/
# Often gets built
core/dms
core/dankinstall
core/internal/shellembed/dist
-4
View File
@@ -1,4 +0,0 @@
[submodule "dank-qml-common"]
path = dux-qml-common
url = https://github.com/duxlabs-dev/dux-qml-common.git
branch = master
+2 -26
View File
@@ -5,16 +5,6 @@ repos:
- id: trailing-whitespace - id: trailing-whitespace
- id: check-yaml - id: check-yaml
- id: end-of-file-fixer - id: end-of-file-fixer
- repo: local
hooks:
- id: qml-tests
name: shell logic and QML behavior tests
entry: python3 quickshell/tests/run-qml.py
language: system
types: []
files: ^(quickshell/|dux-qml-common$|Makefile$|core/Makefile$|scripts/shell-test-excludes\.txt$|flake\.nix$|\.pre-commit-config\.yaml$|\.github/workflows/(qml-tests|prek)\.yml$)
pass_filenames: false
require_serial: true
- repo: local - repo: local
hooks: hooks:
- id: shellcheck - id: shellcheck
@@ -30,19 +20,13 @@ repos:
language: system language: system
files: ^core/.*\.(go|mod|sum)$ files: ^core/.*\.(go|mod|sum)$
pass_filenames: false pass_filenames: false
- id: nix-vendor-hash
name: update Nix vendor hash
entry: bash -c 'command -v nix >/dev/null 2>&1 || exit 0; scripts/update-vendor-hash.sh --stage'
language: system
files: ^core/go\.(mod|sum)$
pass_filenames: false
- repo: local - repo: local
hooks: hooks:
- id: settings-search-index - id: settings-search-index
name: settings search index is up to date name: settings search index is up to date
entry: bash -c 'python3 quickshell/translations/extract_settings_index.py >/dev/null || exit 1; if ! git diff --exit-code -- quickshell/translations/settings_search_index.json; then echo "settings_search_index.json is out of date; run quickshell/translations/extract_settings_index.py and stage the result" >&2; exit 1; fi' entry: bash -c 'python3 quickshell/translations/extract_settings_index.py >/dev/null || exit 1; if ! git diff --exit-code -- quickshell/translations/settings_search_index.json; then echo "settings_search_index.json is out of date; run quickshell/translations/extract_settings_index.py and stage the result" >&2; exit 1; fi'
language: system language: system
files: ^quickshell/(Modules/Settings/.*\.qml|Common/SettingsTabs\.qml|Services/SettingsSearchService\.qml|translations/(extract_settings_index\.py|en\.json))$ files: ^quickshell/(Modules/Settings/.*\.qml|Modals/Settings/SettingsSidebar\.qml|translations/extract_settings_index\.py)$
pass_filenames: false pass_filenames: false
- repo: local - repo: local
hooks: hooks:
@@ -52,14 +36,6 @@ repos:
language: system language: system
files: ^quickshell/(.*\.qml|translations/(term_freeze\.json|check_term_freeze\.py|extract_translations\.py))$ files: ^quickshell/(.*\.qml|translations/(term_freeze\.json|check_term_freeze\.py|extract_translations\.py))$
pass_filenames: false pass_filenames: false
- repo: local
hooks:
- id: i18n-term-variants
name: i18n term variants (no case/punctuation duplicates)
entry: python3 quickshell/translations/check_term_variants.py
language: system
files: ^quickshell/(.*\.qml|translations/(check_term_variants\.py|extract_translations\.py))$
pass_filenames: false
- repo: local - repo: local
hooks: hooks:
- id: no-console-in-qml - id: no-console-in-qml
@@ -67,4 +43,4 @@ repos:
entry: bash -c 'if grep -nE "console\.(log|error|info|warn|debug)" "$@"; then echo "Use the Log service (log.info/warn/error/debug/fatal) instead of console.*" >&2; exit 1; fi' -- entry: bash -c 'if grep -nE "console\.(log|error|info|warn|debug)" "$@"; then echo "Use the Log service (log.info/warn/error/debug/fatal) instead of console.*" >&2; exit 1; fi' --
language: system language: system
files: ^quickshell/.*\.qml$ files: ^quickshell/.*\.qml$
exclude: ^quickshell/(Services/Log\.qml$|dms-plugins/|PLUGINS/|tests/) exclude: ^quickshell/(Services/Log\.qml$|dms-plugins/|PLUGINS/)
-46
View File
@@ -1,46 +0,0 @@
## What is this?
DMS is an open-source desktop shell for Wayland compositors on linux, built to work with many compositors.
## 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
## Rules
- 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.
## Tests
- 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.
## Connections vs bound properties
- 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`.
## Commit Messages
`area: short lowercase explanation`, for example `network: report wifi state from the associated adapter`. Description only if required, in simple language.
-9
View File
@@ -1,14 +1,5 @@
This file is more of a quick reference so I know what to account for before next releases. This file is more of a quick reference so I know what to account for before next releases.
# Next
- 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
- -git packages (AUR dms-shell-git, fedora, opensuse, void, debian, ubuntu) no longer install /usr/share/quickshell/dms; dms greeter install falls back to the embedded UI when the greeter package is present
- ping over the socket returns {"pong":true} instead of "pong"
- startup: embedded UI resolution is keyed by build-time .dankrev (no per-start content hashing)
# 1.5.0 # 1.5.0
- Overhauled shadows - Overhauled shadows
- App ID changed to com.danklinux.dms - breaking for window rules - App ID changed to com.danklinux.dms - breaking for window rules
+1 -84
View File
@@ -6,20 +6,6 @@ To contribute fork this repository, make your changes, and open a pull request.
## Setup ## Setup
Clone with submodules — the shared widget library ([dux-qml-common](https://github.com/duxlabs-dev/dux-qml-common)) is vendored at `dux-qml-common/` and symlinked into `quickshell/DCommon`:
```bash
git clone --recurse-submodules https://github.com/duxlabs-dev/dux-shell.git
# or, in an existing clone:
git submodule update --init
```
To have `git pull` keep the submodule in sync automatically (moving it to the commit this repo points at, no separate `git submodule update` step), set:
```bash
git config submodule.recurse true
```
Install [prek](https://prek.j178.dev/) then activate pre-commit hooks: Install [prek](https://prek.j178.dev/) then activate pre-commit hooks:
```bash ```bash
@@ -42,42 +28,6 @@ This will provide:
The dev shell automatically creates the `.qmlls.ini` file in the `quickshell/` directory. The dev shell automatically creates the `.qmlls.ini` file in the `quickshell/` directory.
## Building and running
The Quickshell UI is embedded into the `dms` binary at build time. `make build` copies `quickshell/` into `core/internal/shellembed/dist/` (generated, never committed) and compiles with the `withshell` tag. `make dev` builds without the tag — that binary carries no UI and requires an explicit config dir.
```bash
make build # embedded binary at core/bin/dms
make dev # untagged development build
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:
```
go work init ./core ../dankgo
```
## Shared widgets (dux-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:
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.
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/"
```
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.
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).
## VSCode Setup ## VSCode Setup
This is a monorepo, the easiest thing to do is to open an editor in either `quickshell`, `core`, or both depending on which part of the project you are working on. This is a monorepo, the easiest thing to do is to open an editor in either `quickshell`, `core`, or both depending on which part of the project you are working on.
@@ -138,9 +88,7 @@ touch .qmlls.ini
5. Run `make lint-qml` from the repo root to lint QML entrypoints (requires the `.qmlls.ini` generated above). The script needs the **Qt 6** `qmllint`; it checks `qmllint6`, Fedora's `qmllint-qt6`, `/usr/lib/qt6/bin/qmllint`, then `qmllint` in `PATH`. If your Qt 6 binary lives elsewhere, set `QMLLINT=/path/to/qmllint`. 5. Run `make lint-qml` from the repo root to lint QML entrypoints (requires the `.qmlls.ini` generated above). The script needs the **Qt 6** `qmllint`; it checks `qmllint6`, Fedora's `qmllint-qt6`, `/usr/lib/qt6/bin/qmllint`, then `qmllint` in `PATH`. If your Qt 6 binary lives elsewhere, set `QMLLINT=/path/to/qmllint`.
6. Run `make test-qml` for QML unit tests (`nix develop --command make test-qml` with Nix). These use Qt 6 `qmltestrunner` offscreen and do not require a running shell. Set `QMLTESTRUNNER=/path/to/qmltestrunner` if needed. 6. Make your changes, test, and open a pull request.
7. Make your changes, test, and open a pull request.
### I18n/Localization ### I18n/Localization
@@ -156,10 +104,6 @@ Text {
Preferably, try to keep new terms to a minimum and re-use existing terms where possible. See `quickshell/translations/en.json` for the list of existing terms. (This isn't always possible obviously, but instead of using `Auto-connect` you would use `Autoconnect` since it's already translated) Preferably, try to keep new terms to a minimum and re-use existing terms where possible. See `quickshell/translations/en.json` for the list of existing terms. (This isn't always possible obviously, but instead of using `Auto-connect` you would use `Autoconnect` since it's already translated)
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.
### GO (`core` directory) ### GO (`core` directory)
1. Install the [Go Extension](https://code.visualstudio.com/docs/languages/go) 1. Install the [Go Extension](https://code.visualstudio.com/docs/languages/go)
@@ -168,33 +112,6 @@ Strings inside `quickshell/DCommon/` are owned by the dux-qml-common repo but st
4. Run `go mod tidy` 4. Run `go mod tidy`
5. Open pull request 5. Open pull request
golangci-lint runs as a pre-commit hook and covers `go vet`, so there's nothing separate to run. If you run `go vet ./...` yourself you'll see complaints about the generated mocks, ignore them, `core/.golangci.yml` excludes that directory.
#### Mocks
Test mocks under `core/internal/mocks/` are generated with [mockery](https://vektra.github.io/mockery/) v3. Don't edit them by hand, regenerate after changing a mocked interface:
```bash
cd core
go run github.com/vektra/mockery/v3@latest
# or with it installed (go install github.com/vektra/mockery/v3@latest):
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 ## Pull request
Include screenshots/video if applicable in your pull request if applicable, to visualize what your change is affecting. Include screenshots/video if applicable in your pull request if applicable, to visualize what your change is affecting.
+21 -40
View File
@@ -1,4 +1,4 @@
# Root Makefile for Dux Shell (DMS) # Root Makefile for DankMaterialShell (DMS)
# Orchestrates building, installation, and systemd management # Orchestrates building, installation, and systemd management
# Build configuration # Build configuration
@@ -6,20 +6,19 @@ BINARY_NAME=dms
CORE_DIR=core CORE_DIR=core
BUILD_DIR=$(CORE_DIR)/bin BUILD_DIR=$(CORE_DIR)/bin
PREFIX ?= /usr/local PREFIX ?= /usr/local
INSTALL_DIR=$(DESTDIR)$(PREFIX)/bin INSTALL_DIR=$(PREFIX)/bin
DATA_DIR=$(DESTDIR)$(PREFIX)/share DATA_DIR=$(PREFIX)/share
ICON_DIR=$(DATA_DIR)/icons/hicolor/scalable/apps ICON_DIR=$(DATA_DIR)/icons/hicolor/scalable/apps
USER_HOME := $(if $(SUDO_USER),$(shell getent passwd $(SUDO_USER) | cut -d: -f6),$(HOME)) USER_HOME := $(if $(SUDO_USER),$(shell getent passwd $(SUDO_USER) | cut -d: -f6),$(HOME))
SYSTEMD_USER_DIR=$(USER_HOME)/.config/systemd/user SYSTEMD_USER_DIR=$(USER_HOME)/.config/systemd/user
SHELL_DIR=quickshell SHELL_DIR=quickshell
QMLTESTRUNNER ?=
SHELL_INSTALL_DIR=$(DATA_DIR)/quickshell/dms SHELL_INSTALL_DIR=$(DATA_DIR)/quickshell/dms
ASSETS_DIR=assets ASSETS_DIR=assets
APPLICATIONS_DIR=$(DATA_DIR)/applications APPLICATIONS_DIR=$(DATA_DIR)/applications
.PHONY: all build dev run clean lint-qml install install-bin install-completions install-systemd install-icon install-desktop uninstall uninstall-bin uninstall-shell uninstall-completions uninstall-systemd uninstall-icon uninstall-desktop help .PHONY: all build clean lint-qml install install-bin install-shell install-completions install-systemd install-icon install-desktop uninstall uninstall-bin uninstall-shell uninstall-completions uninstall-systemd uninstall-icon uninstall-desktop help
all: build all: build
@@ -28,12 +27,6 @@ build:
@$(MAKE) -C $(CORE_DIR) build @$(MAKE) -C $(CORE_DIR) build
@echo "Build complete" @echo "Build complete"
dev:
@$(MAKE) -C $(CORE_DIR) dev
run: dev
@$(BUILD_DIR)/$(BINARY_NAME) run -c $(CURDIR)/$(SHELL_DIR)
clean: clean:
@echo "Cleaning build artifacts..." @echo "Cleaning build artifacts..."
@$(MAKE) -C $(CORE_DIR) clean @$(MAKE) -C $(CORE_DIR) clean
@@ -42,22 +35,19 @@ clean:
lint-qml: lint-qml:
@./quickshell/scripts/qmllint-entrypoints.sh @./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
# (submodule pointer + nix flake input). Commit both in one change.
update-common:
git submodule update --remote --merge dux-qml-common
nix --extra-experimental-features 'nix-command flakes' flake update dank-qml-common
# Installation targets # Installation targets
install-bin: install-bin:
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..." @echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME) @install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
@echo "Binary installed" @echo "Binary installed"
install-shell:
@echo "Installing shell files to $(SHELL_INSTALL_DIR)..."
@mkdir -p $(SHELL_INSTALL_DIR)
@cp -r $(SHELL_DIR)/* $(SHELL_INSTALL_DIR)/
@rm -rf $(SHELL_INSTALL_DIR)/.git* $(SHELL_INSTALL_DIR)/.github
@echo "Shell files installed"
install-completions: install-completions:
@echo "Installing shell completions..." @echo "Installing shell completions..."
@mkdir -p $(DATA_DIR)/bash-completion/completions @mkdir -p $(DATA_DIR)/bash-completion/completions
@@ -69,35 +59,29 @@ install-completions:
@echo "Shell completions installed" @echo "Shell completions installed"
install-systemd: install-systemd:
ifneq ($(shell uname),Linux)
@echo "Skipping systemd user service (non-Linux); start the shell from your compositor config with 'dms run'"
else
@echo "Installing systemd user service..." @echo "Installing systemd user service..."
@mkdir -p $(SYSTEMD_USER_DIR) @mkdir -p $(SYSTEMD_USER_DIR)
@if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR); fi @if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR); fi
@sed 's|/usr/bin/dms|$(PREFIX)/bin/dms|g' $(ASSETS_DIR)/systemd/dms.service > $(SYSTEMD_USER_DIR)/dms.service @sed 's|/usr/bin/dms|$(INSTALL_DIR)/dms|g' $(ASSETS_DIR)/systemd/dms.service > $(SYSTEMD_USER_DIR)/dms.service
@chmod 644 $(SYSTEMD_USER_DIR)/dms.service @chmod 644 $(SYSTEMD_USER_DIR)/dms.service
@if [ -n "$(SUDO_USER)" ]; then chown $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR)/dms.service; fi @if [ -n "$(SUDO_USER)" ]; then chown $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR)/dms.service; fi
@echo "Systemd service installed to $(SYSTEMD_USER_DIR)/dms.service" @echo "Systemd service installed to $(SYSTEMD_USER_DIR)/dms.service"
endif
install-icon: install-icon:
@echo "Installing icon..." @echo "Installing icon..."
@install -D -m 644 $(ASSETS_DIR)/dev.duxlabs.shell.svg $(ICON_DIR)/dev.duxlabs.shell.svg @install -D -m 644 $(ASSETS_DIR)/danklogo.svg $(ICON_DIR)/danklogo.svg
@rm -f $(ICON_DIR)/com.danklinux.dms.svg
@gtk-update-icon-cache -q $(DATA_DIR)/icons/hicolor 2>/dev/null || true @gtk-update-icon-cache -q $(DATA_DIR)/icons/hicolor 2>/dev/null || true
@echo "Icon installed" @echo "Icon installed"
install-desktop: install-desktop:
@echo "Installing desktop entries..." @echo "Installing desktop entries..."
@install -D -m 644 $(ASSETS_DIR)/dms-open.desktop $(APPLICATIONS_DIR)/dms-open.desktop @install -D -m 644 $(ASSETS_DIR)/dms-open.desktop $(APPLICATIONS_DIR)/dms-open.desktop
@install -D -m 644 $(ASSETS_DIR)/dev.duxlabs.shell.desktop $(APPLICATIONS_DIR)/dev.duxlabs.shell.desktop @install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
@install -D -m 644 $(ASSETS_DIR)/dev.duxlabs.shell.notepad.desktop $(APPLICATIONS_DIR)/dev.duxlabs.shell.notepad.desktop @install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.notepad.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true @update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
@echo "Desktop entries installed" @echo "Desktop entries installed"
install: install-bin install-completions install-systemd install-icon install-desktop install: install-bin install-shell install-completions install-systemd install-icon install-desktop
@echo "" @echo ""
@echo "Installation complete!" @echo "Installation complete!"
@echo "" @echo ""
@@ -110,7 +94,7 @@ uninstall-bin:
@echo "Binary removed" @echo "Binary removed"
uninstall-shell: uninstall-shell:
@echo "Removing pre-1.6 shell files from $(SHELL_INSTALL_DIR)..." @echo "Removing shell files from $(SHELL_INSTALL_DIR)..."
@rm -rf $(SHELL_INSTALL_DIR) @rm -rf $(SHELL_INSTALL_DIR)
@echo "Shell files removed" @echo "Shell files removed"
@@ -129,8 +113,6 @@ uninstall-systemd:
uninstall-icon: uninstall-icon:
@echo "Removing icon..." @echo "Removing icon..."
@rm -f $(ICON_DIR)/dev.duxlabs.shell.svg
@rm -f $(ICON_DIR)/com.danklinux.dms.svg
@rm -f $(ICON_DIR)/danklogo.svg @rm -f $(ICON_DIR)/danklogo.svg
@gtk-update-icon-cache -q $(DATA_DIR)/icons/hicolor 2>/dev/null || true @gtk-update-icon-cache -q $(DATA_DIR)/icons/hicolor 2>/dev/null || true
@echo "Icon removed" @echo "Icon removed"
@@ -138,9 +120,8 @@ uninstall-icon:
uninstall-desktop: uninstall-desktop:
@echo "Removing desktop entries..." @echo "Removing desktop entries..."
@rm -f $(APPLICATIONS_DIR)/dms-open.desktop @rm -f $(APPLICATIONS_DIR)/dms-open.desktop
@rm -f $(APPLICATIONS_DIR)/dev.duxlabs.shell.desktop @rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
@rm -f $(APPLICATIONS_DIR)/dev.duxlabs.shell.notepad.desktop @rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true @update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
@echo "Desktop entries removed" @echo "Desktop entries removed"
@@ -157,11 +138,11 @@ help:
@echo " build - Same as 'all'" @echo " build - Same as 'all'"
@echo " clean - Clean build artifacts" @echo " clean - Clean build artifacts"
@echo " lint-qml - Run qmllint on shell entrypoints using the Quickshell tooling VFS" @echo " lint-qml - Run qmllint on shell entrypoints using the Quickshell tooling VFS"
@echo " test-qml - Run shell logic and Qt unit tests"
@echo "" @echo ""
@echo "Install:" @echo "Install:"
@echo " install - Build and install everything (requires sudo)" @echo " install - Build and install everything (requires sudo)"
@echo " install-bin - Install only the binary" @echo " install-bin - Install only the binary"
@echo " install-shell - Install only shell files"
@echo " install-completions - Install only shell completions" @echo " install-completions - Install only shell completions"
@echo " install-systemd - Install only systemd service" @echo " install-systemd - Install only systemd service"
@echo " install-icon - Install only icon" @echo " install-icon - Install only icon"
@@ -170,7 +151,7 @@ help:
@echo "Uninstall:" @echo "Uninstall:"
@echo " uninstall - Remove everything (requires sudo)" @echo " uninstall - Remove everything (requires sudo)"
@echo " uninstall-bin - Remove only the binary" @echo " uninstall-bin - Remove only the binary"
@echo " uninstall-shell - Remove shell files left by pre-1.6 installs" @echo " uninstall-shell - Remove only shell files"
@echo " uninstall-completions - Remove only shell completions" @echo " uninstall-completions - Remove only shell completions"
@echo " uninstall-systemd - Remove only systemd service" @echo " uninstall-systemd - Remove only systemd service"
@echo " uninstall-icon - Remove only icon" @echo " uninstall-icon - Remove only icon"
+27 -45
View File
@@ -1,32 +1,32 @@
# Dux Shell # DankMaterialShell
<div align="center"> <div align="center">
<a href="https://duxlabs.dev"> <a href="https://danklinux.com">
<img src="assets/danklogo.svg" alt="Dux Shell" width="200"> <img src="assets/danklogo.svg" alt="DankMaterialShell" width="200">
</a> </a>
### A modern desktop shell for Wayland ### A modern desktop shell for Wayland
Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/) Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
[![Documentation](https://img.shields.io/badge/docs-duxlabs.dev-9ccbfb?style=for-the-badge&labelColor=101418)](https://duxlabs.dev/docs) [![Documentation](https://img.shields.io/badge/docs-danklinux.com-9ccbfb?style=for-the-badge&labelColor=101418)](https://danklinux.com/docs)
[![GitHub stars](https://img.shields.io/github/stars/duxlabs-dev/dux-shell?style=for-the-badge&labelColor=101418&color=ffd700)](https://github.com/duxlabs-dev/dux-shell/stargazers) [![GitHub stars](https://img.shields.io/github/stars/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=ffd700)](https://github.com/AvengeMedia/DankMaterialShell/stargazers)
[![GitHub License](https://img.shields.io/github/license/duxlabs-dev/dux-shell?style=for-the-badge&labelColor=101418&color=b9c8da)](https://github.com/duxlabs-dev/dux-shell/blob/master/LICENSE) [![GitHub License](https://img.shields.io/github/license/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=b9c8da)](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE)
[![GitHub release](https://img.shields.io/github/v/release/duxlabs-dev/dux-shell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://github.com/duxlabs-dev/dux-shell/releases) [![GitHub release](https://img.shields.io/github/v/release/AvengeMedia/DankMaterialShell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://github.com/AvengeMedia/DankMaterialShell/releases)
[![Arch version](https://img.shields.io/archlinux/v/extra/x86_64/dms-shell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://archlinux.org/packages/extra/x86_64/dms-shell/) [![Arch version](https://img.shields.io/archlinux/v/extra/x86_64/dms-shell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://archlinux.org/packages/extra/x86_64/dms-shell/)
[![AUR version (git)](<https://img.shields.io/aur/version/dms-shell-git?style=for-the-badge&labelColor=101418&color=9ccbfb&label=AUR%20(git)>)](https://aur.archlinux.org/packages/dms-shell-git) [![AUR version (git)](<https://img.shields.io/aur/version/dms-shell-git?style=for-the-badge&labelColor=101418&color=9ccbfb&label=AUR%20(git)>)](https://aur.archlinux.org/packages/dms-shell-git)
[![Ko-Fi donate](https://img.shields.io/badge/donate-kofi?style=for-the-badge&logo=ko-fi&logoColor=ffffff&label=ko-fi&labelColor=101418&color=f16061&link=https%3A%2F%2Fko-fi.com%2Fduxlabs)](https://ko-fi.com/duxlabs) [![Ko-Fi donate](https://img.shields.io/badge/donate-kofi?style=for-the-badge&logo=ko-fi&logoColor=ffffff&label=ko-fi&labelColor=101418&color=f16061&link=https%3A%2F%2Fko-fi.com%2Fdanklinux)](https://ko-fi.com/danklinux)
</div> </div>
Dux Shell is a complete desktop shell for [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [MangoWC](https://github.com/DreamMaoMao/mangowc), [Sway](https://swaywm.org), [labwc](https://labwc.github.io/), [Scroll](https://github.com/dawsers/scroll), [Miracle WM](https://github.com/miracle-wm-org/miracle-wm), and other Wayland compositors. It replaces waybar, swaylock, swayidle, mako, fuzzel, polkit, and everything else you'd normally stitch together to make a desktop. DankMaterialShell is a complete desktop shell for [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [MangoWC](https://github.com/DreamMaoMao/mangowc), [Sway](https://swaywm.org), [labwc](https://labwc.github.io/), [Scroll](https://github.com/dawsers/scroll), [Miracle WM](https://github.com/miracle-wm-org/miracle-wm), and other Wayland compositors. It replaces waybar, swaylock, swayidle, mako, fuzzel, polkit, and everything else you'd normally stitch together to make a desktop.
## Repository Structure ## Repository Structure
This is a monorepo containing both the shell interface and the core backend services: This is a monorepo containing both the shell interface and the core backend services:
``` ```
dux-shell/ DankMaterialShell/
├── quickshell/ # QML-based shell interface ├── quickshell/ # QML-based shell interface
│ ├── Modules/ # UI components (panels, widgets, overlays) │ ├── Modules/ # UI components (panels, widgets, overlays)
│ ├── Services/ # System integration (audio, network, bluetooth) │ ├── Services/ # System integration (audio, network, bluetooth)
@@ -47,7 +47,7 @@ dux-shell/
<div align="center"> <div align="center">
https://github.com/user-attachments/assets/d310418b-cc44-4ace-ac0d-2fc0c851291d https://github.com/user-attachments/assets/1200a739-7770-4601-8b85-695ca527819a
</div> </div>
@@ -70,14 +70,12 @@ https://github.com/user-attachments/assets/d310418b-cc44-4ace-ac0d-2fc0c851291d
## Installation ## Installation
```bash ```bash
curl -fsSL https://install.duxlabs.dev | sh curl -fsSL https://install.danklinux.com | sh
``` ```
One command installs DMS and all dependencies on Arch, Fedora, Debian, Ubuntu, openSUSE, or Gentoo. One command installs DMS and all dependencies on Arch, Fedora, Debian, Ubuntu, openSUSE, or Gentoo.
**FreeBSD:** native support is included in-tree; see [distro/freebsd/README.md](distro/freebsd/README.md) for supported integrations and setup notes. **[Manual installation guide](https://danklinux.com/docs/dankmaterialshell/installation)**
**[Manual installation guide](https://duxlabs.dev/docs/dms/installation)**
## Features ## Features
@@ -85,10 +83,10 @@ One command installs DMS and all dependencies on Arch, Fedora, Debian, Ubuntu, o
Wallpaper-based color schemes that automatically theme GTK, Qt, terminals, editors (vscode, vscodium), and more using [matugen](https://github.com/InioX/matugen) and dank16. Wallpaper-based color schemes that automatically theme GTK, Qt, terminals, editors (vscode, vscodium), and more using [matugen](https://github.com/InioX/matugen) and dank16.
**System Monitoring** **System Monitoring**
Real-time CPU, RAM, GPU metrics and temperatures built into the dms daemon (powered by [dgop](https://github.com/duxlabs-dev/dux-gop)). Process list with search and management. Real-time CPU, RAM, GPU metrics and temperatures with [dgop](https://github.com/AvengeMedia/dgop). Process list with search and management.
**Powerful Launcher** **Powerful Launcher**
Spotlight-style search for applications, files ([dsearch](https://github.com/duxlabs-dev/dux-search)), emojis, running windows, calculator, and commands. Extensible with plugins. Spotlight-style search for applications, files ([dsearch](https://github.com/AvengeMedia/danksearch)), emojis, running windows, calculator, and commands. Extensible with plugins.
**Control Center** **Control Center**
Unified interface for network, Bluetooth, audio devices, display settings, and night mode. Unified interface for network, Bluetooth, audio devices, display settings, and night mode.
@@ -100,18 +98,16 @@ Notification center with grouping, rich text support, and keyboard navigation.
MPRIS player controls, calendar sync, weather widgets, and clipboard history with image previews. MPRIS player controls, calendar sync, weather widgets, and clipboard history with image previews.
**Session Management** **Session Management**
Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and a settings front-end for [dux-greeter](https://github.com/duxlabs-dev/dux-greeter). Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and greeter support.
**Plugin System** **Plugin System**
Extend functionality with the [plugin registry](https://plugins.duxlabs.dev). DMS keeps Extend functionality with the [plugin registry](https://plugins.danklinux.com).
`~/.config/dms/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 ## Supported Compositors
Works best with [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [Sway](https://swaywm.org/), [MangoWC](https://github.com/DreamMaoMao/mangowc), [labwc](https://labwc.github.io/), [Scroll](https://github.com/dawsers/scroll), and [Miracle WM](https://github.com/miracle-wm-org/miracle-wm) with full workspace switching, overview integration, and monitor management. Other Wayland compositors work with reduced features. Works best with [niri](https://github.com/YaLTeR/niri), [Hyprland](https://hyprland.org/), [Sway](https://swaywm.org/), [MangoWC](https://github.com/DreamMaoMao/mangowc), [labwc](https://labwc.github.io/), [Scroll](https://github.com/dawsers/scroll), and [Miracle WM](https://github.com/miracle-wm-org/miracle-wm) with full workspace switching, overview integration, and monitor management. Other Wayland compositors work with reduced features.
[Compositor configuration guide](https://duxlabs.dev/docs/dms/compositors) [Compositor configuration guide](https://danklinux.com/docs/dankmaterialshell/compositors)
## Command Line Interface ## Command Line Interface
@@ -124,30 +120,17 @@ dms ipc call audio setvolume 50
dms ipc call wallpaper set /path/to/image.jpg dms ipc call wallpaper set /path/to/image.jpg
dms brightness list # List available displays dms brightness list # List available displays
dms plugins search # Browse plugin registry dms plugins search # Browse plugin registry
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 ## Documentation
- **Website:** [duxlabs.dev](https://duxlabs.dev) - **Website:** [danklinux.com](https://danklinux.com)
- **Docs:** [duxlabs.dev/docs](https://duxlabs.dev/docs/) - **Docs:** [danklinux.com/docs](https://danklinux.com/docs/)
- **Theming:** [Application themes](https://duxlabs.dev/docs/dms/application-themes) | [Custom themes](https://duxlabs.dev/docs/dms/custom-themes) - **Theming:** [Application themes](https://danklinux.com/docs/dankmaterialshell/application-themes) | [Custom themes](https://danklinux.com/docs/dankmaterialshell/custom-themes)
- **Plugins:** [Development guide](https://duxlabs.dev/docs/dms/plugins-overview) - **Plugins:** [Development guide](https://danklinux.com/docs/dankmaterialshell/plugins-overview)
- **Support:** [Ko-fi](https://ko-fi.com/duxlabs) - **Support:** [Ko-fi](https://ko-fi.com/avengemediallc)
## Dux 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.
## Development ## Development
@@ -177,7 +160,7 @@ quickshell -p quickshell/
```nix ```nix
{ {
inputs.dms.url = "github:duxlabs-dev/dux-shell"; inputs.dms.url = "github:AvengeMedia/DankMaterialShell";
# Use in home-manager or NixOS configuration # Use in home-manager or NixOS configuration
imports = [ inputs.dms.homeModules.dank-material-shell ]; imports = [ inputs.dms.homeModules.dank-material-shell ];
@@ -193,7 +176,7 @@ Contributions welcome. Bug fixes, widgets, features, documentation, and plugins
3. Test thoroughly 3. Test thoroughly
4. Open a pull request 4. Open a pull request
For documentation contributions, see [dux-docs](https://github.com/duxlabs-dev/dux-docs). For documentation contributions, see [DankLinux-Docs](https://github.com/AvengeMedia/DankLinux-Docs).
## Credits ## Credits
@@ -202,11 +185,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) - [Ly-sec](http://github.com/ly-sec) - Wallpaper effects from [Noctalia](https://github.com/noctalia-dev/noctalia-shell)
- [soramanew](https://github.com/soramanew) - [Caelestia](https://github.com/caelestia-dots/shell) inspiration - [soramanew](https://github.com/soramanew) - [Caelestia](https://github.com/caelestia-dots/shell) inspiration
- [end-4](https://github.com/end-4) - [dots-hyprland](https://github.com/end-4/dots-hyprland) inspiration - [end-4](https://github.com/end-4) - [dots-hyprland](https://github.com/end-4/dots-hyprland) inspiration
- [Reverier-Xu](https://github.com/Reverier-Xu) - [Ori-fcitx5](https://github.com/Reverier-Xu/Ori-fcitx5) inspiration for Fcitx5 theme
## Star History ## Star History
[![Star History Chart](https://star-history.dera.page/svg?repos=duxlabs-dev/dux-shell&type=date&legend=top-left)](https://star-history.dera.page/#duxlabs-dev/dux-shell&type=date&legend=top-left) [![Star History Chart](https://api.star-history.com/svg?repos=AvengeMedia/DankMaterialShell&type=date&legend=top-left)](https://www.star-history.com/#AvengeMedia/DankMaterialShell&type=date&legend=top-left)
## License ## License
@@ -1,10 +1,10 @@
[Desktop Entry] [Desktop Entry]
Type=Application Type=Application
Name=Dux Shell Name=DankMaterialShell
GenericName=Desktop Shell GenericName=Desktop Shell
Comment=Material 3 inspired shell for Wayland compositors Comment=Material 3 inspired shell for Wayland compositors
Exec=dms run Exec=dms run
Icon=dev.duxlabs.shell Icon=danklogo
Terminal=false Terminal=false
NoDisplay=true NoDisplay=true
StartupNotify=false StartupNotify=false
@@ -2,9 +2,9 @@
Type=Application Type=Application
Name=DMS Notepad Name=DMS Notepad
GenericName=Text Editor GenericName=Text Editor
Comment=Quick notes and text files in Dux Shell Comment=Quick notes and text files in the dank desktop shell
Exec=dms ipc call notepad openFile %f Exec=dms ipc call notepad openFile %f
Icon=dev.duxlabs.shell Icon=danklogo
Terminal=false Terminal=false
NoDisplay=true NoDisplay=true
Categories=Utility;TextEditor; Categories=Utility;TextEditor;
-113
View File
@@ -1,113 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg1"
width="482.90668"
height="558.15088"
viewBox="0 0 482.90667 558.15088"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
shape-rendering="auto"
style="image-rendering: auto; filter: url(#smoothing);">
<defs
id="defs1">
<filter id="smoothing" x="-0.05" y="-0.05" width="1.1" height="1.1">
<feGaussianBlur in="SourceGraphic" stdDeviation="0.5" />
</filter>
<color-profile
name="sRGB IEC61966-2.1"
xlink:href="data:application/vnd.iccprofile;base64,AAAMbExpbm8CEAAAbW50clJHQiBYWVogB84AAgAJAAYAMQAAYWNzcE1TRlQAAAAASUVDIHNSR0IAAAAAAAAAAAAAAAAAAPbWAAEAAAAA0y1IUCAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARY3BydAAAAVAAAAAzZGVzYwAAAYQAAACQd3RwdAAAAhQAAAAUYmtwdAAAAigAAAAUclhZWgAAAjwAAAAUZ1hZWgAAAlAAAAAUYlhZWgAAAmQAAAAUZG1uZAAAAngAAABwZG1kZAAAAugAAACIdnVlZAAAA3AAAACGdmlldwAAA/gAAAAkbHVtaQAABBwAAAAUbWVhcwAABDAAAAAkdGVjaAAABFQAAAAMclRSQwAABGAAAAgMZ1RSQwAABGAAAAgMYlRSQwAABGAAAAgMdGV4dAAAAABDb3B5cmlnaHQgKGMpIDE5OTggSGV3bGV0dC1QYWNrYXJkIENvbXBhbnkAAGRlc2MAAAAAAAAAEnNSR0IgSUVDNjE5NjYtMi4xAAAAAAAAAAASAHMAUgBHAEIAIABJAEUAQwA2ADEAOQA2ADYALQAyAC4AMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFhZWiAAAAAAAADzUQABAAAAARbMWFlaIAAAAAAAAAAAAAAAAAAAAABYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9kZXNjAAAAAAAAABZJRUMgaHR0cDovL3d3dy5pZWMuY2gAAAAAAAAAAAAAABZJRUMgaHR0cDovL3d3dy5pZWMuY2gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZGVzYwAAAAAAAAAuSUVDIDYxOTY2LTIuMSBEZWZhdWx0IFJHQiBjb2xvdXIgc3BhY2UgLSBzUkdCAAAAAAAAAAAAAAAuSUVDIDYxOTY2LTIuMSBEZWZhdWx0IFJHQiBjb2xvdXIgc3BhY2UgLSBzUkdCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGRlc2MAAAAAAAAALFJlZmVyZW5jZSBWaWV3aW5nIENvbmRpdGlvbiBpbiBJRUM2MTk2Ni0yLjEAAAAAAAAAAAAAACxSZWZlcmVuY2UgVmlld2luZyBDb25kaXRpb24gaW4gSUVDNjE5NjYtMi4xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB2aWV3AAAAAAATpP4AFF8uABDPFAAD7cwABBMLAANcngAAAAFYWVogAAAAAABMCVYAUAAAAFcf521lYXMAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAKPAAAAAnNpZyAAAAAAQ1JUIGN1cnYAAAAAAAAEAAAAAAUACgAPABQAGQAeACMAKAAtADIANwA7AEAARQBKAE8AVABZAF4AYwBoAG0AcgB3AHwAgQCGAIsAkACVAJoAnwCkAKkArgCyALcAvADBAMYAywDQANUA2wDgAOUA6wDwAPYA+wEBAQcBDQETARkBHwElASsBMgE4AT4BRQFMAVIBWQFgAWcBbgF1AXwBgwGLAZIBmgGhAakBsQG5AcEByQHRAdkB4QHpAfIB+gIDAgwCFAIdAiYCLwI4AkECSwJUAl0CZwJxAnoChAKOApgCogKsArYCwQLLAtUC4ALrAvUDAAMLAxYDIQMtAzgDQwNPA1oDZgNyA34DigOWA6IDrgO6A8cD0wPgA+wD+QQGBBMEIAQtBDsESARVBGMEcQR+BIwEmgSoBLYExATTBOEE8AT+BQ0FHAUrBToFSQVYBWcFdwWGBZYFpgW1BcUF1QXlBfYGBgYWBicGNwZIBlkGagZ7BowGnQavBsAG0QbjBvUHBwcZBysHPQdPB2EHdAeGB5kHrAe/B9IH5Qf4CAsIHwgyCEYIWghuCIIIlgiqCL4I0gjnCPsJEAklCToJTwlkCXkJjwmkCboJzwnlCfsKEQonCj0KVApqCoEKmAquCsUK3ArzCwsLIgs5C1ELaQuAC5gLsAvIC+EL+QwSDCoMQwxcDHUMjgynDMAM2QzzDQ0NJg1ADVoNdA2ODakNww3eDfgOEw4uDkkOZA5/DpsOtg7SDu4PCQ8lD0EPXg96D5YPsw/PD+wQCRAmEEMQYRB+EJsQuRDXEPURExExEU8RbRGMEaoRyRHoEgcSJhJFEmQShBKjEsMS4xMDEyMTQxNjE4MTpBPFE+UUBhQnFEkUahSLFK0UzhTwFRIVNBVWFXgVmxW9FeAWAxYmFkkWbBaPFrIW1hb6Fx0XQRdlF4kXrhfSF/cYGxhAGGUYihivGNUY+hkgGUUZaxmRGbcZ3RoEGioaURp3Gp4axRrsGxQbOxtjG4obshvaHAIcKhxSHHscoxzMHPUdHh1HHXAdmR3DHeweFh5AHmoelB6+HukfEx8+H2kflB+/H+ogFSBBIGwgmCDEIPAhHCFIIXUhoSHOIfsiJyJVIoIiryLdIwojOCNmI5QjwiPwJB8kTSR8JKsk2iUJJTglaCWXJccl9yYnJlcmhya3JugnGCdJJ3onqyfcKA0oPyhxKKIo1CkGKTgpaymdKdAqAio1KmgqmyrPKwIrNitpK50r0SwFLDksbiyiLNctDC1BLXYtqy3hLhYuTC6CLrcu7i8kL1ovkS/HL/4wNTBsMKQw2zESMUoxgjG6MfIyKjJjMpsy1DMNM0YzfzO4M/E0KzRlNJ402DUTNU01hzXCNf02NzZyNq426TckN2A3nDfXOBQ4UDiMOMg5BTlCOX85vDn5OjY6dDqyOu87LTtrO6o76DwnPGU8pDzjPSI9YT2hPeA+ID5gPqA+4D8hP2E/oj/iQCNAZECmQOdBKUFqQaxB7kIwQnJCtUL3QzpDfUPARANER0SKRM5FEkVVRZpF3kYiRmdGq0bwRzVHe0fASAVIS0iRSNdJHUljSalJ8Eo3Sn1KxEsMS1NLmkviTCpMcky6TQJNSk2TTdxOJU5uTrdPAE9JT5NP3VAnUHFQu1EGUVBRm1HmUjFSfFLHUxNTX1OqU/ZUQlSPVNtVKFV1VcJWD1ZcVqlW91dEV5JX4FgvWH1Yy1kaWWlZuFoHWlZaplr1W0VblVvlXDVchlzWXSddeF3JXhpebF69Xw9fYV+zYAVgV2CqYPxhT2GiYfViSWKcYvBjQ2OXY+tkQGSUZOllPWWSZedmPWaSZuhnPWeTZ+loP2iWaOxpQ2maafFqSGqfavdrT2una/9sV2yvbQhtYG25bhJua27Ebx5veG/RcCtwhnDgcTpxlXHwcktypnMBc11zuHQUdHB0zHUodYV14XY+dpt2+HdWd7N4EXhueMx5KnmJeed6RnqlewR7Y3vCfCF8gXzhfUF9oX4BfmJ+wn8jf4R/5YBHgKiBCoFrgc2CMIKSgvSDV4O6hB2EgITjhUeFq4YOhnKG14c7h5+IBIhpiM6JM4mZif6KZIrKizCLlov8jGOMyo0xjZiN/45mjs6PNo+ekAaQbpDWkT+RqJIRknqS45NNk7aUIJSKlPSVX5XJljSWn5cKl3WX4JhMmLiZJJmQmfyaaJrVm0Kbr5wcnImc951kndKeQJ6unx2fi5/6oGmg2KFHobaiJqKWowajdqPmpFakx6U4pammGqaLpv2nbqfgqFKoxKk3qamqHKqPqwKrdavprFys0K1ErbiuLa6hrxavi7AAsHWw6rFgsdayS7LCszizrrQltJy1E7WKtgG2ebbwt2i34LhZuNG5SrnCuju6tbsuu6e8IbybvRW9j74KvoS+/796v/XAcMDswWfB48JfwtvDWMPUxFHEzsVLxcjGRsbDx0HHv8g9yLzJOsm5yjjKt8s2y7bMNcy1zTXNtc42zrbPN8+40DnQutE80b7SP9LB00TTxtRJ1MvVTtXR1lXW2Ndc1+DYZNjo2WzZ8dp22vvbgNwF3IrdEN2W3hzeot8p36/gNuC94UThzOJT4tvjY+Pr5HPk/OWE5g3mlucf56noMui86Ubp0Opb6uXrcOv77IbtEe2c7ijutO9A78zwWPDl8XLx//KM8xnzp/Q09ML1UPXe9m32+/eK+Bn4qPk4+cf6V/rn+3f8B/yY/Sn9uv5L/tz/bf//"
id="color-profile1" />
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath21">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-673.87432,-704.25842)"
id="path21" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath25">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-466.30451,-703.59782)"
id="path25" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath27">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-695.28002,-473.92741)"
id="path27" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath29">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-457.93881,-632.99062)"
id="path29" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath31">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-466.30451,-703.59782)"
id="path31" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath33">
<path
d="M 0,1200 H 2000 V 0 H 0 Z"
transform="translate(-614.51722,-638.93302)"
id="path33" />
</clipPath>
</defs>
<g
id="layer-MC0"
transform="translate(-486.31024,-515.02722)">
<path
id="path20"
d="M 0,0 C -1.568,1.568 -3.163,3.098 -4.787,4.61 -5.944,3.966 -7.185,3.35 -8.529,2.762 -9.658,2.277 -10.815,1.82 -11.981,1.4 c 1.885,-1.689 3.742,-3.425 5.552,-5.207 0.448,-0.429 0.886,-0.868 1.325,-1.306 2.221,-2.221 4.386,-4.498 6.476,-6.84 27.639,-30.784 44.453,-71.459 44.453,-116.089 0,-29.347 -7.259,-56.977 -20.09,-81.21 -2.192,-4.134 -4.544,-8.174 -7.054,-12.102 -6.83,-10.74 -14.827,-20.669 -23.785,-29.636 -5.944,-5.944 -12.317,-11.459 -19.073,-16.498 -0.56,-0.42 -1.12,-0.83 -1.689,-1.231 -28.675,-20.893 -63.975,-33.201 -102.186,-33.201 -48.018,0 -91.464,19.456 -122.948,50.93 -0.737,0.737 -1.465,1.474 -2.174,2.221 -0.55,0.569 -1.101,1.147 -1.633,1.726 -15.545,16.553 -27.881,36.14 -36.018,57.779 -3.098,8.211 -5.58,16.712 -7.409,25.464 -2.417,11.534 -3.686,23.496 -3.686,35.758 0.01,42.326 15.117,81.097 40.246,111.246 -2.072,1.278 -3.975,2.809 -5.534,4.637 -26.174,-31.399 -41.934,-71.822 -41.934,-115.883 0,-18.187 2.678,-35.748 7.67,-52.311 3.359,-11.142 7.754,-21.835 13.092,-31.95 8.528,-16.208 19.446,-30.961 32.276,-43.801 1.251,-1.25 2.529,-2.491 3.817,-3.685 0.662,-0.644 1.334,-1.26 2.006,-1.876 10.862,-9.938 22.945,-18.578 36,-25.661 25.632,-13.913 55.016,-21.816 86.229,-21.816 2.454,0 4.908,0.047 7.334,0.14 36.056,1.446 69.424,13.427 97.054,32.976 0.569,0.392 1.148,0.793 1.698,1.204 7.82,5.655 15.164,11.925 21.966,18.718 7.904,7.904 15.07,16.526 21.406,25.773 2.556,3.723 4.973,7.558 7.25,11.477 15.499,26.697 24.382,57.723 24.382,90.812 C 53.038,-78.055 32.762,-32.762 0,0"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,898.49907,660.9888)"
clip-path="url(#clipPath21)" />
<path
id="path24"
d="M 0,0 -0.169,-0.292 C 1.43,-0.2 3.091,-0.108 4.798,0 Z"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,621.73933,661.8696)"
clip-path="url(#clipPath25)" />
<path
id="path26"
d="m 0,0 c -6.336,-9.247 -13.502,-17.869 -21.406,-25.773 -6.802,-6.793 -14.146,-13.063 -21.965,-18.718 -0.551,-0.411 -1.129,-0.812 -1.699,-1.204 -27.629,-19.549 -60.998,-31.53 -97.053,-32.976 -33.247,1.129 -64.852,8.762 -93.564,21.676 -13.054,7.082 -25.138,15.723 -36,25.661 -0.672,0.616 -1.343,1.232 -2.006,1.876 -1.288,1.194 -2.566,2.435 -3.816,3.685 -12.831,12.84 -23.748,27.593 -32.277,43.801 -1.092,7.651 -1.941,15.378 -2.445,23.039 -0.102,1.502 -0.186,3.004 -0.261,4.497 0,0 -2.865,29.795 23.944,36.634 26.827,6.84 65.654,19.745 87.722,50.305 0,0 0.327,-8.38 5.506,-15.779 8.034,-11.422 46.674,-100.46 46.674,-100.46 l 6.121,51.135 -13.978,15.079 -4.553,-1.987 15.228,16.544 c 0,0 8.94,4.218 16.554,-0.653 7.605,-4.899 14.146,-16.153 14.146,-16.153 l -5.879,3.892 -4.227,-12.364 c -0.756,-2.184 -0.83,-4.535 -0.233,-6.784 l 16.796,-62.687 c 0,0 -0.243,87.415 -2.781,111.685 0,0 14.221,-10.367 21.621,-14.454 7.381,-4.078 47.215,-20.407 53.738,-22.395 6.504,-1.987 13.222,-5.841 15.882,-11.916 1.026,-2.351 8.594,-26.939 17.486,-56.229 C -1.829,6.028 -0.914,3.023 0,0"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,927.04,968.0968)"
clip-path="url(#clipPath27)" />
<path
id="path28"
d="m 0,0 c 0,0 -3.081,-67.22 -8.64,-90.616 -5.559,-23.397 30.316,0 30.316,0 l 20.9,68.629 z"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,610.58507,756.01253)"
clip-path="url(#clipPath29)" />
<path
id="path30"
d="M 0,0 -0.169,-0.292 C 1.43,-0.2 3.091,-0.108 4.798,0 Z"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,621.73933,661.8696)"
clip-path="url(#clipPath31)" />
<path
id="path32"
d="m 0,0 c 0,0 12.178,7.604 15.029,18.355 0,0 36.913,-9.243 52.904,-26.719 C 66.887,-8.091 29.642,2.031 0,0 m -36.152,-5.643 c -26.791,0.404 -31.781,16.05 -34.086,29.523 -0.511,2.958 -3.148,5.073 -6.13,4.883 l -46.275,-4.764 14.922,7.14 c 3.505,1.687 7.164,3.077 10.895,4.17 3.742,1.093 7.556,1.901 11.429,2.376 4.051,0.499 8.542,1.01 13.46,1.509 0,0 6.19,2.459 7.414,-4.479 1.259,-6.926 0.499,-31.864 28.287,-31.103 27.777,0.76 32.612,19.412 32.612,19.412 0,0 3.458,14.471 9.232,14.257 5.263,-0.202 7.27,-6.142 7.853,-10.479 0.142,-1.045 -0.06,-3.54 0.582,-4.324 -0.939,1.152 -2.162,2.032 -3.564,2.554 C 7.604,26.113 3.065,26.945 2.471,21.658 1.616,13.877 -9.599,-6.059 -36.152,-5.643 m 13.306,48.354 9.528,-2.377 c 0,0 -2.388,-17.5 -11.227,-21.979 -0.25,-0.13 4.194,14.281 1.699,24.356 m 59.022,3.041 3.79,-1.687 c 0,0 2.198,-14.209 -3.79,-20.577 0,0 2.174,12.474 0,22.264 m -187.451,35.63 c 12.225,2.067 24.45,4.229 36.664,6.332 l 36.663,6.392 73.303,12.748 c 2.958,0.522 5.774,-1.462 6.285,-4.408 0.522,-2.947 -1.462,-5.762 -4.42,-6.285 -0.119,-0.024 -0.261,-0.036 -0.38,-0.047 H -3.184 L -114.243,85.029 c -12.344,-1.224 -24.676,-2.495 -37.032,-3.647 M 43.97,16.93 c 3.54,4.693 5.215,23.096 5.215,23.096 l 1.497,2.518 v 8.744 C 29.381,61.327 11.869,50.682 11.869,50.682 l -16.491,0.749 c -3.112,0.142 -7.449,2.637 -10.644,3.433 -4.42,1.093 -8.804,2.068 -13.342,2.566 -16.407,1.735 -32.933,0 -49.091,-2.934 -7.021,-1.271 -13.971,-2.851 -20.957,-4.325 -6.701,-1.425 -12.878,-3.908 -19.151,-6.605 -4.313,-1.842 -8.649,-3.659 -12.641,-6.119 -2.436,-1.496 -4.741,-3.243 -6.737,-5.31 -2.126,-2.21 -3.659,-4.859 -5.476,-7.319 -1.545,-2.091 -4.907,-0.463 -4.147,2.032 0.024,0.059 0.048,0.119 0.06,0.154 0.867,2.044 2.221,4.646 3.659,6.345 3.588,4.241 8.958,8.292 13.686,11.12 10.039,6.071 21.48,9.766 32.767,12.985 24.771,7.057 51.442,8.138 77.009,10.55 14.114,1.331 28.43,2.091 42.473,4.016 12.784,1.734 37.935,4.859 36.176,22.882 -1.71,18.129 -59.355,18.712 -59.355,18.712 0,0 -21.943,45.027 -27.372,50.468 -5.418,5.418 -16.503,18.474 -74.254,4.384 -57.739,-14.078 -55.327,-42.259 -55.327,-42.259 l -2.412,-39.622 c 0,0 -43.198,-11.179 -41.832,-23.321 1.391,-12.142 39.171,-12.819 39.171,-12.819 0,0 8.577,0.439 20.696,1.152 l -5.382,-9.683 c -6.749,-12.13 -6.38,-25.258 -8.851,-38.908 -4.277,-23.631 11.963,-52.702 33.978,-62.147 21.1,-9.053 53.949,-13.782 99.012,6.368 11.085,4.954 20.328,13.282 26.66,23.618 l 0.499,0.796 c 22.728,26.125 80.574,9.386 80.574,9.386 C 81.774,1.699 43.97,16.93 43.97,16.93"
style="fill:#D0BCFF;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,819.35627,748.08933)"
clip-path="url(#clipPath33)" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 13 KiB

+1 -1
View File
@@ -3,7 +3,7 @@ Type=Application
Name=DMS Name=DMS
Comment=Select an application to open links and files Comment=Select an application to open links and files
Exec=dms open %u Exec=dms open %u
Icon=dev.duxlabs.shell Icon=danklogo
Terminal=false Terminal=false
NoDisplay=true NoDisplay=true
MimeType=x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/dms;text/html;application/xhtml+xml; MimeType=x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/dms;text/html;application/xhtml+xml;
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=DMS Status Notifier Watcher (early tray)
# The tray watcher starts in parallel with the compositor and owns org.kde.StatusNotifierWatcher before
# graphical-session.target, i.e. before any XDG autostart app launches.
PartOf=graphical-session.target
Before=graphical-session.target
[Service]
Type=dbus
BusName=org.kde.StatusNotifierWatcher
ExecStart=/usr/bin/dms tray-watcher
Restart=on-failure
RestartSec=1
Slice=session.slice
[Install]
WantedBy=graphical-session.target
+4 -9
View File
@@ -1,21 +1,16 @@
[Unit] [Unit]
Description=Dux Shell (DMS) Description=Dank Material Shell (DMS)
PartOf=graphical-session.target PartOf=graphical-session.target
After=graphical-session.target After=graphical-session.target
Requisite=graphical-session.target Requisite=graphical-session.target
[Service] [Service]
Type=notify Type=dbus
NotifyAccess=main BusName=org.freedesktop.Notifications
Environment=QS_DISABLE_CRASH_HANDLER=1
ExecStart=/usr/bin/dms run --session ExecStart=/usr/bin/dms run --session
ExecReload=/bin/kill -USR1 $MAINPID ExecReload=/usr/bin/pkill -USR1 -x dms
LimitNOFILE=16384:infinity
Restart=on-failure Restart=on-failure
RestartForceExitStatus=TEMPFAIL
SuccessExitStatus=TEMPFAIL
RestartSec=1.23 RestartSec=1.23
TimeoutStartSec=90s
TimeoutStopSec=10 TimeoutStopSec=10
[Install] [Install]
+6 -5
View File
@@ -72,6 +72,7 @@ linters:
- path: _test\.go - path: _test\.go
linters: linters:
- errcheck - errcheck
- govet
- unused - unused
- ineffassign - ineffassign
- staticcheck - staticcheck
@@ -92,15 +93,15 @@ linters:
- linters: - linters:
- errcheck - errcheck
text: "Error return value of `.+\\.AddMatchSignal` is not checked" text: "Error return value of `.+\\.AddMatchSignal` is not checked"
# Exclude wayland pkg from errcheck and ineffassign (generated code patterns)
- linters:
- errcheck
- ineffassign
path: pkg/go-wayland/
# Exclude proto pkg from ineffassign (generated protocol code) # Exclude proto pkg from ineffassign (generated protocol code)
- linters: - linters:
- ineffassign - ineffassign
path: internal/proto/ path: internal/proto/
# Exclude proto pkg SA4006 (generated protocol code has trailing dead offset increments)
- linters:
- staticcheck
text: "SA4006:"
path: internal/proto/
# binary.Write/Read to bytes.Buffer can't fail # binary.Write/Read to bytes.Buffer can't fail
- linters: - linters:
- errcheck - errcheck
+10 -10
View File
@@ -6,13 +6,13 @@ template: testify
template-data: template-data:
unroll-variadic: true unroll-variadic: true
packages: packages:
github.com/duxlabs-dev/dux-shell/core/internal/geolocation: github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation:
config: config:
dir: internal/mocks/geolocation dir: internal/mocks/geolocation
pkgname: mocks_geolocation pkgname: mocks_geolocation
interfaces: interfaces:
Client: {} Client: {}
github.com/duxlabs-dev/dux-shell/core/internal/server/cups: github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups:
config: config:
dir: internal/mocks/cups dir: internal/mocks/cups
pkgname: mocks_cups pkgname: mocks_cups
@@ -22,46 +22,46 @@ packages:
config: config:
dir: internal/mocks/cups_pkhelper dir: internal/mocks/cups_pkhelper
pkgname: mocks_cups_pkhelper pkgname: mocks_cups_pkhelper
github.com/duxlabs-dev/dux-shell/core/internal/server/evdev: github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev:
config: config:
dir: internal/mocks/evdev dir: internal/mocks/evdev
pkgname: mocks_evdev pkgname: mocks_evdev
interfaces: interfaces:
EvdevDevice: {} EvdevDevice: {}
github.com/duxlabs-dev/dux-shell/core/internal/server/network: github.com/AvengeMedia/DankMaterialShell/core/internal/server/network:
config: config:
dir: internal/mocks/network dir: internal/mocks/network
pkgname: mocks_network pkgname: mocks_network
interfaces: interfaces:
Backend: {} Backend: {}
github.com/duxlabs-dev/dux-shell/core/internal/server/wlcontext: github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext:
config: config:
dir: internal/mocks/wlcontext dir: internal/mocks/wlcontext
pkgname: mocks_wlcontext pkgname: mocks_wlcontext
interfaces: interfaces:
WaylandContext: {} WaylandContext: {}
github.com/duxlabs-dev/dux-shell/core/internal/utils: github.com/AvengeMedia/DankMaterialShell/core/internal/utils:
config: config:
dir: internal/mocks/utils dir: internal/mocks/utils
pkgname: mocks_utils pkgname: mocks_utils
interfaces: interfaces:
AppChecker: {} AppChecker: {}
github.com/duxlabs-dev/dux-shell/core/internal/version: github.com/AvengeMedia/DankMaterialShell/core/internal/version:
config: config:
dir: internal/mocks/version dir: internal/mocks/version
pkgname: mocks_version pkgname: mocks_version
interfaces: interfaces:
VersionFetcher: {} VersionFetcher: {}
github.com/duxlabs-dev/dux-go/wayland/client: github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client:
config: config:
dir: internal/mocks/wlclient dir: internal/mocks/wlclient
pkgname: mocks_wlclient pkgname: mocks_wlclient
interfaces: interfaces:
WaylandDisplay: {} WaylandDisplay: {}
github.com/duxlabs-dev/dux-shell/core/internal/plugins: github.com/AvengeMedia/DankMaterialShell/core/internal/plugins:
interfaces: interfaces:
GitClient: {} GitClient: {}
github.com/duxlabs-dev/dux-shell/core/internal/server/brightness: github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness:
config: config:
dir: internal/mocks/brightness dir: internal/mocks/brightness
pkgname: mocks_brightness pkgname: mocks_brightness
+4 -17
View File
@@ -1,26 +1,13 @@
repos: repos:
- repo: local - repo: https://github.com/golangci/golangci-lint
rev: v2.12.2
hooks: hooks:
- id: golangci-lint-fmt - id: golangci-lint-fmt
name: golangci-lint-fmt
entry: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] fmt
language: system
types: [go]
require_serial: true require_serial: true
pass_filenames: false
- id: golangci-lint-full - id: golangci-lint-full
name: golangci-lint-full
entry: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --fix
language: system
types: [go]
require_serial: true
pass_filenames: false
- id: golangci-lint-config-verify - id: golangci-lint-config-verify
name: golangci-lint-config-verify - repo: local
entry: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] config verify hooks:
language: system
files: '\.golangci\.(?:yml|yaml|toml|json)'
pass_filenames: false
- id: go-test - id: go-test
name: go test name: go test
entry: go test ./... entry: go test ./...
+27 -45
View File
@@ -3,8 +3,6 @@ BINARY_NAME_INSTALL=dankinstall
SOURCE_DIR=cmd/dms SOURCE_DIR=cmd/dms
SOURCE_DIR_INSTALL=cmd/dankinstall SOURCE_DIR_INSTALL=cmd/dankinstall
BUILD_DIR=bin BUILD_DIR=bin
SHELL_SRC=../quickshell
EMBED_DIR=internal/shellembed/dist
PREFIX ?= /usr/local PREFIX ?= /usr/local
INSTALL_DIR=$(PREFIX)/bin INSTALL_DIR=$(PREFIX)/bin
@@ -23,39 +21,17 @@ BUILD_LDFLAGS=-ldflags='-s -w -X main.Version=$(VERSION) -X main.buildTime=$(BUI
# Architecture to build for dist target (amd64, arm64, or all) # Architecture to build for dist target (amd64, arm64, or all)
ARCH ?= all ARCH ?= all
# Target OSes for dist builds
DIST_OSES ?= linux freebsd
ifeq ($(ARCH),all) .PHONY: all build dankinstall dist clean install install-all install-dankinstall uninstall uninstall-all uninstall-dankinstall install-config uninstall-config test fmt vet deps print-version help
DIST_ARCHS = amd64 arm64
else
DIST_ARCHS = $(ARCH)
endif
.PHONY: all build sync-shell dankinstall dist clean install install-all install-dankinstall uninstall uninstall-all uninstall-dankinstall install-config uninstall-config test fmt vet deps print-version help
# Default target # Default target
all: build all: build
sync-shell:
@test -e $(SHELL_SRC)/DCommon/Widgets/DIcon.qml || { echo "DCommon 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 -
@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
@rm -f $(EMBED_DIR)/scripts/spam-notifications.sh $(EMBED_DIR)/scripts/verify-notifications.sh
@rm -f $(EMBED_DIR)/translations/*.py $(EMBED_DIR)/translations/WORKFLOW.md
@cd $(EMBED_DIR) && find . -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum | sha256sum | cut -c1-16 > .dankrev
# Build the main binary (dms) # Build the main binary (dms)
build: sync-shell build:
@echo "Building $(BINARY_NAME)..." @echo "Building $(BINARY_NAME)..."
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 $(GO) build -tags withshell $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./$(SOURCE_DIR) CGO_ENABLED=0 $(GO) build $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./$(SOURCE_DIR)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)" @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)"
dankinstall: dankinstall:
@@ -64,18 +40,26 @@ dankinstall:
CGO_ENABLED=0 $(GO) build $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME_INSTALL) ./$(SOURCE_DIR_INSTALL) CGO_ENABLED=0 $(GO) build $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME_INSTALL) ./$(SOURCE_DIR_INSTALL)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME_INSTALL)" @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME_INSTALL)"
# Build distro binaries (no update/greeter support) for each DIST_OSES/DIST_ARCHS pair # Build distro binaries for amd64 and arm64 (Linux only, no update/greeter support)
dist: sync-shell dist:
@echo "Building $(BINARY_NAME) for distribution ($(DIST_OSES) x $(DIST_ARCHS))..." ifeq ($(ARCH),all)
@echo "Building $(BINARY_NAME) for distribution (amd64 and arm64)..."
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
@for os in $(DIST_OSES); do \ @echo "Building for linux/amd64..."
for arch in $(DIST_ARCHS); do \ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -tags distro_binary $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(SOURCE_DIR)
echo "Building for $$os/$$arch..."; \ @echo "Building for linux/arm64..."
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch $(GO) build -tags 'distro_binary withshell' $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-$$os-$$arch ./$(SOURCE_DIR) || exit 1; \ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build -tags distro_binary $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(SOURCE_DIR)
echo " $(BUILD_DIR)/$(BINARY_NAME)-$$os-$$arch"; \ @echo "Distribution builds complete:"
done; \ @echo " $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64"
done @echo " $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
@echo "Distribution builds complete" else
@echo "Building $(BINARY_NAME) for distribution ($(ARCH))..."
@mkdir -p $(BUILD_DIR)
@echo "Building for linux/$(ARCH)..."
CGO_ENABLED=0 GOOS=linux GOARCH=$(ARCH) $(GO) build -tags distro_binary $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-$(ARCH) ./$(SOURCE_DIR)
@echo "Distribution build complete:"
@echo " $(BUILD_DIR)/$(BINARY_NAME)-linux-$(ARCH)"
endif
build-all: build dankinstall build-all: build dankinstall
@@ -115,7 +99,7 @@ uninstall-dankinstall:
clean: clean:
@echo "Cleaning build artifacts..." @echo "Cleaning build artifacts..."
@rm -rf $(BUILD_DIR) $(EMBED_DIR) @rm -rf $(BUILD_DIR)
@echo "Clean complete" @echo "Clean complete"
test: test:
@@ -128,8 +112,7 @@ fmt:
vet: vet:
@echo "Running go vet..." @echo "Running go vet..."
$(GO) vet $$($(GO) list ./... | grep -v internal/highlight/lexers) $(GO) vet ./...
$(GO) vet -composites=false ./internal/highlight/lexers
deps: deps:
@echo "Updating dependencies..." @echo "Updating dependencies..."
@@ -158,11 +141,10 @@ print-version:
help: help:
@echo "Available targets:" @echo "Available targets:"
@echo " all - Build the main binary (dms) (default)" @echo " all - Build the main binary (dms) (default)"
@echo " build - Build the main binary (dms) with the embedded UI" @echo " build - Build the main binary (dms)"
@echo " sync-shell - Copy quickshell/ into the embed dir (runs before tagged builds)"
@echo " dankinstall - Build dankinstall binary" @echo " dankinstall - Build dankinstall binary"
@echo " dist - Build dms for linux/freebsd amd64/arm64 (no update/greeter)" @echo " dist - Build dms for linux amd64/arm64 (no update/greeter)"
@echo " Use ARCH=amd64 or ARCH=arm64 and/or DIST_OSES=linux to narrow" @echo " Use ARCH=amd64 or ARCH=arm64 to build only one"
@echo " build-all - Build both binaries" @echo " build-all - Build both binaries"
@echo " install - Install dms to $(INSTALL_DIR)" @echo " install - Install dms to $(INSTALL_DIR)"
@echo " install-all - Install both dms and dankinstall to $(INSTALL_DIR)" @echo " install-all - Install both dms and dankinstall to $(INSTALL_DIR)"
+18 -31
View File
@@ -1,6 +1,6 @@
# DMS Backend & CLI # DMS Backend & CLI
Go-based backend for Dux Shell providing system integration, IPC, and installation tools. Go-based backend for DankMaterialShell providing system integration, IPC, and installation tools.
**See [root README](../README.md) for project overview and installation.** **See [root README](../README.md) for project overview and installation.**
@@ -10,7 +10,7 @@ Go-based backend for Dux Shell providing system integration, IPC, and installati
Command-line interface and daemon for shell management and system control. Command-line interface and daemon for shell management and system control.
**dankinstall** **dankinstall**
Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo, and Void. Supports both an interactive TUI and a headless (unattended) mode via CLI flags. Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo. Supports both an interactive TUI and a headless (unattended) mode via CLI flags.
## System Integration ## System Integration
@@ -74,11 +74,11 @@ Custom IPC via unix socket (JSON API) for shell communication.
- `dms run [-d]` - Start shell (optionally as daemon) - `dms run [-d]` - Start shell (optionally as daemon)
- `dms restart` / `dms kill` - Manage running processes - `dms restart` / `dms kill` - Manage running processes
- `dms ipc <command>` - Send IPC commands (toggle launcher, notifications, etc.) - `dms ipc <command>` - Send IPC commands (toggle launcher, notifications, etc.)
- `dms plugins [install|browse|search|lock|restore]` - Plugin management and portable exact-revision lockfiles - `dms plugins [install|browse|search]` - Plugin management
- `dms brightness [list|set]` - Control display/monitor brightness - `dms brightness [list|set]` - Control display/monitor brightness
- `dms color pick` - Native color picker (see below) - `dms color pick` - Native color picker (see below)
- `dms update` - Update DMS and dependencies (disabled in distro packages) - `dms update` - Update DMS and dependencies (disabled in distro packages)
- `dms greeter` - Deprecated; forwards to the standalone [dms-greeter](https://github.com/duxlabs-dev/dux-greeter) binary - `dms greeter install` - Install greetd greeter (disabled in distro packages)
### Color Picker ### Color Picker
@@ -109,7 +109,7 @@ make test # Run tests
**Distribution build:** **Distribution build:**
```bash ```bash
make dist # Build without update features make dist # Build without update/greeter features
``` ```
Produces `bin/dms-linux-amd64` and `bin/dms-linux-arm64` Produces `bin/dms-linux-amd64` and `bin/dms-linux-arm64`
@@ -132,11 +132,9 @@ This runs gofmt, golangci-lint, tests, and builds before each commit when `core/
**Regenerating Wayland Protocol Bindings:** **Regenerating Wayland Protocol Bindings:**
Protocol XML files live in `internal/proto/xml/`. Bindings are generated with the [dankgo](https://github.com/duxlabs-dev/dux-go) `go-wayland-scanner`:
```bash ```bash
go run github.com/duxlabs-dev/dux-go/cmd/go-wayland-scanner@latest \ go install github.com/rajveermalviya/go-wayland/cmd/go-wayland-scanner@latest
-i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \ go-wayland-scanner -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
-pkg wlr_gamma_control -o internal/proto/wlr_gamma_control/gamma_control.go -pkg wlr_gamma_control -o internal/proto/wlr_gamma_control/gamma_control.go
``` ```
@@ -152,39 +150,31 @@ go run github.com/duxlabs-dev/dux-go/cmd/go-wayland-scanner@latest \
**Interactive (TUI):** **Interactive (TUI):**
```bash ```bash
curl -fsSL https://install.duxlabs.dev | sh curl -fsSL https://install.danklinux.com | sh
``` ```
**Headless (unattended):** **Headless (unattended):**
Headless mode requires cached credentials or a passwordless rule for your privilege escalation tool (sudo, doas, or run0). With sudo, run `sudo -v` first: Headless mode requires cached sudo credentials. Run `sudo -v` first:
```bash ```bash
sudo -v && curl -fsSL https://install.duxlabs.dev | sh -s -- -c niri -t ghostty -y sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c niri -t ghostty -y
sudo -v && curl -fsSL https://install.duxlabs.dev | sh -s -- -c hyprland -t kitty --dms-greeter -y sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c hyprland -t kitty --include-deps dms-greeter -y
sudo -v && curl -fsSL https://install.duxlabs.dev | sh -s -- -c niri -t ghostty --git-deps niri,quickshell --all-features -y
``` ```
| Flag | Short | Description | | Flag | Short | Description |
|------|-------|-------------| |------|-------|-------------|
| `--compositor <niri\|hyprland\|mango>` | `-c` | Compositor/WM to install (required for headless) | | `--compositor <niri|hyprland>` | `-c` | Compositor/WM to install (required for headless) |
| `--term <ghostty\|kitty\|alacritty>` | `-t` | Terminal emulator (required for headless) | | `--term <ghostty|kitty|alacritty>` | `-t` | Terminal emulator (required for headless) |
| `--privesc <sudo\|doas\|run0>` | | Privilege escalation tool (default: autodetect) |
| `--git` | | Install the git version of every dependency that has one |
| `--git-deps <name,...>` | | Install the git version of specific dependencies (e.g. `niri,quickshell`) |
| `--all-features` | | Enable all optional dependencies (`dms-greeter`, `danksearch`, `dankcalendar`) |
| `--dms-greeter` | | Install dms-greeter |
| `--danksearch` | | Install danksearch and enable its user indexing service |
| `--dankcalendar` | | Install dankcalendar |
| `--include-deps <name,...>` | | Enable optional dependencies (e.g. `dms-greeter`) | | `--include-deps <name,...>` | | Enable optional dependencies (e.g. `dms-greeter`) |
| `--exclude-deps <name,...>` | | Skip specific dependencies | | `--exclude-deps <name,...>` | | Skip specific dependencies |
| `--replace-configs <name,...>` | | Replace specific configuration files (mutually exclusive with `--replace-configs-all`) | | `--replace-configs <name,...>` | | Replace specific configuration files (mutually exclusive with `--replace-configs-all`) |
| `--replace-configs-all` | | Replace all configuration files (mutually exclusive with `--replace-configs`) | | `--replace-configs-all` | | Replace all configuration files (mutually exclusive with `--replace-configs`) |
| `--yes` | `-y` | Required for headless mode - confirms installation without interactive prompts | | `--yes` | `-y` | Required for headless mode — confirms installation without interactive prompts |
Headless mode requires `--yes` to proceed; without it, the installer exits with an error. Headless mode requires `--yes` to proceed; without it, the installer exits with an error.
Configuration files are not replaced by default unless `--replace-configs` or `--replace-configs-all` is specified. Configuration files are not replaced by default unless `--replace-configs` or `--replace-configs-all` is specified.
Optional packages (`dms-greeter`, `danksearch`, `dankcalendar`) are disabled by default; enable them with their dedicated flags, `--include-deps`, or `--all-features`. `dms-greeter` is disabled by default; use `--include-deps dms-greeter` to enable it.
When no flags are provided, `dankinstall` launches the interactive TUI. When no flags are provided, `dankinstall` launches the interactive TUI.
@@ -193,7 +183,7 @@ When no flags are provided, `dankinstall` launches the interactive TUI.
Headless mode activates when `--compositor` or `--term` is provided. Headless mode activates when `--compositor` or `--term` is provided.
- Both `--compositor` and `--term` are required; providing only one results in an error. - Both `--compositor` and `--term` are required; providing only one results in an error.
- Headless-only flags (`--privesc`, `--git`, `--git-deps`, `--all-features`, `--include-deps`, `--exclude-deps`, `--replace-configs`, `--replace-configs-all`, `--yes`, `--danksearch`, `--dankcalendar`, `--dms-greeter`) are rejected in TUI mode. - Headless-only flags (`--include-deps`, `--exclude-deps`, `--replace-configs`, `--replace-configs-all`, `--yes`) are rejected in TUI mode.
- Positional arguments are not accepted. - Positional arguments are not accepted.
### Log file location ### Log file location
@@ -203,7 +193,7 @@ Set the `DANKINSTALL_LOG_DIR` environment variable to override the log directory
## Supported Distributions ## Supported Distributions
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo, Void (and derivatives) Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives)
**Arch Linux** **Arch Linux**
Uses `pacman` for system packages, builds AUR packages via `makepkg`, no AUR helper dependency. Uses `pacman` for system packages, builds AUR packages via `makepkg`, no AUR helper dependency.
@@ -216,7 +206,7 @@ Uses COPR repositories (`avengemedia/danklinux`, `avengemedia/dms`).
Requires PPA support. Most packages built from source (slow first install). Requires PPA support. Most packages built from source (slow first install).
**Debian** **Debian**
Debian 13+ (Trixie), testing, and sid. Stable quickshell from Debian (trixie-backports on 13). Other companions via OBS. Debian 13+ (Trixie). niri only, no Hyprland support. Builds from source.
**openSUSE** **openSUSE**
Most packages available in standard repos. Minimal building required. Most packages available in standard repos. Minimal building required.
@@ -224,7 +214,4 @@ Most packages available in standard repos. Minimal building required.
**Gentoo** **Gentoo**
Uses Portage with GURU overlay. Automatically configures USE flags. Variable success depending on system configuration. Uses Portage with GURU overlay. Automatically configures USE flags. Variable success depending on system configuration.
**Void Linux**
Uses XBPS with the DMS and Dux Labs self-hosted repositories.
See installer output for distribution-specific details during installation. See installer output for distribution-specific details during installation.
+23 -38
View File
@@ -5,42 +5,36 @@ import (
"os" "os"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/headless"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/duxlabs-dev/dux-shell/core/internal/headless"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/tui"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var Version = "dev" var Version = "dev"
// Flag variables bound via pflag
var ( var (
compositor string compositor string
term string term string
privescTool string
gitAll bool
gitDeps []string
allFeatures bool
includeDeps []string includeDeps []string
excludeDeps []string excludeDeps []string
replaceConfigs []string replaceConfigs []string
replaceConfigsAll bool replaceConfigsAll bool
yes bool yes bool
danksearch bool
dankcalendar bool
dmsGreeter bool
) )
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "dankinstall", Use: "dankinstall",
Short: "Install Dux Shell and its dependencies", Short: "Install DankMaterialShell and its dependencies",
Long: `dankinstall sets up Dux Shell with your chosen compositor and terminal. Long: `dankinstall sets up DankMaterialShell with your chosen compositor and terminal.
Without flags, it launches an interactive TUI. Providing either --compositor Without flags, it launches an interactive TUI. Providing either --compositor
or --term activates headless (unattended) mode, which requires both flags. or --term activates headless (unattended) mode, which requires both flags.
Headless mode requires cached credentials or a passwordless rule for your Headless mode requires cached sudo credentials. Run 'sudo -v' beforehand, or
privilege escalation tool (sudo, doas, or run0).`, configure passwordless sudo for your user.`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: runDankinstall, RunE: runDankinstall,
SilenceErrors: true, SilenceErrors: true,
@@ -50,18 +44,11 @@ privilege escalation tool (sudo, doas, or run0).`,
func init() { func init() {
rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri, hyprland, or mango (enables headless mode)") rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri, hyprland, or mango (enables headless mode)")
rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)") rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)")
rootCmd.Flags().StringVar(&privescTool, "privesc", "", "Privilege escalation tool: sudo, doas, or run0 (default: autodetect)")
rootCmd.Flags().BoolVar(&gitAll, "git", false, "Install the git version of every dep that has one")
rootCmd.Flags().StringSliceVar(&gitDeps, "git-deps", []string{}, "Deps to install the git version of (e.g. niri,quickshell)")
rootCmd.Flags().BoolVar(&allFeatures, "all-features", false, "Enable all optional deps (dms-greeter, danksearch, dankcalendar)")
rootCmd.Flags().StringSliceVar(&includeDeps, "include-deps", []string{}, "Optional deps to enable (e.g. dms-greeter)") rootCmd.Flags().StringSliceVar(&includeDeps, "include-deps", []string{}, "Optional deps to enable (e.g. dms-greeter)")
rootCmd.Flags().StringSliceVar(&excludeDeps, "exclude-deps", []string{}, "Deps to skip during installation") rootCmd.Flags().StringSliceVar(&excludeDeps, "exclude-deps", []string{}, "Deps to skip during installation")
rootCmd.Flags().StringSliceVar(&replaceConfigs, "replace-configs", []string{}, "Deploy only named configs (e.g. niri,ghostty)") rootCmd.Flags().StringSliceVar(&replaceConfigs, "replace-configs", []string{}, "Deploy only named configs (e.g. niri,ghostty)")
rootCmd.Flags().BoolVar(&replaceConfigsAll, "replace-configs-all", false, "Deploy and replace all configurations") rootCmd.Flags().BoolVar(&replaceConfigsAll, "replace-configs-all", false, "Deploy and replace all configurations")
rootCmd.Flags().BoolVarP(&yes, "yes", "y", false, "Auto-confirm all prompts") rootCmd.Flags().BoolVarP(&yes, "yes", "y", false, "Auto-confirm all prompts")
rootCmd.Flags().BoolVar(&danksearch, "danksearch", false, "Install danksearch and enable its user indexing service")
rootCmd.Flags().BoolVar(&dankcalendar, "dankcalendar", false, "Install dankcalendar")
rootCmd.Flags().BoolVar(&dmsGreeter, "dms-greeter", false, "Install dms-greeter")
} }
func main() { func main() {
@@ -80,19 +67,13 @@ func runDankinstall(cmd *cobra.Command, args []string) error {
headlessMode := compositor != "" || term != "" headlessMode := compositor != "" || term != ""
if !headlessMode { if !headlessMode {
// Reject headless-only flags when running in TUI mode.
headlessOnly := []string{ headlessOnly := []string{
"privesc",
"git",
"git-deps",
"all-features",
"include-deps", "include-deps",
"exclude-deps", "exclude-deps",
"replace-configs", "replace-configs",
"replace-configs-all", "replace-configs-all",
"yes", "yes",
"danksearch",
"dankcalendar",
"dms-greeter",
} }
var set []string var set []string
for _, name := range headlessOnly { for _, name := range headlessOnly {
@@ -112,6 +93,7 @@ func runDankinstall(cmd *cobra.Command, args []string) error {
} }
func runHeadless() error { func runHeadless() error {
// Validate required flags
if compositor == "" { if compositor == "" {
return fmt.Errorf("--compositor is required for headless mode (niri, hyprland, or mango)") return fmt.Errorf("--compositor is required for headless mode (niri, hyprland, or mango)")
} }
@@ -122,22 +104,16 @@ func runHeadless() error {
cfg := headless.Config{ cfg := headless.Config{
Compositor: compositor, Compositor: compositor,
Terminal: term, Terminal: term,
PrivescTool: privescTool,
GitAll: gitAll,
GitDeps: gitDeps,
AllFeatures: allFeatures,
IncludeDeps: includeDeps, IncludeDeps: includeDeps,
ExcludeDeps: excludeDeps, ExcludeDeps: excludeDeps,
ReplaceConfigs: replaceConfigs, ReplaceConfigs: replaceConfigs,
ReplaceConfigsAll: replaceConfigsAll, ReplaceConfigsAll: replaceConfigsAll,
Yes: yes, Yes: yes,
DankSearch: danksearch,
DankCalendar: dankcalendar,
DmsGreeter: dmsGreeter,
} }
runner := headless.NewRunner(cfg) runner := headless.NewRunner(cfg)
// Set up file logging
fileLogger, err := log.NewFileLogger() fileLogger, err := log.NewFileLogger()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to create log file: %v\n", err) fmt.Fprintf(os.Stderr, "Warning: Failed to create log file: %v\n", err)
@@ -152,6 +128,10 @@ func runHeadless() error {
} }
}() }()
} else { } else {
// Drain the log channel to prevent blocking sends from deadlocking
// downstream components (distros, config deployer) that write to it.
// Use an explicit stop signal because this code does not own the
// runner log channel and cannot assume it will be closed.
defer drainLogChan(runner.GetLogChan())() defer drainLogChan(runner.GetLogChan())()
} }
@@ -191,6 +171,10 @@ func runTUI() error {
if fileLogger != nil { if fileLogger != nil {
fileLogger.StartListening(model.GetLogChan()) fileLogger.StartListening(model.GetLogChan())
} else { } else {
// Drain the log channel to prevent blocking sends from deadlocking
// downstream components (distros, config deployer) that write to it.
// Use an explicit stop signal because this code does not own the
// model log channel and cannot assume it will be closed.
defer drainLogChan(model.GetLogChan())() defer drainLogChan(model.GetLogChan())()
} }
@@ -208,9 +192,10 @@ func runTUI() error {
return nil return nil
} }
// drainLogChan discards messages from logCh so blocking sends in downstream // drainLogChan starts a goroutine that discards all messages from logCh,
// components cannot deadlock when no file logger is attached; the returned // preventing blocking sends from deadlocking downstream components. It returns
// cleanup stops the drain without assuming the channel will ever close. // a cleanup function that signals the goroutine to stop and waits for it to
// exit. Callers should defer the returned function.
func drainLogChan(logCh <-chan string) func() { func drainLogChan(logCh <-chan string) func() {
drainStop := make(chan struct{}) drainStop := make(chan struct{})
drainDone := make(chan struct{}) drainDone := make(chan struct{})
+3 -168
View File
@@ -1,22 +1,18 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"os"
"os/exec"
"path/filepath"
"strings" "strings"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
sharedpam "github.com/duxlabs-dev/dux-shell/core/internal/pam" sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var authCmd = &cobra.Command{ var authCmd = &cobra.Command{
Use: "auth", Use: "auth",
Short: "Manage DMS authentication sync", Short: "Manage DMS authentication sync",
Long: "Manage PAM/authentication setup for the DMS lock screen", Long: "Manage shared PAM/authentication setup for DMS greeter and lock screen",
} }
var authSyncCmd = &cobra.Command{ var authSyncCmd = &cobra.Command{
@@ -39,142 +35,9 @@ var authSyncCmd = &cobra.Command{
}, },
} }
var authResolveLockCmd = &cobra.Command{
Use: "resolve-lock",
Short: "Generate the lock-screen PAM config from the system auth stack",
Long: "Resolve the distribution's PAM auth stack into a self-contained lock-screen config under the user state directory.\n" +
"Runs unprivileged (reads /etc/pam.d, writes to the user's state dir) and is used by the shell as a fallback when /etc/pam.d/dankshell is not managed.\n" +
"Prints the path of the generated file.",
Run: func(cmd *cobra.Command, args []string) {
quiet, _ := cmd.Flags().GetBool("quiet")
logFunc := func(msg string) {
if !quiet {
fmt.Println(msg)
}
}
path, err := sharedpam.WriteUserLockscreenPamConfig(logFunc)
if err != nil {
log.Fatalf("Error resolving lock-screen PAM config: %v", err)
}
fmt.Println(path)
},
}
var authListServicesCmd = &cobra.Command{
Use: "list-services",
Short: "List candidate lock-screen PAM services available on this system",
Long: "Enumerate the lock-screen PAM services that exist on this system and report their resolved auth stack (whether it has an auth directive and whether fingerprint/U2F modules appear inline).",
Run: func(cmd *cobra.Command, args []string) {
asJSON, _ := cmd.Flags().GetBool("json")
services := sharedpam.ListLockscreenPamServices()
if asJSON {
payload := struct {
Services []sharedpam.LockscreenPamServiceInfo `json:"services"`
}{Services: services}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
log.Fatalf("Error encoding services: %v", err)
}
fmt.Println(string(data))
return
}
if len(services) == 0 {
fmt.Println("No candidate lock-screen PAM services found.")
return
}
for _, s := range services {
fmt.Printf("%-20s %-30s auth=%-5t fingerprint=%-5t u2f=%t\n", s.Name, s.Path, s.HasAuth, s.InlineFingerprint, s.InlineU2f)
}
},
}
var authValidateCmd = &cobra.Command{
Use: "validate",
Short: "Validate a PAM service file for use by the DMS lock screen",
Long: "Validate one PAM service (by --service NAME or --path /abs/file) for use as the DMS lock-screen password or dedicated U2F stack. Exits 1 when the file is not usable.",
Run: func(cmd *cobra.Command, args []string) {
path, _ := cmd.Flags().GetString("path")
service, _ := cmd.Flags().GetString("service")
purpose, _ := cmd.Flags().GetString("purpose")
asJSON, _ := cmd.Flags().GetBool("json")
if (path == "") == (service == "") {
log.Fatalf("Error: exactly one of --path or --service is required")
}
if purpose != "password" && purpose != "u2f" {
log.Fatalf("Error: --purpose must be password or u2f")
}
var result sharedpam.LockscreenPamValidation
switch {
case service != "":
if purpose == "u2f" {
result = sharedpam.ValidateLockscreenU2fPamService(service)
} else {
result = sharedpam.ValidateLockscreenPamService(service)
}
case !filepath.IsAbs(path):
result = sharedpam.LockscreenPamValidation{
Path: path,
MissingModules: []string{},
Warnings: []string{},
Errors: []string{"--path must be an absolute file path"},
}
default:
if purpose == "u2f" {
result = sharedpam.ValidateLockscreenU2fPamPath(path)
} else {
result = sharedpam.ValidateLockscreenPamPath(path)
}
}
if asJSON {
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
log.Fatalf("Error encoding validation: %v", err)
}
fmt.Println(string(data))
} else {
printLockscreenPamValidation(result)
}
if !result.Valid {
os.Exit(1)
}
},
}
func printLockscreenPamValidation(result sharedpam.LockscreenPamValidation) {
fmt.Printf("Path: %s\n", result.Path)
fmt.Printf("Valid: %t\n", result.Valid)
fmt.Printf("Has auth: %t\n", result.HasAuth)
fmt.Printf("Inline fingerprint: %t\n", result.InlineFingerprint)
fmt.Printf("Inline U2F: %t\n", result.InlineU2f)
if len(result.MissingModules) > 0 {
fmt.Printf("Missing modules: %s\n", strings.Join(result.MissingModules, ", "))
}
for _, w := range result.Warnings {
fmt.Println("⚠ " + w)
}
for _, e := range result.Errors {
fmt.Println("✗ " + e)
}
}
func init() { func init() {
authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts") authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts")
authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)") authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)")
authResolveLockCmd.Flags().BoolP("quiet", "q", false, "Only print the resulting file path")
authListServicesCmd.Flags().Bool("json", false, "Output as JSON")
authValidateCmd.Flags().String("path", "", "Absolute path to a PAM service file to validate")
authValidateCmd.Flags().String("service", "", "Name of a PAM service to resolve across the system PAM dirs")
authValidateCmd.Flags().String("purpose", "password", "Validation purpose: password or u2f")
authValidateCmd.Flags().Bool("json", false, "Output as JSON")
} }
func syncAuth(nonInteractive bool) error { func syncAuth(nonInteractive bool) error {
@@ -212,31 +75,3 @@ func syncAuthInTerminal(nonInteractive bool) error {
shellCmd := shellSyncCmd + `; echo; echo "Authentication sync finished. Closing in 3 seconds..."; sleep 3` shellCmd := shellSyncCmd + `; echo; echo "Authentication sync finished. Closing in 3 seconds..."; sleep 3`
return runCommandInTerminal(shellCmd) return runCommandInTerminal(shellCmd)
} }
func runCommandInTerminal(shellCmd string) error {
terminals := []struct {
name string
args []string
}{
{"gnome-terminal", []string{"--", "bash", "-c", shellCmd}},
{"konsole", []string{"-e", "bash", "-c", shellCmd}},
{"xfce4-terminal", []string{"-e", "bash -c \"" + strings.ReplaceAll(shellCmd, `"`, `\"`) + "\""}},
{"ghostty", []string{"-e", "bash", "-c", shellCmd}},
{"wezterm", []string{"start", "--", "bash", "-c", shellCmd}},
{"alacritty", []string{"-e", "bash", "-c", shellCmd}},
{"kitty", []string{"bash", "-c", shellCmd}},
{"xterm", []string{"-e", "bash -c \"" + strings.ReplaceAll(shellCmd, `"`, `\"`) + "\""}},
}
for _, t := range terminals {
if _, err := exec.LookPath(t.name); err != nil {
continue
}
cmd := exec.Command(t.name, t.args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return err
}
return nil
}
return fmt.Errorf("no terminal emulator found (tried: gnome-terminal, konsole, xfce4-terminal, ghostty, wezterm, alacritty, kitty, xterm)")
}
-62
View File
@@ -1,62 +0,0 @@
package main
import (
"fmt"
"os"
"github.com/duxlabs-dev/dux-shell/core/internal/backup"
"github.com/spf13/cobra"
)
var backupOutputPath string
var backupCmd = &cobra.Command{
Use: "backup",
Short: "Backup and restore DMS configuration",
}
var backupCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a tar.gz backup of ~/.config/dms (settings, plugins, themes)",
Args: cobra.NoArgs,
Run: runBackupCreate,
}
var backupRestoreCmd = &cobra.Command{
Use: "restore <archive>",
Short: "Restore a DMS configuration backup; the current configuration is moved aside first",
Args: cobra.ExactArgs(1),
Run: runBackupRestore,
}
func init() {
backupCreateCmd.Flags().StringVarP(&backupOutputPath, "output", "o", "", "Output archive path (default dms-backup-<timestamp>.tar.gz)")
backupCmd.AddCommand(backupCreateCmd, backupRestoreCmd)
rootCmd.AddCommand(backupCmd)
}
func runBackupCreate(cmd *cobra.Command, args []string) {
output := backupOutputPath
if output == "" {
output = backup.DefaultArchiveName()
}
if err := backup.Create(output); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println(output)
}
func runBackupRestore(cmd *cobra.Command, args []string) {
previous, err := backup.Restore(args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if previous != "" {
fmt.Printf("Previous configuration moved to %s\n", previous)
}
fmt.Println("Restore complete. Restart the shell to apply.")
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"github.com/duxlabs-dev/dux-shell/core/internal/blur" "github.com/AvengeMedia/DankMaterialShell/core/internal/blur"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/server/brightness" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
+10 -8
View File
@@ -10,11 +10,11 @@ import (
"github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/duxlabs-dev/dux-shell/core/internal/highlight" "github.com/alecthomas/chroma/v2/lexers"
"github.com/duxlabs-dev/dux-shell/core/internal/highlight/lexers" "github.com/alecthomas/chroma/v2/styles"
"github.com/duxlabs-dev/dux-shell/core/internal/highlight/styles"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/parser"
ghtml "github.com/yuin/goldmark/renderer/html" ghtml "github.com/yuin/goldmark/renderer/html"
@@ -116,7 +116,7 @@ func getCachedStyle(name string) *chroma.Style {
style := styles.Get(name) style := styles.Get(name)
if style == nil { if style == nil {
fmt.Fprintf(os.Stderr, "Warning: Style '%s' not found, using fallback\n", name) fmt.Fprintf(os.Stderr, "Warning: Style '%s' not found, using fallback\n", name)
style = styles.Fallback() style = styles.Fallback
} }
cacheMutex.Lock() cacheMutex.Lock()
@@ -209,10 +209,12 @@ func runChroma(cmd *cobra.Command, args []string) {
md := goldmark.New( md := goldmark.New(
goldmark.WithExtensions( goldmark.WithExtensions(
extension.GFM, extension.GFM,
highlight.Markdown{ highlighting.NewHighlighting(
Style: styles.Get(chromaStyle), highlighting.WithStyle(chromaStyle),
Formatter: html.New(html.WithClasses(!chromaInline)), highlighting.WithFormatOptions(
}, html.WithClasses(!chromaInline),
),
),
), ),
goldmark.WithParserOptions( goldmark.WithParserOptions(
parser.WithAutoHeadingID(), parser.WithAutoHeadingID(),
+28 -35
View File
@@ -28,10 +28,9 @@ import (
_ "golang.org/x/image/tiff" _ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp" _ "golang.org/x/image/webp"
"github.com/duxlabs-dev/dux-go/ipc" "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/duxlabs-dev/dux-go/wlclipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -284,8 +283,8 @@ func runClipCopy(cmd *cobra.Command, args []string) {
} }
} }
func parseMultiOffers(data []byte) ([]wlclipboard.Offer, error) { func parseMultiOffers(data []byte) ([]clipboard.Offer, error) {
var offers []wlclipboard.Offer var offers []clipboard.Offer
pos := 0 pos := 0
for pos < len(data) { for pos < len(data) {
@@ -312,14 +311,14 @@ func parseMultiOffers(data []byte) ([]wlclipboard.Offer, error) {
offerData := data[pos : pos+dataLen] offerData := data[pos : pos+dataLen]
pos += dataLen pos += dataLen
offers = append(offers, wlclipboard.Offer{MimeType: mimeType, Data: offerData}) offers = append(offers, clipboard.Offer{MimeType: mimeType, Data: offerData})
} }
return offers, nil return offers, nil
} }
func runClipPaste(cmd *cobra.Command, args []string) { func runClipPaste(cmd *cobra.Command, args []string) {
data, _, err := wlclipboard.Paste() data, _, err := clipboard.Paste()
if err != nil { if err != nil {
log.Fatalf("paste: %v", err) log.Fatalf("paste: %v", err)
} }
@@ -345,13 +344,13 @@ func runClipWatch(cmd *cobra.Command, args []string) {
switch { switch {
case len(args) > 0: case len(args) > 0:
if err := wlclipboard.Watch(ctx, func(data []byte, mimeType string) { if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
runCommand(args, data) runCommand(args, data)
}); err != nil && err != context.Canceled { }); err != nil && err != context.Canceled {
log.Fatalf("Watch error: %v", err) log.Fatalf("Watch error: %v", err)
} }
case clipWatchStore: case clipWatchStore:
if err := wlclipboard.Watch(ctx, func(data []byte, mimeType string) { if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
if err := clipboard.Store(data, mimeType); err != nil { if err := clipboard.Store(data, mimeType); err != nil {
log.Errorf("store: %v", err) log.Errorf("store: %v", err)
} }
@@ -383,7 +382,7 @@ func runClipWatch(cmd *cobra.Command, args []string) {
log.Fatalf("Watch error: %v", err) log.Fatalf("Watch error: %v", err)
} }
case clipJSONOutput: case clipJSONOutput:
if err := wlclipboard.Watch(ctx, func(data []byte, mimeType string) { if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
out := map[string]any{ out := map[string]any{
"data": string(data), "data": string(data),
"mimeType": mimeType, "mimeType": mimeType,
@@ -396,7 +395,7 @@ func runClipWatch(cmd *cobra.Command, args []string) {
log.Fatalf("Watch error: %v", err) log.Fatalf("Watch error: %v", err)
} }
default: default:
if err := wlclipboard.Watch(ctx, func(data []byte, mimeType string) { if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
os.Stdout.Write(data) os.Stdout.Write(data)
os.Stdout.WriteString("\n") os.Stdout.WriteString("\n")
}); err != nil && err != context.Canceled { }); err != nil && err != context.Canceled {
@@ -430,7 +429,7 @@ func runCommand(args []string, stdin []byte) {
} }
func runClipHistory(cmd *cobra.Command, args []string) { func runClipHistory(cmd *cobra.Command, args []string) {
req := ipc.Request{ req := models.Request{
ID: 1, ID: 1,
Method: "clipboard.getHistory", Method: "clipboard.getHistory",
} }
@@ -501,7 +500,7 @@ func runClipGet(cmd *cobra.Command, args []string) {
} }
if clipGetCopy { if clipGetCopy {
req := ipc.Request{ req := models.Request{
ID: 1, ID: 1,
Method: "clipboard.copyEntry", Method: "clipboard.copyEntry",
Params: map[string]any{"id": id}, Params: map[string]any{"id": id},
@@ -519,7 +518,7 @@ func runClipGet(cmd *cobra.Command, args []string) {
return return
} }
req := ipc.Request{ req := models.Request{
ID: 1, ID: 1,
Method: "clipboard.getEntry", Method: "clipboard.getEntry",
Params: map[string]any{ Params: map[string]any{
@@ -565,7 +564,7 @@ func runClipDelete(cmd *cobra.Command, args []string) {
log.Fatalf("Invalid ID: %v", err) log.Fatalf("Invalid ID: %v", err)
} }
req := ipc.Request{ req := models.Request{
ID: 1, ID: 1,
Method: "clipboard.deleteEntry", Method: "clipboard.deleteEntry",
Params: map[string]any{ Params: map[string]any{
@@ -586,7 +585,7 @@ func runClipDelete(cmd *cobra.Command, args []string) {
} }
func runClipClear(cmd *cobra.Command, args []string) { func runClipClear(cmd *cobra.Command, args []string) {
req := ipc.Request{ req := models.Request{
ID: 1, ID: 1,
Method: "clipboard.clearHistory", Method: "clipboard.clearHistory",
} }
@@ -621,7 +620,7 @@ func runClipSearch(cmd *cobra.Command, args []string) {
params["isImage"] = false params["isImage"] = false
} }
req := ipc.Request{ req := models.Request{
ID: 1, ID: 1,
Method: "clipboard.search", Method: "clipboard.search",
Params: params, Params: params,
@@ -688,7 +687,7 @@ func runClipSearch(cmd *cobra.Command, args []string) {
} }
func runClipConfigGet(cmd *cobra.Command, args []string) { func runClipConfigGet(cmd *cobra.Command, args []string) {
req := ipc.Request{ req := models.Request{
ID: 1, ID: 1,
Method: "clipboard.getConfig", Method: "clipboard.getConfig",
} }
@@ -742,7 +741,7 @@ func runClipConfigSet(cmd *cobra.Command, args []string) {
return return
} }
req := ipc.Request{ req := models.Request{
ID: 1, ID: 1,
Method: "clipboard.setConfig", Method: "clipboard.setConfig",
Params: params, Params: params,
@@ -761,7 +760,7 @@ func runClipConfigSet(cmd *cobra.Command, args []string) {
} }
func runClipExport(cmd *cobra.Command, args []string) { func runClipExport(cmd *cobra.Command, args []string) {
req := ipc.Request{ req := models.Request{
ID: 1, ID: 1,
Method: "clipboard.getHistory", Method: "clipboard.getHistory",
} }
@@ -932,7 +931,7 @@ func downloadToTempFile(rawURL string) (string, error) {
var contentType string var contentType string
var lastErr error var lastErr error
for attempt := range 3 { for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 { if attempt > 0 {
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond) time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
} }
@@ -1004,25 +1003,19 @@ func downloadToTempFile(rawURL string) (string, error) {
return filePath, nil return filePath, nil
} }
// copyFileToClipboard copies through the server so the entry lands in
// history, and serves the same offers itself when the server is down or
// refuses (e.g. file over the history size limit).
func copyFileToClipboard(filePath string) error { func copyFileToClipboard(filePath string) error {
if abs, err := filepath.Abs(filePath); err == nil { req := models.Request{
filePath = abs
}
resp, ok := tryServerRequest(ipc.Request{
ID: 1, ID: 1,
Method: "clipboard.copyFile", Method: "clipboard.copyFile",
Params: map[string]any{"filePath": filePath}, Params: map[string]any{"filePath": filePath},
})
if ok && resp.Error == "" {
return nil
} }
data, err := os.ReadFile(filePath) resp, err := sendServerRequest(req)
if err != nil { if err != nil {
return fmt.Errorf("read file: %w", err) return fmt.Errorf("server request: %w", err)
} }
return clipboard.CopyMulti(clipboard.FileOffers(filePath, filePath, data), false, false) if resp.Error != "" {
return fmt.Errorf("server error: %s", resp.Error)
}
return nil
} }
+13 -23
View File
@@ -3,20 +3,17 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"runtime/debug"
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/duxlabs-dev/dux-shell/core/internal/colorpicker" "github.com/AvengeMedia/DankMaterialShell/core/internal/colorpicker"
"github.com/duxlabs-dev/dux-shell/core/internal/site"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var ( var (
colorOutputFmt string colorOutputFmt string
colorAutocopy bool colorAutocopy bool
colorNotify bool colorNotify bool
colorLowercase bool colorLowercase bool
colorAllowMulti bool
) )
var colorCmd = &cobra.Command{ var colorCmd = &cobra.Command{
@@ -27,7 +24,7 @@ var colorCmd = &cobra.Command{
This is the screen eyedropper CLI. To open the in-shell color modal, use: This is the screen eyedropper CLI. To open the in-shell color modal, use:
dms ipc call color-picker toggle dms ipc call color-picker toggle
See: ` + site.Docs + "/dms/keybinds-ipc", See: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc`,
} }
var colorPickCmd = &cobra.Command{ var colorPickCmd = &cobra.Command{
@@ -56,8 +53,7 @@ Examples:
dms color pick --rgb # Output as RGB dms color pick --rgb # Output as RGB
dms color pick --json # Output all formats as JSON dms color pick --json # Output all formats as JSON
dms color pick --hex -l # Output hex in lowercase dms color pick --hex -l # Output hex in lowercase
dms color pick -a # Auto-copy result to clipboard dms color pick -a # Auto-copy result to clipboard`,
dms color pick --allow-multiple # Skip the one-picker-at-a-time guard`,
Run: runColorPick, Run: runColorPick,
} }
@@ -72,7 +68,6 @@ func init() {
colorPickCmd.Flags().StringVarP(&colorOutputFmt, "output-format", "o", "", "Custom output format template") colorPickCmd.Flags().StringVarP(&colorOutputFmt, "output-format", "o", "", "Custom output format template")
colorPickCmd.Flags().BoolVarP(&colorAutocopy, "autocopy", "a", false, "Copy result to clipboard") colorPickCmd.Flags().BoolVarP(&colorAutocopy, "autocopy", "a", false, "Copy result to clipboard")
colorPickCmd.Flags().BoolVarP(&colorLowercase, "lowercase", "l", false, "Output hex in lowercase") colorPickCmd.Flags().BoolVarP(&colorLowercase, "lowercase", "l", false, "Output hex in lowercase")
colorPickCmd.Flags().BoolVar(&colorAllowMulti, "allow-multiple", false, "Open a picker even when another selection overlay is already open")
colorPickCmd.MarkFlagsMutuallyExclusive("hex", "rgb", "hsl", "hsv", "cmyk", "json") colorPickCmd.MarkFlagsMutuallyExclusive("hex", "rgb", "hsl", "hsv", "cmyk", "json")
@@ -94,18 +89,13 @@ func runColorPick(cmd *cobra.Command, args []string) {
} }
config := colorpicker.Config{ config := colorpicker.Config{
Format: format, Format: format,
CustomFormat: colorOutputFmt, CustomFormat: colorOutputFmt,
Lowercase: colorLowercase, Lowercase: colorLowercase,
Autocopy: colorAutocopy, Autocopy: colorAutocopy,
Notify: colorNotify, Notify: colorNotify,
AllowMultiple: colorAllowMulti,
} }
// One-shot process holding a few screen-sized buffers: skip GC cycles while it runs.
debug.SetGCPercent(-1)
debug.SetMemoryLimit(1 << 30)
picker := colorpicker.New(config) picker := colorpicker.New(config)
color, err := picker.Run() color, err := picker.Run()
if err != nil { if err != nil {
+95 -116
View File
@@ -6,12 +6,10 @@ import (
"regexp" "regexp"
"strings" "strings"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/duxlabs-dev/dux-shell/core/internal/plugins" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/server" "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
"github.com/duxlabs-dev/dux-shell/core/internal/server/sysupdate" "github.com/AvengeMedia/DankMaterialShell/core/internal/server"
"github.com/duxlabs-dev/dux-shell/core/internal/shellembed"
"github.com/duxlabs-dev/dux-shell/core/internal/site"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -21,6 +19,63 @@ var versionCmd = &cobra.Command{
Run: runVersion, Run: runVersion,
} }
var runCmd = &cobra.Command{
Use: "run",
Short: "Launch quickshell with DMS configuration",
Long: "Launch quickshell with DMS configuration (qs -c dms)",
PreRunE: findConfig,
Run: func(cmd *cobra.Command, args []string) {
daemon, _ := cmd.Flags().GetBool("daemon")
session, _ := cmd.Flags().GetBool("session")
if v, _ := cmd.Flags().GetString("log-level"); v != "" {
if err := os.Setenv("DMS_LOG_LEVEL", v); err != nil {
log.Fatalf("Failed to set DMS_LOG_LEVEL: %v", err)
}
}
if v, _ := cmd.Flags().GetString("log-file"); v != "" {
if err := os.Setenv("DMS_LOG_FILE", v); err != nil {
log.Fatalf("Failed to set DMS_LOG_FILE: %v", err)
}
}
log.ApplyEnvOverrides()
config.CleanupStrayHyprlandConfFile(log.Infof)
if daemon {
runShellDaemon(session)
} else {
runShellInteractive(session)
}
},
}
var restartCmd = &cobra.Command{
Use: "restart",
Short: "Restart quickshell with DMS configuration",
Long: "Kill existing DMS shell processes and restart quickshell with DMS configuration",
PreRunE: findConfig,
Run: func(cmd *cobra.Command, args []string) {
restartShell()
},
}
var restartDetachedCmd = &cobra.Command{
Use: "restart-detached <pid>",
Hidden: true,
Args: cobra.ExactArgs(1),
PreRunE: findConfig,
Run: func(cmd *cobra.Command, args []string) {
runDetachedRestart(args[0])
},
}
var killCmd = &cobra.Command{
Use: "kill",
Short: "Kill running DMS shell processes",
Long: "Kill all running quickshell processes with DMS configuration",
Run: func(cmd *cobra.Command, args []string) {
killShell()
},
}
var ipcCmd = &cobra.Command{ var ipcCmd = &cobra.Command{
Use: "ipc", Use: "ipc",
Short: "Send IPC commands to running DMS shell", Short: "Send IPC commands to running DMS shell",
@@ -29,7 +84,7 @@ var ipcCmd = &cobra.Command{
dms ipc call <target> <function> [args...] invoke a command dms ipc call <target> <function> [args...] invoke a command
dms ipc list list all targets and functions dms ipc list list all targets and functions
Full reference: ` + site.Docs + "/dms/keybinds-ipc", Full reference: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc`,
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return getShellIPCCompletions(args, toComplete), cobra.ShellCompDirectiveNoFileComp return getShellIPCCompletions(args, toComplete), cobra.ShellCompDirectiveNoFileComp
}, },
@@ -53,8 +108,6 @@ func init() {
}) })
pluginsUpdateCmd.Flags().BoolP("all", "a", false, "Update all installed plugins") pluginsUpdateCmd.Flags().BoolP("all", "a", false, "Update all installed plugins")
pluginsUpdateCmd.Flags().Bool("check", false, "Check for available updates without applying them") pluginsUpdateCmd.Flags().Bool("check", false, "Check for available updates without applying them")
pluginsLockCmd.Flags().StringP("output", "o", "", "Also write the lockfile to this path")
pluginsRestoreCmd.Flags().Bool("prune", false, "Remove managed plugins that are not in the lockfile")
} }
var debugSrvCmd = &cobra.Command{ var debugSrvCmd = &cobra.Command{
@@ -182,36 +235,6 @@ var pluginsUpdateCmd = &cobra.Command{
}, },
} }
var pluginsLockCmd = &cobra.Command{
Use: "lock",
Short: "Record installed plugins and their exact revisions",
Long: "Write a portable plugins.lock.json containing every managed user plugin and its current Git commit.",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
if err := lockPluginsCLI(output); err != nil {
log.Fatalf("Error writing plugin lockfile: %v", err)
}
},
}
var pluginsRestoreCmd = &cobra.Command{
Use: "restore [lockfile]",
Short: "Restore plugins from exact revisions in a lockfile",
Long: "Install or reset all plugins in a portable plugins.lock.json. Existing managed plugins are retained unless --prune is specified.",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
path := ""
if len(args) == 1 {
path = args[0]
}
prune, _ := cmd.Flags().GetBool("prune")
if err := restorePluginsCLI(path, prune); err != nil {
log.Fatalf("Error restoring plugins: %v", err)
}
},
}
func runVersion(cmd *cobra.Command, args []string) { func runVersion(cmd *cobra.Command, args []string) {
fmt.Printf("%s\n", formatVersion(Version)) fmt.Printf("%s\n", formatVersion(Version))
} }
@@ -219,17 +242,21 @@ func runVersion(cmd *cobra.Command, args []string) {
// Git builds: dms (git) v0.6.2-XXXX // Git builds: dms (git) v0.6.2-XXXX
// Stable releases: dms v0.6.2 // Stable releases: dms v0.6.2
func formatVersion(version string) string { func formatVersion(version string) string {
if n := sysupdate.GitBuildCount(version); n > 0 { // Arch/Debian/Ubuntu/OpenSUSE git format: 0.6.2+git2264.c5c5ce84
// Fedora COPR (0.0.git.2267.d430cae9) carries no real base version. re := regexp.MustCompile(`^([\d.]+)\+git(\d+)\.`)
base, _, ok := strings.Cut(version, "+git") if matches := re.FindStringSubmatch(version); matches != nil {
if !ok { return fmt.Sprintf("dms (git) v%s-%s", matches[1], matches[2])
base = getBaseVersion() }
}
return fmt.Sprintf("dms (git) v%s-%d", strings.TrimPrefix(base, "v"), n) // Fedora COPR git format: 0.0.git.2267.d430cae9
re = regexp.MustCompile(`^[\d.]+\.git\.(\d+)\.`)
if matches := re.FindStringSubmatch(version); matches != nil {
baseVersion := getBaseVersion()
return fmt.Sprintf("dms (git) v%s-%s", baseVersion, matches[1])
} }
// Stable release format: 0.6.2 // Stable release format: 0.6.2
re := regexp.MustCompile(`^([\d.]+)$`) re = regexp.MustCompile(`^([\d.]+)$`)
if matches := re.FindStringSubmatch(version); matches != nil { if matches := re.FindStringSubmatch(version); matches != nil {
return fmt.Sprintf("dms v%s", matches[1]) return fmt.Sprintf("dms v%s", matches[1])
} }
@@ -237,41 +264,29 @@ func formatVersion(version string) string {
return fmt.Sprintf("dms %s", version) return fmt.Sprintf("dms %s", version)
} }
var baseVersionRe = regexp.MustCompile(`^([\d.]+)`)
// Installed UI trees, for builds without an embedded UI.
var shellVersionPaths = []string{
"/usr/share/quickshell/dms/VERSION",
"/usr/local/share/quickshell/dms/VERSION",
"/etc/xdg/quickshell/dms/VERSION",
}
func getBaseVersion() string { func getBaseVersion() string {
if ver := parseBaseVersion(shellembed.Version()); ver != "" { paths := []string{
return ver "/usr/share/quickshell/dms/VERSION",
"/usr/local/share/quickshell/dms/VERSION",
"/etc/xdg/quickshell/dms/VERSION",
} }
for _, path := range shellVersionPaths { for _, path := range paths {
content, err := os.ReadFile(path) if content, err := os.ReadFile(path); err == nil {
if err != nil { ver := strings.TrimSpace(string(content))
continue ver = strings.TrimPrefix(ver, "v")
} if re := regexp.MustCompile(`^([\d.]+)`); re.MatchString(ver) {
if ver := parseBaseVersion(string(content)); ver != "" { if matches := re.FindStringSubmatch(ver); matches != nil {
return ver return matches[1]
}
}
} }
} }
// Fallback
return "1.0.2" return "1.0.2"
} }
func parseBaseVersion(raw string) string {
matches := baseVersionRe.FindStringSubmatch(strings.TrimPrefix(strings.TrimSpace(raw), "v"))
if matches == nil {
return ""
}
return matches[1]
}
func startDebugServer() error { func startDebugServer() error {
server.CLIVersion = Version server.CLIVersion = Version
return server.Start(true) return server.Start(true)
@@ -463,40 +478,6 @@ func installPluginCLI(idOrName string) error {
return nil return nil
} }
func lockPluginsCLI(outputPath string) error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
warnings, err := manager.WriteCurrentLockfile(outputPath)
if err != nil {
return err
}
for _, warning := range warnings {
fmt.Fprintf(os.Stderr, "Warning: %s\n", warning)
}
fmt.Printf("Plugin lockfile written: %s\n", manager.GetLockfilePath())
if outputPath != "" && outputPath != manager.GetLockfilePath() {
fmt.Printf("Plugin lockfile exported: %s\n", outputPath)
}
return nil
}
func restorePluginsCLI(path string, prune bool) error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
if err := manager.RestoreFromLockfile(path, prune); err != nil {
return err
}
if path == "" {
path = manager.GetLockfilePath()
}
fmt.Printf("Plugins restored from lockfile: %s\n", path)
return nil
}
func getAvailablePluginIDs() []string { func getAvailablePluginIDs() []string {
registry, err := plugins.NewRegistry() registry, err := plugins.NewRegistry()
if err != nil { if err != nil {
@@ -762,22 +743,22 @@ func checkAllPluginsCLI() error {
} }
func getCommonCommands() []*cobra.Command { func getCommonCommands() []*cobra.Command {
commands := shellApp.Commands() return []*cobra.Command{
return append(commands, []*cobra.Command{
versionCmd, versionCmd,
runCmd,
restartCmd,
restartDetachedCmd,
killCmd,
ipcCmd, ipcCmd,
debugSrvCmd, debugSrvCmd,
pluginsCmd, pluginsCmd,
registryCmd,
dank16Cmd, dank16Cmd,
brightnessCmd, brightnessCmd,
iccCmd,
dpmsCmd, dpmsCmd,
keybindsCmd, keybindsCmd,
greeterCmd, greeterCmd,
setupCmd, setupCmd,
colorCmd, colorCmd,
qrCmd,
screenshotCmd, screenshotCmd,
notifyActionCmd, notifyActionCmd,
notifyCmd, notifyCmd,
@@ -792,9 +773,7 @@ func getCommonCommands() []*cobra.Command {
blurCmd, blurCmd,
trashCmd, trashCmd,
systemCmd, systemCmd,
systemdCmd,
switchUserCmd, switchUserCmd,
switchCmd, trayWatcherCmd,
dmenuCmd, }
}...)
} }
+72 -73
View File
@@ -7,10 +7,9 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/luaconfig" "github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -36,15 +35,12 @@ var resolveIncludeCmd = &cobra.Command{
"layout.lua", "layout.lua",
"outputs.lua", "outputs.lua",
"cursor.lua", "cursor.lua",
"input.lua",
"windowrules.lua", "windowrules.lua",
"cursor.kdl", "cursor.kdl",
"layout.kdl", "layout.kdl",
"outputs.kdl", "outputs.kdl",
"binds.kdl", "binds.kdl",
"input.kdl",
"cursor.conf", "cursor.conf",
"input.conf",
"layout.conf", "layout.conf",
"outputs.conf", "outputs.conf",
"binds.conf", "binds.conf",
@@ -55,32 +51,8 @@ var resolveIncludeCmd = &cobra.Command{
Run: runResolveInclude, Run: runResolveInclude,
} }
var mangoMigrateMain bool
var mangoMigrateCmd = &cobra.Command{
Use: "mango-migrate",
Short: "Respell Mango config keys for the installed Mango",
Long: "Detects whether the installed Mango uses legacy or snake_case config keys, rewrites the DMS fragments in mango/dms to match, and prints the dialect on the last line. --main also rewrites config.conf after a backup.",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
dialect := mangoconf.Detect()
changed, err := dialect.Migrate(mangoconf.Dir(), mangoMigrateMain)
for _, path := range changed {
fmt.Fprintf(os.Stderr, "migrated %s\n", path)
}
// Printed before any error: the shell needs the dialect even when a fragment failed.
fmt.Println(dialect)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func init() { func init() {
configCmd.AddCommand(resolveIncludeCmd) configCmd.AddCommand(resolveIncludeCmd)
mangoMigrateCmd.Flags().BoolVar(&mangoMigrateMain, "main", false, "also rewrite config.conf (a backup is kept)")
configCmd.AddCommand(mangoMigrateCmd)
} }
type IncludeResult struct { type IncludeResult struct {
@@ -131,21 +103,32 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
return result, err return result, err
} }
targetRel := filepath.ToSlash(filepath.Join("dms", filename))
mainLua := filepath.Join(configDir, "hyprland.lua") mainLua := filepath.Join(configDir, "hyprland.lua")
if _, err := os.Stat(mainLua); err == nil { if _, err := os.Stat(mainLua); err == nil {
result.ConfigFormat = "lua" result.ConfigFormat = "lua"
result.Included = luaconfig.RequiresTarget(mainLua, targetAbs, make(map[string]bool)) result.ReadOnly = false
return result, nil processedLua := make(map[string]bool)
if luaconfig.RequiresTarget(mainLua, targetAbs, processedLua) {
result.Included = true
return result, nil
}
} }
// 0.55/0.56 installs still on hyprland.conf: read-only until migrated.
mainConf := filepath.Join(configDir, "hyprland.conf") mainConf := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(mainConf); err != nil { if _, err := os.Stat(mainConf); err == nil {
return result, nil if result.ConfigFormat == "" {
result.ConfigFormat = "hyprlang"
result.ReadOnly = true
}
processed := make(map[string]bool)
if hyprlandFindIncludeHyprlang(mainConf, targetRel, processed) {
result.Included = true
return result, nil
}
} }
result.ConfigFormat = "hyprlang"
result.ReadOnly = true
result.Included = hyprlandFindIncludeHyprlang(mainConf, targetAbs, make(map[string]bool))
return result, nil return result, nil
} }
@@ -166,8 +149,14 @@ func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]b
} }
baseDir := filepath.Dir(absPath) baseDir := filepath.Dir(absPath)
for line := range strings.SplitSeq(string(data), "\n") { lines := strings.Split(string(data), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
continue
}
if !strings.HasPrefix(trimmed, "source") { if !strings.HasPrefix(trimmed, "source") {
continue continue
} }
@@ -177,12 +166,22 @@ func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]b
continue continue
} }
fullPath, err := resolveSourcePath(baseDir, strings.TrimSpace(parts[1])) sourcePath := strings.TrimSpace(parts[1])
if matchesTarget(sourcePath, target) {
return true
}
fullPath := sourcePath
if !filepath.IsAbs(sourcePath) {
fullPath = filepath.Join(baseDir, sourcePath)
}
expanded, err := utils.ExpandPath(fullPath)
if err != nil { if err != nil {
continue continue
} }
if fullPath == target || hyprlandFindIncludeHyprlang(fullPath, target, processed) { if hyprlandFindIncludeHyprlang(expanded, target, processed) {
return true return true
} }
} }
@@ -205,13 +204,8 @@ func checkNiriInclude(filename string) (IncludeResult, error) {
return result, nil return result, nil
} }
targetAbs, err := filepath.Abs(targetPath)
if err != nil {
return result, err
}
processed := make(map[string]bool) processed := make(map[string]bool)
result.Included = niriFindInclude(mainConfig, targetAbs, processed) result.Included = niriFindInclude(mainConfig, "dms/"+filename, processed)
return result, nil return result, nil
} }
@@ -234,7 +228,7 @@ func niriFindInclude(filePath, target string, processed map[string]bool) bool {
baseDir := filepath.Dir(absPath) baseDir := filepath.Dir(absPath)
content := string(data) content := string(data)
for line := range strings.SplitSeq(content, "\n") { for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") || trimmed == "" { if strings.HasPrefix(trimmed, "//") || trimmed == "" {
continue continue
@@ -253,12 +247,17 @@ func niriFindInclude(filePath, target string, processed map[string]bool) bool {
continue continue
} }
fullPath, err := resolveSourcePath(baseDir, trimmed[startQuote+1:endQuote]) includePath := trimmed[startQuote+1 : endQuote]
if err != nil { if matchesTarget(includePath, target) {
continue return true
} }
if fullPath == target || niriFindInclude(fullPath, target, processed) { fullPath := includePath
if !filepath.IsAbs(includePath) {
fullPath = filepath.Join(baseDir, includePath)
}
if niriFindInclude(fullPath, target, processed) {
return true return true
} }
} }
@@ -267,7 +266,7 @@ func niriFindInclude(filePath, target string, processed map[string]bool) bool {
} }
func checkMangoWCInclude(filename string) (IncludeResult, error) { func checkMangoWCInclude(filename string) (IncludeResult, error) {
configDir := mangoconf.Dir() configDir := filepath.Join(utils.XDGConfigHome(), "mango")
targetPath := filepath.Join(configDir, "dms", filename) targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{} result := IncludeResult{}
@@ -284,13 +283,8 @@ func checkMangoWCInclude(filename string) (IncludeResult, error) {
return result, nil return result, nil
} }
targetAbs, err := filepath.Abs(targetPath)
if err != nil {
return result, err
}
processed := make(map[string]bool) processed := make(map[string]bool)
result.Included = mangowcFindInclude(mainConfig, targetAbs, processed) result.Included = mangowcFindInclude(mainConfig, "dms/"+filename, processed)
return result, nil return result, nil
} }
@@ -311,9 +305,9 @@ func mangowcFindInclude(filePath, target string, processed map[string]bool) bool
} }
baseDir := filepath.Dir(absPath) baseDir := filepath.Dir(absPath)
lines := strings.SplitSeq(string(data), "\n") lines := strings.Split(string(data), "\n")
for line := range lines { for _, line := range lines {
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") || trimmed == "" { if strings.HasPrefix(trimmed, "#") || trimmed == "" {
continue continue
@@ -328,12 +322,22 @@ func mangowcFindInclude(filePath, target string, processed map[string]bool) bool
continue continue
} }
fullPath, err := resolveSourcePath(baseDir, strings.TrimSpace(parts[1])) sourcePath := strings.TrimSpace(parts[1])
if matchesTarget(sourcePath, target) {
return true
}
fullPath := sourcePath
if !filepath.IsAbs(sourcePath) {
fullPath = filepath.Join(baseDir, sourcePath)
}
expanded, err := utils.ExpandPath(fullPath)
if err != nil { if err != nil {
continue continue
} }
if fullPath == target || mangowcFindInclude(fullPath, target, processed) { if mangowcFindInclude(expanded, target, processed) {
return true return true
} }
} }
@@ -341,13 +345,8 @@ func mangowcFindInclude(filePath, target string, processed map[string]bool) bool
return false return false
} }
func resolveSourcePath(baseDir, raw string) (string, error) { func matchesTarget(path, target string) bool {
expanded, err := utils.ExpandPath(raw) path = strings.TrimPrefix(path, "./")
if err != nil { target = strings.TrimPrefix(target, "./")
return "", err return path == target || strings.HasSuffix(path, "/"+target)
}
if !filepath.IsAbs(expanded) {
expanded = filepath.Join(baseDir, expanded)
}
return filepath.Abs(expanded)
} }
-74
View File
@@ -1,74 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func writeConfigFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestNiriFindIncludeNestedRelativeToIncludingFile(t *testing.T) {
dir := t.TempDir()
writeConfigFile(t, filepath.Join(dir, "config.kdl"), "include \"dms/dms.kdl\"\n")
writeConfigFile(t, filepath.Join(dir, "dms", "dms.kdl"), "include \"binds.kdl\"\ninclude \"./outputs.kdl\"\n")
for _, name := range []string{"binds.kdl", "outputs.kdl"} {
target := filepath.Join(dir, "dms", name)
if !niriFindInclude(filepath.Join(dir, "config.kdl"), target, map[string]bool{}) {
t.Errorf("%s included through dms/dms.kdl not detected", name)
}
}
if niriFindInclude(filepath.Join(dir, "config.kdl"), filepath.Join(dir, "dms", "layout.kdl"), map[string]bool{}) {
t.Error("layout.kdl reported included without an include line")
}
}
func TestMangowcFindIncludeNestedRelativeToIncludingFile(t *testing.T) {
dir := t.TempDir()
writeConfigFile(t, filepath.Join(dir, "config.conf"), "source=./dms/dms.conf\n")
writeConfigFile(t, filepath.Join(dir, "dms", "dms.conf"), "source = binds.conf\n")
if !mangowcFindInclude(filepath.Join(dir, "config.conf"), filepath.Join(dir, "dms", "binds.conf"), map[string]bool{}) {
t.Error("binds.conf sourced through dms/dms.conf not detected")
}
}
func TestCheckHyprlandIncludeFormat(t *testing.T) {
tests := []struct {
name string
files map[string]string
wantFormat string
wantReadOnly bool
wantIncluded bool
}{
{"hyprlang sourced", map[string]string{"hyprland.conf": "source = ./dms/colors.conf\n"}, "hyprlang", true, true},
{"hyprlang not sourced", map[string]string{"hyprland.conf": "bind = SUPER, Q, killactive\n"}, "hyprlang", true, false},
{"lua wins over leftover conf", map[string]string{"hyprland.conf": "source = ./dms/colors.conf\n", "hyprland.lua": "\n"}, "lua", false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
xdg := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", xdg)
for name, content := range tt.files {
writeConfigFile(t, filepath.Join(xdg, "hypr", name), content)
}
got, err := checkHyprlandInclude("colors.conf")
if err != nil {
t.Fatal(err)
}
if got.ConfigFormat != tt.wantFormat || got.ReadOnly != tt.wantReadOnly || got.Included != tt.wantIncluded {
t.Fatalf("got %+v, want format %q readOnly %v included %v", got, tt.wantFormat, tt.wantReadOnly, tt.wantIncluded)
}
})
}
}
+7 -14
View File
@@ -4,8 +4,8 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/duxlabs-dev/dux-shell/core/internal/dank16" "github.com/AvengeMedia/DankMaterialShell/core/internal/dank16"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -28,13 +28,9 @@ func init() {
dank16Cmd.Flags().Bool("wezterm", false, "Output in Wezterm terminal format") dank16Cmd.Flags().Bool("wezterm", false, "Output in Wezterm terminal format")
dank16Cmd.Flags().String("background", "", "Custom background color") dank16Cmd.Flags().String("background", "", "Custom background color")
dank16Cmd.Flags().String("contrast", "dps", "Contrast algorithm: dps (Delta Phi Star, default) or wcag") dank16Cmd.Flags().String("contrast", "dps", "Contrast algorithm: dps (Delta Phi Star, default) or wcag")
dank16Cmd.Flags().String("profile", "default", "Palette: default, low, medium or high")
dank16Cmd.Flags().Bool("variants", false, "Output all variants (dark/light/default) in JSON") dank16Cmd.Flags().Bool("variants", false, "Output all variants (dark/light/default) in JSON")
dank16Cmd.Flags().String("primary-dark", "", "Primary color for dark mode (use with --variants)") dank16Cmd.Flags().String("primary-dark", "", "Primary color for dark mode (use with --variants)")
dank16Cmd.Flags().String("primary-light", "", "Primary color for light mode (use with --variants)") dank16Cmd.Flags().String("primary-light", "", "Primary color for light mode (use with --variants)")
_ = dank16Cmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"default", "low", "medium", "high"}, cobra.ShellCompDirectiveNoFileComp
})
_ = dank16Cmd.RegisterFlagCompletionFunc("contrast", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { _ = dank16Cmd.RegisterFlagCompletionFunc("contrast", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"dps", "wcag"}, cobra.ShellCompDirectiveNoFileComp return []string{"dps", "wcag"}, cobra.ShellCompDirectiveNoFileComp
}) })
@@ -51,7 +47,6 @@ func runDank16(cmd *cobra.Command, args []string) {
isWezterm, _ := cmd.Flags().GetBool("wezterm") isWezterm, _ := cmd.Flags().GetBool("wezterm")
background, _ := cmd.Flags().GetString("background") background, _ := cmd.Flags().GetString("background")
contrastAlgo, _ := cmd.Flags().GetString("contrast") contrastAlgo, _ := cmd.Flags().GetString("contrast")
profile, _ := cmd.Flags().GetString("profile")
useVariants, _ := cmd.Flags().GetBool("variants") useVariants, _ := cmd.Flags().GetBool("variants")
primaryDark, _ := cmd.Flags().GetString("primary-dark") primaryDark, _ := cmd.Flags().GetString("primary-dark")
primaryLight, _ := cmd.Flags().GetString("primary-light") primaryLight, _ := cmd.Flags().GetString("primary-light")
@@ -84,12 +79,11 @@ func runDank16(cmd *cobra.Command, args []string) {
primaryLight = primaryColor primaryLight = primaryColor
} }
variantOpts := dank16.VariantOptions{ variantOpts := dank16.VariantOptions{
PrimaryDark: primaryDark, PrimaryDark: primaryDark,
PrimaryLight: primaryLight, PrimaryLight: primaryLight,
BackgroundDark: background, Background: background,
UseDPS: contrastAlgo == "dps", UseDPS: contrastAlgo == "dps",
IsLightMode: isLight, IsLightMode: isLight,
Profile: profile,
} }
variantColors := dank16.GenerateVariantPalette(variantOpts) variantColors := dank16.GenerateVariantPalette(variantOpts)
fmt.Print(dank16.GenerateVariantJSON(variantColors)) fmt.Print(dank16.GenerateVariantJSON(variantColors))
@@ -108,7 +102,6 @@ func runDank16(cmd *cobra.Command, args []string) {
IsLight: isLight, IsLight: isLight,
Background: background, Background: background,
UseDPS: contrastAlgo == "dps", UseDPS: contrastAlgo == "dps",
Profile: profile,
} }
colors := dank16.GeneratePalette(primaryColor, opts) colors := dank16.GeneratePalette(primaryColor, opts)
-643
View File
@@ -1,643 +0,0 @@
package main
import (
"bufio"
"bytes"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/duxlabs-dev/dux-shell/core/internal/dmenuipc"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/qsipc"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const dmenuConnectTimeout = 5 * time.Second
var (
dmenuPrompt string
dmenuLines int
dmenuPlaceholder string
dmenuPromptOnly bool
dmenuSep string
dmenuActiveSpec string
dmenuUrgentSpec string
dmenuOnlyMatch bool
dmenuNoCustom bool
dmenuSelect string
dmenuMesg string
dmenuInput string
dmenuPassword bool
dmenuMarkupRows bool
dmenuSync bool
dmenuNoRunIfEmpty bool
dmenuFormat string
dmenuMultiSelect bool
dmenuDump bool
dmenuKeybindSpecs []string
dmenuPrintInfo bool
dmenuView string
dmenuSize string
dmenuIcon string
dmenuCaseInsens bool
dmenuDisableHist bool
)
var dmenuCmd = &cobra.Command{
Use: "dmenu",
Short: "dmenu-compatible picker backed by the DMS app launcher",
Long: `Read newline-separated items from stdin, show them in the DMS launcher,
and print the selected item to stdout
Examples:
printf '%s\n' opt1 opt2 opt3 | dms dmenu -p "Pick one:"
dms dmenu --prompt-only -p "Rename to:"
Matching is always case-insensitive; -i/--case-insensitive is accepted for
dmenu script compatibility but has no effect.
Most, but not all, rofi flags are implemented.
Long-form flags also accept rofi's single-dash form
(e.g. -only-match as well as --only-match).
Exit codes: 0 on accept, 1 if the user cancelled (or stdin was empty),
2 if the shell was unreachable.`,
Run: runDmenu,
}
func init() {
dmenuCmd.Flags().BoolVarP(&dmenuCaseInsens, "case-insensitive", "i", false, "No effect: matching is always case-insensitive. Accepted for dmenu/rofi script compatibility")
dmenuCmd.Flags().StringVarP(&dmenuPrompt, "prompt", "p", "dmenu", "Prompt label")
dmenuCmd.Flags().IntVarP(&dmenuLines, "lines", "l", 0, "Max visible result rows (0 = shell default)")
dmenuCmd.Flags().StringVar(&dmenuPlaceholder, "placeholder", "", "Input placeholder text")
dmenuCmd.Flags().BoolVar(&dmenuPromptOnly, "prompt-only", false, "No piped item list — pure free-text prompt")
dmenuCmd.Flags().StringVar(&dmenuSep, "sep", "\n", "Input item separator")
dmenuCmd.Flags().StringVarP(&dmenuActiveSpec, "active", "a", "", "Mark rows active by index (e.g. \"1,3,7:11,-3:\")")
dmenuCmd.Flags().StringVarP(&dmenuUrgentSpec, "urgent", "u", "", "Mark rows urgent by index (same spec syntax as --active)")
dmenuCmd.Flags().BoolVar(&dmenuOnlyMatch, "only-match", false, "Reject free text; only a listed item may be returned; also blocks Escape/click-outside cancel, forcing a real pick")
dmenuCmd.Flags().BoolVar(&dmenuNoCustom, "no-custom", false, "Reject free text; only a listed item may be returned (unlike --only-match, Escape/cancel still works normally)")
dmenuCmd.Flags().StringVar(&dmenuSelect, "select", "", "Pre-select the first row matching this string (or, with --dump, filter to matching rows)")
dmenuCmd.Flags().StringVar(&dmenuMesg, "mesg", "", "Extra message line shown alongside the list")
dmenuCmd.Flags().StringVar(&dmenuInput, "input", "", "Read items from this file instead of stdin")
dmenuCmd.Flags().BoolVar(&dmenuPassword, "password", false, "Mask typed input (cosmetic only, not a security boundary)")
dmenuCmd.Flags().BoolVar(&dmenuMarkupRows, "markup-rows", false, "Render each row's text as rich markup")
dmenuCmd.Flags().BoolVar(&dmenuSync, "sync", false, "Read all input to EOF before showing the picker (default: stream as items arrive)")
dmenuCmd.Flags().BoolVar(&dmenuNoRunIfEmpty, "no-run-if-empty", false, "If stdin is empty, exit 1 immediately with no UI shown")
dmenuCmd.Flags().StringVar(&dmenuFormat, "format", "s", "What to print on accept: s=text (default), i=0-based index, d=1-based index, q=shell-quoted, p=markup-stripped, f=typed filter text, F=quoted filter text")
dmenuCmd.Flags().BoolVar(&dmenuMultiSelect, "multi-select", false, "Allow selecting multiple rows; each is printed on its own line on accept")
dmenuCmd.Flags().BoolVar(&dmenuDump, "dump", false, "Apply --select against the item list and print the result immediately; no UI is ever shown")
dmenuCmd.Flags().StringArrayVar(&dmenuKeybindSpecs, "keybind", nil, "Bind a custom accept key (repeatable): N=keyspec, e.g. --keybind 1=ctrl+e (N is 1-19); exits 9+N (10-28) instead of 0 when accepted that way")
dmenuCmd.Flags().BoolVar(&dmenuPrintInfo, "print-info", false, "Also print an accepted row's info metadata (the per-row \\0info\\x1f... field, see the per-row metadata protocol) on a second line")
dmenuCmd.Flags().BoolVar(&dmenuDisableHist, "disable-history", false, "Don't add this session's search query to the launcher's persistent history (default: recorded, like other launcher modes)")
dmenuCmd.Flags().StringVar(&dmenuView, "view", "", "Override the result view mode for this invocation: list, grid, or tile (default: list)")
dmenuCmd.Flags().StringVar(&dmenuSize, "size", "", "Override the popup size preset for this invocation: 1-4, matching the Appearance settings size buttons (default: the shell's own launcher size setting)")
dmenuCmd.Flags().StringVar(&dmenuIcon, "icon", "", "Show a Material Symbols icon next to the prompt badge (default: no icon)")
}
var dmenuLongFlagToken = regexp.MustCompile(`^-([a-zA-Z][a-zA-Z0-9-]+)(=.*)?$`)
func dmenuAliasableLongFlags() map[string]bool {
set := map[string]bool{}
dmenuCmd.Flags().VisitAll(func(f *pflag.Flag) {
if len(f.Name) > 1 {
set[f.Name] = true
}
})
return set
}
func normalizeDmenuArgv(args []string) []string {
if len(args) < 2 || args[1] != "dmenu" {
return args
}
aliasable := dmenuAliasableLongFlags()
out := make([]string, len(args))
copy(out, args)
for i := 2; i < len(out); i++ {
tok := out[i]
if tok == "--" {
break
}
m := dmenuLongFlagToken.FindStringSubmatch(tok)
if m == nil {
continue
}
name, valueSuffix := m[1], m[2]
if aliasable[name] {
out[i] = "--" + name + valueSuffix
}
}
return out
}
func dmenuSplitFunc(sep string) bufio.SplitFunc {
if sep == "\n" {
return bufio.ScanLines
}
sepBytes := []byte(sep)
return func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.Index(data, sepBytes); i >= 0 {
return i + len(sepBytes), data[0:i], nil
}
if atEOF {
return len(data), data, nil
}
return 0, nil, nil
}
}
func parseIndexSpec(spec string) ([]dmenuipc.Range, error) {
spec = strings.TrimSpace(spec)
if spec == "" {
return nil, nil
}
var ranges []dmenuipc.Range
for _, tok := range strings.Split(spec, ",") {
tok = strings.TrimSpace(tok)
if tok == "" {
continue
}
if idx := strings.Index(tok, ":"); idx >= 0 {
startStr, endStr := strings.TrimSpace(tok[:idx]), strings.TrimSpace(tok[idx+1:])
start, end := 0, -1
if startStr != "" {
n, err := strconv.Atoi(startStr)
if err != nil {
return nil, fmt.Errorf("invalid range start %q in spec %q", startStr, spec)
}
start = n
}
if endStr != "" {
n, err := strconv.Atoi(endStr)
if err != nil {
return nil, fmt.Errorf("invalid range end %q in spec %q", endStr, spec)
}
end = n
}
ranges = append(ranges, dmenuipc.Range{Start: start, End: end})
continue
}
n, err := strconv.Atoi(tok)
if err != nil {
return nil, fmt.Errorf("invalid index %q in spec %q", tok, spec)
}
ranges = append(ranges, dmenuipc.Range{Start: n, End: n + 1})
}
return ranges, nil
}
func parseDmenuKeybinds(specs []string) ([]dmenuipc.Keybind, error) {
var out []dmenuipc.Keybind
seen := map[int]bool{}
for _, s := range specs {
eq := strings.IndexByte(s, '=')
if eq <= 0 {
return nil, fmt.Errorf("invalid --keybind %q (expected N=keyspec, e.g. 1=ctrl+e)", s)
}
n, err := strconv.Atoi(strings.TrimSpace(s[:eq]))
if err != nil || n < 1 || n > 19 {
return nil, fmt.Errorf("invalid --keybind index in %q (expected 1-19)", s)
}
key := strings.TrimSpace(s[eq+1:])
if key == "" {
return nil, fmt.Errorf("invalid --keybind %q: empty key spec", s)
}
if seen[n] {
return nil, fmt.Errorf("duplicate --keybind index %d", n)
}
seen[n] = true
out = append(out, dmenuipc.Keybind{N: n, Key: key})
}
return out, nil
}
func dmenuParseBool(val string) bool {
switch strings.ToLower(strings.TrimSpace(val)) {
case "", "0", "false", "no":
return false
default:
return true
}
}
func parseDmenuRow(line string) (text string, meta *dmenuipc.ItemMeta) {
idx := strings.IndexByte(line, 0)
if idx < 0 {
return line, nil
}
text = line[:idx]
fields := strings.Split(line[idx+1:], "\x1f")
m := dmenuipc.ItemMeta{}
any := false
for i := 0; i+1 < len(fields); i += 2 {
key, val := fields[i], fields[i+1]
switch key {
case "icon":
m.Icon = val
case "display":
m.Display = val
case "meta":
m.Meta = val
case "info":
m.Info = val
case "nonselectable":
m.NonSelectable = dmenuParseBool(val)
case "urgent":
m.Urgent = dmenuParseBool(val)
case "active":
m.Active = dmenuParseBool(val)
case "permanent":
m.Permanent = dmenuParseBool(val)
case "header":
m.Header = dmenuParseBool(val)
default:
continue
}
any = true
}
if !any {
return text, nil
}
return text, &m
}
func validateDmenuFormat(format string) error {
switch format {
case "", "s", "i", "d", "q", "p", "f", "F":
return nil
default:
return fmt.Errorf("invalid --format %q (expected one of s,i,d,q,p,f,F)", format)
}
}
func validateDmenuView(view string) error {
switch view {
case "", "list", "grid", "tile":
return nil
default:
return fmt.Errorf("invalid --view %q (expected one of list,grid,tile)", view)
}
}
func dmenuSizePreset(size string) (string, error) {
switch size {
case "":
return "", nil
case "1":
return "micro", nil
case "2":
return "compact", nil
case "3":
return "medium", nil
case "4":
return "large", nil
default:
return "", fmt.Errorf("invalid --size %q (expected one of 1,2,3,4)", size)
}
}
var dmenuMarkupTagRe = regexp.MustCompile(`<[^>]*>`)
func dmenuShellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
func dmenuFormatOutput(format string, index int, text, filterText string) (string, error) {
switch format {
case "", "s":
return text, nil
case "i":
return strconv.Itoa(index), nil
case "d":
if index < 0 {
return strconv.Itoa(index), nil
}
return strconv.Itoa(index + 1), nil
case "q":
return dmenuShellQuote(text), nil
case "p":
return dmenuMarkupTagRe.ReplaceAllString(text, ""), nil
case "f":
return filterText, nil
case "F":
return dmenuShellQuote(filterText), nil
default:
return "", fmt.Errorf("invalid --format %q (expected one of s,i,d,q,p,f,F)", format)
}
}
func dmenuOpenInput() *os.File {
if dmenuPromptOnly {
return nil
}
if dmenuInput != "" {
f, err := os.Open(dmenuInput)
if err != nil {
log.Fatalf("Error opening --input file: %v", err)
}
return f
}
return os.Stdin
}
type dmenuRow struct {
Text string
Meta *dmenuipc.ItemMeta
}
func dmenuReadAll(input *os.File) []dmenuRow {
if input == nil {
return nil
}
scanner := bufio.NewScanner(input)
scanner.Buffer(make([]byte, 64*1024), 1<<20)
scanner.Split(dmenuSplitFunc(dmenuSep))
var rows []dmenuRow
for scanner.Scan() {
text, meta := parseDmenuRow(scanner.Text())
rows = append(rows, dmenuRow{Text: text, Meta: meta})
}
if err := scanner.Err(); err != nil {
log.Fatalf("Error reading items: %v", err)
}
return rows
}
func dmenuDumpLines(rows []dmenuRow, select_, format string, printInfo bool) ([]string, error) {
var lower string
filtering := select_ != ""
if filtering {
lower = strings.ToLower(select_)
}
var lines []string
for i, row := range rows {
if row.Meta != nil && (row.Meta.NonSelectable || row.Meta.Header) {
continue
}
if filtering && !strings.Contains(strings.ToLower(row.Text), lower) {
continue
}
out, err := dmenuFormatOutput(format, i, row.Text, select_)
if err != nil {
return nil, err
}
lines = append(lines, out)
if printInfo && row.Meta != nil && row.Meta.Info != "" {
lines = append(lines, row.Meta.Info)
}
}
return lines, nil
}
func runDmenuDump(rows []dmenuRow, format string) {
lines, err := dmenuDumpLines(rows, dmenuSelect, format, dmenuPrintInfo)
if err != nil {
log.Fatalf("Error formatting output: %v", err)
}
for _, l := range lines {
fmt.Println(l)
}
os.Exit(0)
}
func runDmenu(cmd *cobra.Command, args []string) {
if cmd.Flags().Changed("case-insensitive") {
fmt.Fprintln(os.Stderr, "dms dmenu: warning: -i/--case-insensitive has no effect (matching is always case-insensitive)")
}
os.Exit(dmenuSession())
}
func dmenuSession() int {
format := dmenuFormat
if format == "" {
format = "s"
}
if err := validateDmenuFormat(format); err != nil {
log.Fatalf("Error in --format: %v", err)
}
if dmenuDump {
input := dmenuOpenInput()
if input != nil && input != os.Stdin {
defer input.Close()
}
runDmenuDump(dmenuReadAll(input), format)
return 0
}
activeRanges, err := parseIndexSpec(dmenuActiveSpec)
if err != nil {
log.Fatalf("Error parsing --active: %v", err)
}
urgentRanges, err := parseIndexSpec(dmenuUrgentSpec)
if err != nil {
log.Fatalf("Error parsing --urgent: %v", err)
}
keybinds, err := parseDmenuKeybinds(dmenuKeybindSpecs)
if err != nil {
log.Fatalf("Error parsing --keybind: %v", err)
}
if err := validateDmenuView(dmenuView); err != nil {
log.Fatalf("Error in --view: %v", err)
}
dmenuSizePresetName, err := dmenuSizePreset(dmenuSize)
if err != nil {
log.Fatalf("Error in --size: %v", err)
}
input := dmenuOpenInput()
if input != nil && input != os.Stdin {
defer input.Close()
}
var scanner *bufio.Scanner
if input != nil {
scanner = bufio.NewScanner(input)
scanner.Buffer(make([]byte, 64*1024), 1<<20)
scanner.Split(dmenuSplitFunc(dmenuSep))
}
var bufferedItems []string
streaming := scanner != nil && !dmenuSync
if scanner != nil && !streaming {
for scanner.Scan() {
bufferedItems = append(bufferedItems, scanner.Text())
}
if err := scanner.Err(); err != nil {
log.Errorf("Error reading items: %v", err)
return 1
}
}
var pendingFirstItem string
havePendingFirstItem := false
if dmenuNoRunIfEmpty {
if streaming {
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
log.Errorf("Error reading items: %v", err)
return 1
}
return 1
}
pendingFirstItem = scanner.Text()
havePendingFirstItem = true
} else if len(bufferedItems) == 0 {
return 1
}
}
release, err := dmenuipc.AcquireQueueSlot(func() {
fmt.Fprintln(os.Stderr, "dms dmenu: waiting for another dmenu session to finish...")
})
if err != nil {
log.Fatalf("Error acquiring dmenu queue slot: %v", err)
}
defer release()
sess, err := dmenuipc.Listen()
if err != nil {
log.Fatalf("Error creating dmenu socket: %v", err)
}
defer sess.Close()
pid, ok := shellApp.SessionPID()
if !ok {
log.Errorf("DMS shell is not running")
return 1
}
mode := "dmenu:" + sess.Path()
if _, _, err := qsipc.Call(qsipc.SocketPathForPID(pid), "launcher", "openWith", []string{mode}); err != nil {
log.Errorf("Error opening launcher: %v", err)
return 1
}
conn, err := sess.Accept(dmenuConnectTimeout)
if err != nil {
log.Debugf("dmenu: shell never connected: %v", err)
return 2
}
defer conn.Close()
header := dmenuipc.Header{
Prompt: dmenuPrompt,
Placeholder: dmenuPlaceholder,
Lines: dmenuLines,
PromptOnly: dmenuPromptOnly,
Select: dmenuSelect,
Mesg: dmenuMesg,
Password: dmenuPassword,
MarkupRows: dmenuMarkupRows,
OnlyMatch: dmenuOnlyMatch,
NoCustom: dmenuNoCustom,
MultiSelect: dmenuMultiSelect,
Active: activeRanges,
Urgent: urgentRanges,
Keybinds: keybinds,
View: dmenuView,
Size: dmenuSizePresetName,
Icon: dmenuIcon,
DisableHistory: dmenuDisableHist,
}
if err := conn.SendHeader(header); err != nil {
log.Debugf("dmenu: failed to send header: %v", err)
return 2
}
var sendErr error
sendItem := func(line string) {
text, meta := parseDmenuRow(line)
if err := conn.SendItem(text, meta); err != nil {
log.Debugf("dmenu: failed to send item: %v", err)
sendErr = err
}
}
if streaming {
if havePendingFirstItem && sendErr == nil {
sendItem(pendingFirstItem)
}
for sendErr == nil && scanner.Scan() {
sendItem(scanner.Text())
}
if err := scanner.Err(); err != nil {
log.Debugf("dmenu: error reading items mid-stream: %v", err)
return 2
}
} else {
for _, item := range bufferedItems {
if sendErr != nil {
break
}
sendItem(item)
}
}
if sendErr != nil {
return 2
}
if err := conn.SendEnd(); err != nil {
log.Debugf("dmenu: failed to send end marker: %v", err)
return 2
}
sel, selected, err := conn.ReadSelection()
if err != nil {
log.Debugf("dmenu: failed to read result: %v", err)
return 2
}
if !selected {
return 1
}
switch sel.Kind {
case "multi":
for _, item := range sel.Items {
out, err := dmenuFormatOutput(format, item.Index, item.Text, sel.FilterText)
if err != nil {
log.Errorf("Error formatting output: %v", err)
return 1
}
fmt.Println(out)
if dmenuPrintInfo && item.Info != "" {
fmt.Println(item.Info)
}
}
case "freetext":
out, err := dmenuFormatOutput(format, -1, sel.Text, sel.FilterText)
if err != nil {
log.Errorf("Error formatting output: %v", err)
return 1
}
fmt.Println(out)
default: // "row"
out, err := dmenuFormatOutput(format, sel.Index, sel.Text, sel.FilterText)
if err != nil {
log.Errorf("Error formatting output: %v", err)
return 1
}
fmt.Println(out)
if dmenuPrintInfo && sel.Info != "" {
fmt.Println(sel.Info)
}
}
return dmenuKeybindExitCode(sel.KeybindN)
}
func dmenuKeybindExitCode(keybindN int) int {
if keybindN <= 0 {
return 0
}
return 9 + keybindN
}
-350
View File
@@ -1,350 +0,0 @@
package main
import (
"reflect"
"testing"
"github.com/duxlabs-dev/dux-shell/core/internal/dmenuipc"
)
func TestParseIndexSpec(t *testing.T) {
for _, tc := range []struct {
spec string
want []dmenuipc.Range
wantErr bool
}{
{"", nil, false},
{"1,3", []dmenuipc.Range{{Start: 1, End: 2}, {Start: 3, End: 4}}, false},
{"7:11", []dmenuipc.Range{{Start: 7, End: 11}}, false},
{"-3:", []dmenuipc.Range{{Start: -3, End: -1}}, false},
{":5", []dmenuipc.Range{{Start: 0, End: 5}}, false},
{"1, 3 , 7:11", []dmenuipc.Range{{Start: 1, End: 2}, {Start: 3, End: 4}, {Start: 7, End: 11}}, false},
{"1,,3", []dmenuipc.Range{{Start: 1, End: 2}, {Start: 3, End: 4}}, false},
{"x", nil, true},
{"1:y", nil, true},
{"x:1", nil, true},
} {
got, err := parseIndexSpec(tc.spec)
if tc.wantErr {
if err == nil {
t.Errorf("parseIndexSpec(%q): expected error, got %v", tc.spec, got)
}
continue
}
if err != nil {
t.Errorf("parseIndexSpec(%q): unexpected error: %v", tc.spec, err)
continue
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("parseIndexSpec(%q) = %+v, want %+v", tc.spec, got, tc.want)
}
}
}
func TestParseDmenuKeybinds(t *testing.T) {
for _, tc := range []struct {
name string
specs []string
want []dmenuipc.Keybind
wantErr bool
}{
{"empty", nil, nil, false},
{"multiple", []string{"1=ctrl+e", "2=f5"}, []dmenuipc.Keybind{{N: 1, Key: "ctrl+e"}, {N: 2, Key: "f5"}}, false},
{"missing equals", []string{"bad"}, nil, true},
{"zero index", []string{"0=x"}, nil, true},
{"out of range index", []string{"20=x"}, nil, true},
{"empty key spec", []string{"1="}, nil, true},
{"duplicate index", []string{"1=ctrl+e", "1=f5"}, nil, true},
{"non numeric index", []string{"a=x"}, nil, true},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := parseDmenuKeybinds(tc.specs)
if tc.wantErr {
if err == nil {
t.Fatalf("parseDmenuKeybinds(%v): expected error, got %v", tc.specs, got)
}
return
}
if err != nil {
t.Fatalf("parseDmenuKeybinds(%v): unexpected error: %v", tc.specs, err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("parseDmenuKeybinds(%v) = %+v, want %+v", tc.specs, got, tc.want)
}
})
}
}
func TestDmenuParseBool(t *testing.T) {
for _, tc := range []struct {
val string
want bool
}{
{"", false},
{"0", false},
{"false", false},
{"False", false},
{"no", false},
{"anything-else", true},
} {
if got := dmenuParseBool(tc.val); got != tc.want {
t.Errorf("dmenuParseBool(%q) = %v, want %v", tc.val, got, tc.want)
}
}
}
func TestParseDmenuRow(t *testing.T) {
t.Run("plain text, no metadata", func(t *testing.T) {
text, meta := parseDmenuRow("apple")
if text != "apple" || meta != nil {
t.Fatalf("parseDmenuRow(apple) = %q, %+v, want apple, nil", text, meta)
}
})
t.Run("single field", func(t *testing.T) {
text, meta := parseDmenuRow("Firefox\x00icon\x1ffirefox")
if text != "Firefox" {
t.Fatalf("text = %q, want Firefox", text)
}
if meta == nil || meta.Icon != "firefox" {
t.Fatalf("meta = %+v, want Icon=firefox", meta)
}
})
t.Run("all recognized fields", func(t *testing.T) {
line := "Firefox\x00icon\x1ffirefox\x1fdisplay\x1fMozilla Firefox\x1fmeta\x1fbrowser\x1finfo\x1ffirefox.desktop" +
"\x1fnonselectable\x1ftrue\x1furgent\x1ftrue\x1factive\x1ftrue\x1fpermanent\x1ftrue\x1fheader\x1ftrue"
text, meta := parseDmenuRow(line)
want := dmenuipc.ItemMeta{
Icon: "firefox", Display: "Mozilla Firefox", Meta: "browser", Info: "firefox.desktop",
NonSelectable: true, Urgent: true, Active: true, Permanent: true, Header: true,
}
if text != "Firefox" {
t.Fatalf("text = %q, want Firefox", text)
}
if meta == nil || *meta != want {
t.Fatalf("meta = %+v, want %+v", meta, want)
}
})
t.Run("unknown field ignored but still produces meta", func(t *testing.T) {
text, meta := parseDmenuRow("apple\x00bogus\x1fvalue")
if text != "apple" {
t.Fatalf("text = %q, want apple", text)
}
if meta != nil {
t.Fatalf("meta = %+v, want nil (no recognized fields)", meta)
}
})
t.Run("dangling unpaired field ignored", func(t *testing.T) {
text, meta := parseDmenuRow("apple\x00icon\x1ffirefox\x1fdangling")
if text != "apple" {
t.Fatalf("text = %q, want apple", text)
}
if meta == nil || meta.Icon != "firefox" {
t.Fatalf("meta = %+v, want Icon=firefox", meta)
}
})
}
func TestValidateDmenuFormat(t *testing.T) {
for _, f := range []string{"", "s", "i", "d", "q", "p", "f", "F"} {
if err := validateDmenuFormat(f); err != nil {
t.Errorf("validateDmenuFormat(%q): unexpected error: %v", f, err)
}
}
for _, f := range []string{"x", "S", "ff"} {
if err := validateDmenuFormat(f); err == nil {
t.Errorf("validateDmenuFormat(%q): expected error, got nil", f)
}
}
}
func TestValidateDmenuView(t *testing.T) {
for _, v := range []string{"", "list", "grid", "tile"} {
if err := validateDmenuView(v); err != nil {
t.Errorf("validateDmenuView(%q): unexpected error: %v", v, err)
}
}
for _, v := range []string{"bogus", "List"} {
if err := validateDmenuView(v); err == nil {
t.Errorf("validateDmenuView(%q): expected error, got nil", v)
}
}
}
func TestDmenuShellQuote(t *testing.T) {
for _, tc := range []struct {
in string
want string
}{
{"banana", "'banana'"},
{"it's", `'it'\''s'`},
{"", "''"},
} {
if got := dmenuShellQuote(tc.in); got != tc.want {
t.Errorf("dmenuShellQuote(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestDmenuFormatOutput(t *testing.T) {
for _, tc := range []struct {
format string
index int
text string
filterText string
want string
wantErr bool
}{
{"", 1, "banana", "ban", "banana", false},
{"s", 1, "banana", "ban", "banana", false},
{"i", 1, "banana", "ban", "1", false},
{"d", 1, "banana", "ban", "2", false},
{"d", -1, "banana", "xyz", "-1", false},
{"q", 0, "it's", "", "'it'\\''s'", false},
{"p", 0, "<b>Bold</b> item", "", "Bold item", false},
{"f", 0, "banana", "ban", "ban", false},
{"F", 0, "banana", "it's", "'it'\\''s'", false},
{"bogus", 0, "banana", "", "", true},
} {
got, err := dmenuFormatOutput(tc.format, tc.index, tc.text, tc.filterText)
if tc.wantErr {
if err == nil {
t.Errorf("dmenuFormatOutput(%q, ...): expected error, got %q", tc.format, got)
}
continue
}
if err != nil {
t.Errorf("dmenuFormatOutput(%q, ...): unexpected error: %v", tc.format, err)
continue
}
if got != tc.want {
t.Errorf("dmenuFormatOutput(%q, %d, %q, %q) = %q, want %q", tc.format, tc.index, tc.text, tc.filterText, got, tc.want)
}
}
}
func TestDmenuDumpLines(t *testing.T) {
rows := []dmenuRow{
{Text: "Header", Meta: &dmenuipc.ItemMeta{Header: true}},
{Text: "Firefox", Meta: &dmenuipc.ItemMeta{Info: "firefox.desktop"}},
{Text: "apple"},
{Text: "skip", Meta: &dmenuipc.ItemMeta{NonSelectable: true}},
{Text: "banana"},
}
t.Run("no filter, default format", func(t *testing.T) {
got, err := dmenuDumpLines(rows, "", "s", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"Firefox", "apple", "banana"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("with select filter", func(t *testing.T) {
got, err := dmenuDumpLines(rows, "an", "s", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"banana"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("print-info appends info lines", func(t *testing.T) {
got, err := dmenuDumpLines(rows, "", "s", true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"Firefox", "firefox.desktop", "apple", "banana"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("invalid format propagates error", func(t *testing.T) {
if _, err := dmenuDumpLines(rows, "", "bogus", false); err == nil {
t.Fatal("expected error, got nil")
}
})
}
func TestDmenuKeybindExitCode(t *testing.T) {
for _, tc := range []struct {
n int
want int
}{
{0, 0},
{1, 10},
{19, 28},
} {
if got := dmenuKeybindExitCode(tc.n); got != tc.want {
t.Errorf("dmenuKeybindExitCode(%d) = %d, want %d", tc.n, got, tc.want)
}
}
}
func TestNormalizeDmenuArgv(t *testing.T) {
for _, tc := range []struct {
name string
args []string
want []string
}{
{
"not the dmenu subcommand: untouched",
[]string{"dms", "ipc", "-password"},
[]string{"dms", "ipc", "-password"},
},
{
"rofi-style single-dash long flags get a second dash",
[]string{"dms", "dmenu", "-only-match", "-password", "-sep", ","},
[]string{"dms", "dmenu", "--only-match", "--password", "--sep", ","},
},
{
"= form is preserved",
[]string{"dms", "dmenu", "-format=i"},
[]string{"dms", "dmenu", "--format=i"},
},
{
"already-double-dash flags are untouched",
[]string{"dms", "dmenu", "--password", "-p", "hi"},
[]string{"dms", "dmenu", "--password", "-p", "hi"},
},
{
"single-char shorthands are untouched",
[]string{"dms", "dmenu", "-p", "hi", "-l", "5", "-i"},
[]string{"dms", "dmenu", "-p", "hi", "-l", "5", "-i"},
},
{
"negative --active/--urgent spec values are not mistaken for flag names",
[]string{"dms", "dmenu", "--active", "-3:"},
[]string{"dms", "dmenu", "--active", "-3:"},
},
{
"DMS-native long flags also get the single-dash alias",
[]string{"dms", "dmenu", "-icon", "star"},
[]string{"dms", "dmenu", "--icon", "star"},
},
{
"rofi's -a/-u abbreviations are real shorthands, untouched by the rewrite",
[]string{"dms", "dmenu", "-a", "1,3", "-u", "2"},
[]string{"dms", "dmenu", "-a", "1,3", "-u", "2"},
},
{
"stops rewriting after a bare --",
[]string{"dms", "dmenu", "--", "-password"},
[]string{"dms", "dmenu", "--", "-password"},
},
} {
got := normalizeDmenuArgv(tc.args)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("%s: normalizeDmenuArgv(%v) = %v, want %v", tc.name, tc.args, got, tc.want)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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)
}
}
}
-437
View File
@@ -1,437 +0,0 @@
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) {
tests := []struct {
name string
stderr string
want string
}{
{
name: "Qt private API symbol mismatch",
stderr: "qs: symbol lookup error: qs: undefined symbol: _ZN23QUntypedPropertyBindingC1EP23QPropertyBindingPrivate, version Qt_6_PRIVATE_API",
want: "Quickshell is incompatible with the installed Qt libraries. Rebuild or reinstall Quickshell against the current Qt version.",
},
{
name: "other failure",
stderr: "qs: unsupported option --version",
want: "Run 'qs --version' to inspect the failure.",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := quickshellVersionFailureDetails(tc.stderr); got != tc.want {
t.Fatalf("quickshellVersionFailureDetails(%q) = %q, want %q", tc.stderr, got, tc.want)
}
})
}
}
// Parsing a full property dump as a bare value would yield a bogus plugin root,
// making the qtengine check warn at a user whose install is fine.
func TestQtQueryValue(t *testing.T) {
const dumpAll = `QT_INSTALL_PREFIX:/usr
QT_INSTALL_PLUGINS:/usr/lib/qt/plugins
QT_VERSION:5.15.19`
tests := []struct {
name string
output string
key string
want string
}{
{
name: "bare value as qmake prints it",
output: "/usr/lib/qt6/plugins\n",
key: "QT_INSTALL_PLUGINS",
want: "/usr/lib/qt6/plugins",
},
{
name: "key:value line among a full property dump",
output: dumpAll,
key: "QT_INSTALL_PLUGINS",
want: "/usr/lib/qt/plugins",
},
{
name: "key absent from a multi line dump yields nothing",
output: dumpAll,
key: "QT_INSTALL_LIBEXECS",
want: "",
},
{
name: "surrounding whitespace is trimmed",
output: " QT_INSTALL_PLUGINS: /usr/lib/qt/plugins \n",
key: "QT_INSTALL_PLUGINS",
want: "/usr/lib/qt/plugins",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := qtQueryValue(tc.output, tc.key); got != tc.want {
t.Fatalf("qtQueryValue(%q, %q) = %q, want %q", tc.output, tc.key, got, tc.want)
}
})
}
}
// stubQtQueryTool writes a fake Qt build tool answering QT_VERSION and
// QT_INSTALL_PLUGINS from the given values, and points PATH at its directory
// alone so the real toolchain cannot interfere.
func stubQtQueryTool(t *testing.T, bin, version, plugins string) string {
t.Helper()
dir := t.TempDir()
script := "#!/bin/sh\ncase \"$2\" in\n QT_VERSION) echo " + version +
" ;;\n QT_INSTALL_PLUGINS) echo " + plugins + " ;;\nesac\n"
if err := os.WriteFile(filepath.Join(dir, bin), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir)
return dir
}
func writePlugin(t *testing.T, pluginsDir, file string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(pluginsDir, "platformthemes"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginsDir, "platformthemes", file), nil, 0o644); err != nil {
t.Fatal(err)
}
}
// 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,
}
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 {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginsDir, "platformthemes", file), out.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
}
func TestCheckQtPlatformThemePlugin(t *testing.T) {
t.Run("qt6ct-kde is flagged as a package name, not a theme", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "qt6ct-kde")
t.Setenv("PATH", t.TempDir()) // no Qt tools reachable
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 {
t.Fatalf("got %d results, want 1: %+v", len(results), results)
}
if results[0].status != statusWarn {
t.Fatalf("status = %q, want warn", results[0].status)
}
if !strings.Contains(results[0].details, "package name") || !strings.Contains(results[0].details, "qt6ct") {
t.Fatalf("details should name the package/value mixup, got %q", results[0].details)
}
})
t.Run("installed plugin reports OK", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "qt6ct")
plugins := t.TempDir()
writePlugin(t, plugins, "libqt6ct.so")
stubQtQueryTool(t, "qmake6", "6.11.2", plugins)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 || results[0].status != statusOK {
t.Fatalf("got %+v, want one OK result", results)
}
if !strings.Contains(results[0].details, "libqt6ct.so") {
t.Fatalf("details should point at the plugin, got %q", results[0].details)
}
})
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()
stubQtQueryTool(t, "qtpaths6", "6.11.2", plugins)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 || results[0].status != statusWarn {
t.Fatalf("got %+v, want one warn result", results)
}
want := filepath.Join(plugins, "platformthemes", "libqt6ct.so")
if !strings.Contains(results[0].details, want) {
t.Fatalf("details should contain expected path %s, got %q", want, results[0].details)
}
})
t.Run("no Qt build tools degrades to info", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "qt6ct")
t.Setenv("PATH", t.TempDir())
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 || results[0].status != statusInfo {
t.Fatalf("got %+v, want one info result", results)
}
})
t.Run("unset is left to checkEnvVar", 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)
}
})
t.Run("qt6 variable is not satisfied by a Qt5 tool alone", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME_QT6", "qt6ct")
plugins := t.TempDir()
stubQtQueryTool(t, "qmake-qt5", "5.15.19", plugins)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME_QT6")
if len(results) != 1 || results[0].status != statusInfo {
t.Fatalf("got %+v, want one info result", results)
}
})
t.Run("kde plugin miss degrades to info", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "kde")
plugins := t.TempDir()
stubQtQueryTool(t, "qmake6", "6.11.2", plugins)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 || results[0].status != statusInfo {
t.Fatalf("got %+v, want one info result", results)
}
})
t.Run("gtk3 resolves through the libq convention", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "gtk3")
plugins := t.TempDir()
writePlugin(t, plugins, "libqgtk3.so")
stubQtQueryTool(t, "qmake6", "6.11.2", plugins)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 || results[0].status != statusOK {
t.Fatalf("got %+v, want one OK result", results)
}
})
}
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)
}
}
}
+51 -38
View File
@@ -3,19 +3,19 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"net"
"net/http"
"os" "os"
"strings" "path/filepath"
"time" "time"
"github.com/duxlabs-dev/dux-shell/core/internal/netfetch"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var dlOutput string var dlOutput string
var dlUserAgent string var dlUserAgent string
var dlHeaders []string
var dlTimeout int var dlTimeout int
var dlConnectTimeout int
var dlIPv4Only bool var dlIPv4Only bool
var dlCmd = &cobra.Command{ var dlCmd = &cobra.Command{
@@ -33,52 +33,65 @@ var dlCmd = &cobra.Command{
func init() { func init() {
dlCmd.Flags().StringVarP(&dlOutput, "output", "o", "", "Output file path (default: stdout)") dlCmd.Flags().StringVarP(&dlOutput, "output", "o", "", "Output file path (default: stdout)")
dlCmd.Flags().StringVar(&dlUserAgent, "user-agent", "", "Custom User-Agent header") dlCmd.Flags().StringVar(&dlUserAgent, "user-agent", "", "Custom User-Agent header")
dlCmd.Flags().StringArrayVarP(&dlHeaders, "header", "H", nil, "Extra request header as 'Name: value' (repeatable)") dlCmd.Flags().IntVar(&dlTimeout, "timeout", 10, "Request timeout in seconds")
dlCmd.Flags().IntVar(&dlTimeout, "timeout", 10, "Total request timeout in seconds")
dlCmd.Flags().IntVar(&dlConnectTimeout, "connect-timeout", 5, "Connection timeout in seconds")
dlCmd.Flags().BoolVarP(&dlIPv4Only, "ipv4", "4", false, "Force IPv4 only") dlCmd.Flags().BoolVarP(&dlIPv4Only, "ipv4", "4", false, "Force IPv4 only")
} }
func parseHeaders(raw []string) (map[string]string, error) {
if len(raw) == 0 {
return nil, nil
}
headers := make(map[string]string, len(raw))
for _, entry := range raw {
name, value, found := strings.Cut(entry, ":")
if !found {
return nil, fmt.Errorf("invalid header %q: expected 'Name: value'", entry)
}
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("invalid header %q: empty name", entry)
}
headers[name] = strings.TrimSpace(value)
}
return headers, nil
}
func runDownload(url string) error { func runDownload(url string) error {
headers, err := parseHeaders(dlHeaders) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(dlTimeout)*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
return err return fmt.Errorf("invalid request: %w", err)
} }
opts := netfetch.Options{ switch {
Headers: headers, case dlUserAgent != "":
UserAgent: dlUserAgent, req.Header.Set("User-Agent", dlUserAgent)
Timeout: time.Duration(dlTimeout) * time.Second, default:
ConnectTimeout: time.Duration(dlConnectTimeout) * time.Second, req.Header.Set("User-Agent", "DankMaterialShell/1.0 (Linux)")
IPv4Only: dlIPv4Only, }
dialer := &net.Dialer{Timeout: 5 * time.Second}
transport := &http.Transport{DialContext: dialer.DialContext}
if dlIPv4Only {
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, "tcp4", addr)
}
}
client := &http.Client{Transport: transport}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("download failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("HTTP %d", resp.StatusCode)
} }
if dlOutput == "" { if dlOutput == "" {
return netfetch.ToWriter(context.Background(), url, opts, os.Stdout) _, err = io.Copy(os.Stdout, resp.Body)
return err
} }
if err := netfetch.ToFile(context.Background(), url, opts, dlOutput); err != nil { if dir := filepath.Dir(dlOutput); dir != "." {
return err if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("mkdir failed: %w", err)
}
}
f, err := os.Create(dlOutput)
if err != nil {
return fmt.Errorf("create failed: %w", err)
}
defer f.Close()
if _, err := io.Copy(f, resp.Body); err != nil {
os.Remove(dlOutput)
return fmt.Errorf("write failed: %w", err)
} }
fmt.Println(dlOutput) fmt.Println(dlOutput)
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
+44 -69
View File
@@ -13,22 +13,20 @@ import (
"strings" "strings"
"time" "time"
"github.com/duxlabs-dev/dux-shell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/duxlabs-dev/dux-shell/core/internal/errdefs" "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/netfetch" "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/duxlabs-dev/dux-shell/core/internal/privesc" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/duxlabs-dev/dux-shell/core/internal/server/sysupdate" "github.com/AvengeMedia/DankMaterialShell/core/internal/version"
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/duxlabs-dev/dux-shell/core/internal/version"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var updateCmd = &cobra.Command{ var updateCmd = &cobra.Command{
Use: "update", Use: "update",
Short: "Update Dux Shell to the latest version", Short: "Update DankMaterialShell to the latest version",
Long: "Update Dux Shell to the latest version using the appropriate package manager for your distribution", Long: "Update DankMaterialShell to the latest version using the appropriate package manager for your distribution",
PreRunE: shellApp.ResolveConfig, PreRunE: findConfig,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
runUpdate() runUpdate()
}, },
@@ -36,7 +34,7 @@ var updateCmd = &cobra.Command{
var updateCheckCmd = &cobra.Command{ var updateCheckCmd = &cobra.Command{
Use: "check", Use: "check",
Short: "Check if updates are available for Dux Shell", Short: "Check if updates are available for DankMaterialShell",
Long: "Check for available updates without performing the actual update", Long: "Check for available updates without performing the actual update",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
runUpdateCheck() runUpdateCheck()
@@ -44,19 +42,9 @@ var updateCheckCmd = &cobra.Command{
} }
func runUpdateCheck() { func runUpdateCheck() {
fmt.Println("Checking for Dux Shell updates...") fmt.Println("Checking for DankMaterialShell updates...")
fmt.Println() fmt.Println()
shell := sysupdate.ProbeShell(context.Background(), Version)
switch shell.InstallMethod {
case sysupdate.InstallPacman, sysupdate.InstallRPM, sysupdate.InstallDpkg, sysupdate.InstallXbps:
fmt.Printf("Installed as package %s (%s); update it with your package manager.\n", shell.PackageName, shell.InstallMethod)
os.Exit(0)
case sysupdate.InstallNix:
fmt.Println("Managed by Nix; update through your flake or channel.")
os.Exit(0)
}
versionInfo, err := version.GetDMSVersionInfo() versionInfo, err := version.GetDMSVersionInfo()
if err != nil { if err != nil {
log.Fatalf("Error checking for updates: %v", err) log.Fatalf("Error checking for updates: %v", err)
@@ -110,7 +98,7 @@ func runUpdate() {
} }
log.Info("Update complete! Restarting DMS...") log.Info("Update complete! Restarting DMS...")
shellApp.Restart() restartShell()
} }
func updateArchLinux() error { func updateArchLinux() error {
@@ -169,7 +157,7 @@ func updateArchLinux() error {
return updateOtherDistros() return updateOtherDistros()
} }
fmt.Printf("This will update Dux Shell using %s.\n", helper) fmt.Printf("This will update DankMaterialShell using %s.\n", helper)
if !confirmUpdate() { if !confirmUpdate() {
return errdefs.ErrUpdateCancelled return errdefs.ErrUpdateCancelled
} }
@@ -193,8 +181,12 @@ func updateOtherDistros() error {
} }
dmsPath := filepath.Join(homeDir, ".config", "quickshell", "dms") dmsPath := filepath.Join(homeDir, ".config", "quickshell", "dms")
_, statErr := os.Stat(dmsPath)
hasClone := statErr == nil if _, err := os.Stat(dmsPath); os.IsNotExist(err) {
return fmt.Errorf("DMS configuration directory not found at %s", dmsPath)
}
fmt.Printf("Found DMS configuration at %s\n", dmsPath)
versionInfo, err := version.GetDMSVersionInfo() versionInfo, err := version.GetDMSVersionInfo()
if err == nil && !versionInfo.HasUpdate { if err == nil && !versionInfo.HasUpdate {
@@ -206,25 +198,9 @@ func updateOtherDistros() error {
return errdefs.ErrNoUpdateNeeded return errdefs.ErrNoUpdateNeeded
} }
// Release binaries embed the shell; without a clone the binary is the whole install.
if !hasClone {
fmt.Println("\nThis will update the dms binary from GitHub releases.")
if !confirmUpdate() {
return errdefs.ErrUpdateCancelled
}
fmt.Println("\n=== Updating dms binary ===")
if err := updateDMSBinary(); err != nil {
return fmt.Errorf("failed to update dms binary: %w", err)
}
fmt.Println("dms binary successfully updated")
return nil
}
fmt.Printf("Found DMS configuration at %s\n", dmsPath)
fmt.Println("\nThis will update:") fmt.Println("\nThis will update:")
fmt.Println(" 1. The dms binary from GitHub releases") fmt.Println(" 1. The dms binary from GitHub releases")
fmt.Println(" 2. Dux Shell configuration using git") fmt.Println(" 2. DankMaterialShell configuration using git")
if !confirmUpdate() { if !confirmUpdate() {
return errdefs.ErrUpdateCancelled return errdefs.ErrUpdateCancelled
} }
@@ -274,10 +250,6 @@ func updateOtherDistros() error {
return fmt.Errorf("failed to fetch changes: %w", err) return fmt.Errorf("failed to fetch changes: %w", err)
} }
if err := updateSubmodules(); err != nil {
return fmt.Errorf("failed to update submodules: %w", err)
}
if currentTag != "" { if currentTag != "" {
latestTagCmd := exec.Command("git", "tag", "-l", "v*", "--sort=-version:refname") latestTagCmd := exec.Command("git", "tag", "-l", "v*", "--sort=-version:refname")
latestTagOutput, err := latestTagCmd.Output() latestTagOutput, err := latestTagCmd.Output()
@@ -319,10 +291,6 @@ func updateOtherDistros() error {
return fmt.Errorf("update cancelled") return fmt.Errorf("update cancelled")
} }
if err := updateSubmodules(); err != nil {
return fmt.Errorf("failed to update submodules: %w", err)
}
fmt.Printf("\nUpdate complete! Updated from %s to %s\n", currentTag, latestTag) fmt.Printf("\nUpdate complete! Updated from %s to %s\n", currentTag, latestTag)
return nil return nil
} }
@@ -352,21 +320,10 @@ func updateOtherDistros() error {
return fmt.Errorf("update cancelled") return fmt.Errorf("update cancelled")
} }
if err := updateSubmodules(); err != nil {
return fmt.Errorf("failed to update submodules: %w", err)
}
fmt.Println("\nUpdate complete!") fmt.Println("\nUpdate complete!")
return nil return nil
} }
func updateSubmodules() error {
submoduleCmd := exec.Command("git", "submodule", "update", "--init", "--recursive")
submoduleCmd.Stdout = os.Stdout
submoduleCmd.Stderr = os.Stderr
return submoduleCmd.Run()
}
func offerReclone(dmsPath string) bool { func offerReclone(dmsPath string) bool {
fmt.Println("\nWould you like to backup and re-clone the repository? (y/N): ") fmt.Println("\nWould you like to backup and re-clone the repository? (y/N): ")
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
@@ -385,7 +342,7 @@ func offerReclone(dmsPath string) bool {
} }
fmt.Println("Cloning fresh copy...") fmt.Println("Cloning fresh copy...")
cloneCmd := exec.Command("git", "clone", "--recurse-submodules", "https://github.com/duxlabs-dev/dux-shell.git", dmsPath) cloneCmd := exec.Command("git", "clone", "https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath)
cloneCmd.Stdout = os.Stdout cloneCmd.Stdout = os.Stdout
cloneCmd.Stderr = os.Stderr cloneCmd.Stderr = os.Stderr
if err := cloneCmd.Run(); err != nil { if err := cloneCmd.Run(); err != nil {
@@ -436,11 +393,27 @@ func updateDMSBinary() error {
} }
fmt.Println("Fetching latest release version...") fmt.Println("Fetching latest release version...")
version, err := netfetch.LatestReleaseTag(context.Background()) cmd := exec.Command("curl", "-s", "https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest")
output, err := cmd.Output()
if err != nil { if err != nil {
return fmt.Errorf("failed to fetch latest release: %w", err) return fmt.Errorf("failed to fetch latest release: %w", err)
} }
version := ""
for line := range strings.SplitSeq(string(output), "\n") {
if strings.Contains(line, "\"tag_name\"") {
parts := strings.Split(line, "\"")
if len(parts) >= 4 {
version = parts[3]
break
}
}
}
if version == "" {
return fmt.Errorf("could not determine latest version")
}
fmt.Printf("Latest version: %s\n", version) fmt.Printf("Latest version: %s\n", version)
tempDir, err := os.MkdirTemp("", "dms-update-*") tempDir, err := os.MkdirTemp("", "dms-update-*")
@@ -449,19 +422,21 @@ func updateDMSBinary() error {
} }
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
binaryURL := fmt.Sprintf("https://github.com/duxlabs-dev/dux-shell/releases/download/%s/dms-cli-%s.gz", version, arch) binaryURL := fmt.Sprintf("https://github.com/AvengeMedia/DankMaterialShell/releases/download/%s/dms-cli-%s.gz", version, arch)
checksumURL := fmt.Sprintf("https://github.com/duxlabs-dev/dux-shell/releases/download/%s/dms-cli-%s.gz.sha256", version, arch) checksumURL := fmt.Sprintf("https://github.com/AvengeMedia/DankMaterialShell/releases/download/%s/dms-cli-%s.gz.sha256", version, arch)
binaryPath := filepath.Join(tempDir, "dms.gz") binaryPath := filepath.Join(tempDir, "dms.gz")
checksumPath := filepath.Join(tempDir, "dms.gz.sha256") checksumPath := filepath.Join(tempDir, "dms.gz.sha256")
fmt.Println("Downloading dms binary...") fmt.Println("Downloading dms binary...")
if err := netfetch.ToFile(context.Background(), binaryURL, netfetch.Options{Timeout: 5 * time.Minute}, binaryPath); err != nil { downloadCmd := exec.Command("curl", "-L", binaryURL, "-o", binaryPath)
if err := downloadCmd.Run(); err != nil {
return fmt.Errorf("failed to download binary: %w", err) return fmt.Errorf("failed to download binary: %w", err)
} }
fmt.Println("Downloading checksum...") fmt.Println("Downloading checksum...")
if err := netfetch.ToFile(context.Background(), checksumURL, netfetch.Options{Timeout: 30 * time.Second}, checksumPath); err != nil { downloadCmd = exec.Command("curl", "-L", checksumURL, "-o", checksumPath)
if err := downloadCmd.Run(); err != nil {
return fmt.Errorf("failed to download checksum: %w", err) return fmt.Errorf("failed to download checksum: %w", err)
} }
File diff suppressed because it is too large Load Diff
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"errors"
"reflect"
"strings"
"testing"
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
"github.com/spf13/cobra"
)
func TestSyncGreeterConfigsAndAuthDelegatesSharedAuth(t *testing.T) {
origGreeterConfigSyncFn := greeterConfigSyncFn
origSharedAuthSyncFn := sharedAuthSyncFn
t.Cleanup(func() {
greeterConfigSyncFn = origGreeterConfigSyncFn
sharedAuthSyncFn = origSharedAuthSyncFn
})
var calls []string
greeterConfigSyncFn = func(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
if dmsPath != "/tmp/dms" {
t.Fatalf("unexpected dmsPath %q", dmsPath)
}
if compositor != "niri" {
t.Fatalf("unexpected compositor %q", compositor)
}
if sudoPassword != "" {
t.Fatalf("expected empty sudoPassword, got %q", sudoPassword)
}
calls = append(calls, "configs")
return nil
}
var gotOptions sharedpam.SyncAuthOptions
sharedAuthSyncFn = func(logFunc func(string), sudoPassword string, options sharedpam.SyncAuthOptions) error {
if sudoPassword != "" {
t.Fatalf("expected empty sudoPassword, got %q", sudoPassword)
}
gotOptions = options
calls = append(calls, "auth")
return nil
}
err := syncGreeterConfigsAndAuth("/tmp/dms", "niri", func(string) {}, sharedpam.SyncAuthOptions{
ForceGreeterAuth: true,
}, func() {
calls = append(calls, "before-auth")
})
if err != nil {
t.Fatalf("syncGreeterConfigsAndAuth returned error: %v", err)
}
wantCalls := []string{"configs", "before-auth", "auth"}
if !reflect.DeepEqual(calls, wantCalls) {
t.Fatalf("call order = %v, want %v", calls, wantCalls)
}
if !gotOptions.ForceGreeterAuth {
t.Fatalf("expected ForceGreeterAuth to be true, got %+v", gotOptions)
}
}
func TestSyncGreeterConfigsAndAuthStopsOnConfigError(t *testing.T) {
origGreeterConfigSyncFn := greeterConfigSyncFn
origSharedAuthSyncFn := sharedAuthSyncFn
t.Cleanup(func() {
greeterConfigSyncFn = origGreeterConfigSyncFn
sharedAuthSyncFn = origSharedAuthSyncFn
})
greeterConfigSyncFn = func(string, string, func(string), string) error {
return errors.New("config sync failed")
}
authCalled := false
sharedAuthSyncFn = func(func(string), string, sharedpam.SyncAuthOptions) error {
authCalled = true
return nil
}
err := syncGreeterConfigsAndAuth("/tmp/dms", "niri", func(string) {}, sharedpam.SyncAuthOptions{}, nil)
if err == nil || err.Error() != "config sync failed" {
t.Fatalf("expected config sync error, got %v", err)
}
if authCalled {
t.Fatal("expected auth sync not to run after config sync failure")
}
}
func TestGreeterStatusStateDirUsesNixOSDefault(t *testing.T) {
if got := greeterStatusStateDir("", true); got != nixOSGreeterStateDir {
t.Fatalf("greeterStatusStateDir() = %q, want %q", got, nixOSGreeterStateDir)
}
}
func TestGreeterStatusStateDirHonorsExplicitOverrideOnNixOS(t *testing.T) {
command := "dms-greeter --cache-dir /srv/dms-greeter --command niri"
if got := greeterStatusStateDir(command, true); got != "/srv/dms-greeter" {
t.Fatalf("greeterStatusStateDir() = %q, want %q", got, "/srv/dms-greeter")
}
}
func TestRejectNixOSGreeterMutationBlocksImperativeCommands(t *testing.T) {
origGreeterIsNixOSFn := greeterIsNixOSFn
greeterIsNixOSFn = func() bool { return true }
t.Cleanup(func() {
greeterIsNixOSFn = origGreeterIsNixOSFn
})
for _, commandName := range []string{"install", "enable", "sync", "uninstall"} {
t.Run(commandName, func(t *testing.T) {
root := &cobra.Command{Use: "dms"}
greeterCommand := &cobra.Command{Use: "greeter"}
mutationCommand := &cobra.Command{Use: commandName}
root.AddCommand(greeterCommand)
greeterCommand.AddCommand(mutationCommand)
err := rejectNixOSGreeterMutation(mutationCommand)
if err == nil {
t.Fatalf("expected NixOS greeter %s to be rejected", commandName)
}
if !strings.Contains(err.Error(), "dms greeter "+commandName+" is disabled on NixOS") {
t.Fatalf("unexpected error: %v", err)
}
if strings.Contains(err.Error(), "/var/cache/dms-greeter") {
t.Fatalf("NixOS remediation should not recommend the non-NixOS cache path: %v", err)
}
})
}
}
func TestRejectNixOSGreeterMutationAllowsOtherDistros(t *testing.T) {
origGreeterIsNixOSFn := greeterIsNixOSFn
greeterIsNixOSFn = func() bool { return false }
t.Cleanup(func() {
greeterIsNixOSFn = origGreeterIsNixOSFn
})
if err := rejectNixOSGreeterMutation(&cobra.Command{Use: "sync"}); err != nil {
t.Fatalf("expected non-NixOS greeter command to be allowed, got %v", err)
}
}
-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))
}
}
+83 -139
View File
@@ -2,18 +2,13 @@ package main
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"maps"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strconv"
"github.com/duxlabs-dev/dux-shell/core/internal/keybinds" "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
"github.com/duxlabs-dev/dux-shell/core/internal/keybinds/providers" "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/wayland/keymap"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -51,7 +46,7 @@ var keybindsSetCmd = &cobra.Command{
Short: "Set a keybind override", Short: "Set a keybind override",
Long: "Create or update a keybind override for the specified provider", Long: "Create or update a keybind override for the specified provider",
Args: cobra.ExactArgs(3), Args: cobra.ExactArgs(3),
Run: runKeybindsEdit, Run: runKeybindsSet,
} }
var keybindsRemoveCmd = &cobra.Command{ var keybindsRemoveCmd = &cobra.Command{
@@ -59,7 +54,7 @@ var keybindsRemoveCmd = &cobra.Command{
Short: "Remove a keybind", Short: "Remove a keybind",
Long: "Remove a keybind. For Hyprland this writes a negative override to dms/binds-user.lua so the key stays unbound across DMS updates. For other providers it deletes the entry from the managed file.", Long: "Remove a keybind. For Hyprland this writes a negative override to dms/binds-user.lua so the key stays unbound across DMS updates. For other providers it deletes the entry from the managed file.",
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
Run: runKeybindsEdit, Run: runKeybindsRemove,
} }
var keybindsResetCmd = &cobra.Command{ var keybindsResetCmd = &cobra.Command{
@@ -67,23 +62,10 @@ var keybindsResetCmd = &cobra.Command{
Short: "Reset a keybind override to its DMS default", Short: "Reset a keybind override to its DMS default",
Long: "Drop the user override for the given key so the DMS default re-applies. For providers without a separate default file (Niri, MangoWC) this is equivalent to remove.", Long: "Drop the user override for the given key so the DMS default re-applies. For providers without a separate default file (Niri, MangoWC) this is equivalent to remove.",
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
Run: runKeybindsEdit, Run: runKeybindsReset,
}
var keybindsKeymapCmd = &cobra.Command{
Use: "keymap",
Short: "Report which keysym each physical key carries on the first level",
Long: "Print the keysyms the active keyboard layout puts on the first level of every physical key, keyed by xkb keycode.\n\n" +
"Compositors resolve bind keysyms at that level, so a keysym missing from this list cannot be bound however many modifiers are pressed. " +
"The keybind editor uses it to warn instead of storing a bind that never fires.",
Run: runKeybindsKeymap,
} }
func init() { func init() {
keybindsCmd.PersistentFlags().String("expected-generation", "", "Configuration generation observed when editing Aqueous bindings")
for _, command := range []*cobra.Command{keybindsSetCmd, keybindsRemoveCmd, keybindsResetCmd} {
command.Flags().Bool("json", false, "Return structured mutation results, including errors")
}
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON") keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider") keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay") keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay")
@@ -92,14 +74,13 @@ func init() {
keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat") keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat")
keybindsSetCmd.Flags().Bool("no-inhibiting", false, "Keep bind active when shortcuts are inhibited (allow-inhibiting=false)") keybindsSetCmd.Flags().Bool("no-inhibiting", false, "Keep bind active when shortcuts are inhibited (allow-inhibiting=false)")
keybindsSetCmd.Flags().String("replace-key", "", "Original key to replace (removes old key)") keybindsSetCmd.Flags().String("replace-key", "", "Original key to replace (removes old key)")
keybindsSetCmd.Flags().String("flags", "", "Hyprland bind flags as letters: e repeat, l locked, r release, o long-press, n non-consuming, m mouse, t transparent, i ignore-mods, u submap-universal, a auto-consuming, x allow-input-capture") keybindsSetCmd.Flags().String("flags", "", "Hyprland bind flags (e.g., 'e' for repeat, 'l' for locked, 'r' for release)")
keybindsCmd.AddCommand(keybindsListCmd) keybindsCmd.AddCommand(keybindsListCmd)
keybindsCmd.AddCommand(keybindsShowCmd) keybindsCmd.AddCommand(keybindsShowCmd)
keybindsCmd.AddCommand(keybindsSetCmd) keybindsCmd.AddCommand(keybindsSetCmd)
keybindsCmd.AddCommand(keybindsRemoveCmd) keybindsCmd.AddCommand(keybindsRemoveCmd)
keybindsCmd.AddCommand(keybindsResetCmd) keybindsCmd.AddCommand(keybindsResetCmd)
keybindsCmd.AddCommand(keybindsKeymapCmd)
keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) { keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) {
return providers.NewJSONFileProvider(filePath) return providers.NewJSONFileProvider(filePath)
@@ -112,9 +93,6 @@ func initializeProviders() {
registry := keybinds.GetDefaultRegistry() registry := keybinds.GetDefaultRegistry()
hyprlandProvider := providers.NewHyprlandProvider("") hyprlandProvider := providers.NewHyprlandProvider("")
if err := registry.Register(providers.NewAqueousProvider()); err != nil {
log.Warnf("Failed to register Aqueous provider: %v", err)
}
if err := registry.Register(hyprlandProvider); err != nil { if err := registry.Register(hyprlandProvider); err != nil {
log.Warnf("Failed to register Hyprland provider: %v", err) log.Warnf("Failed to register Hyprland provider: %v", err)
} }
@@ -177,33 +155,6 @@ func runKeybindsList(cmd *cobra.Command, _ []string) {
} }
} }
// runKeybindsKeymap answers with empty fields rather than an error when there
// is no Wayland seat to ask, so the caller can simply skip the check instead
// of having to tell "unknown layout" apart from "nothing wrong".
func runKeybindsKeymap(_ *cobra.Command, _ []string) {
keysyms := map[string][]string{}
named := []string{}
km, err := keymap.FromSeat()
if err != nil {
log.Warnf("Failed to read the seat keymap: %v", err)
} else {
for code, syms := range km.Level1ByXkbKeycode() {
keysyms[strconv.FormatUint(uint64(code), 10)] = syms
}
// The vocabulary the keysyms above are drawn from, so a caller can
// tell a keysym we could not name from one the layout is missing.
named = slices.Compact(slices.Sorted(maps.Values(keymap.KeysymNames)))
}
output, err := json.Marshal(map[string]any{"keysyms": keysyms, "named": named})
if err != nil {
log.Warnf("Failed to encode keymap: %v", err)
return
}
fmt.Fprintln(os.Stdout, string(output))
}
func makeProviderWithPath(name, path string) keybinds.Provider { func makeProviderWithPath(name, path string) keybinds.Provider {
switch name { switch name {
case "hyprland": case "hyprland":
@@ -255,92 +206,85 @@ func runKeybindsShow(cmd *cobra.Command, args []string) {
printCheatSheet(provider) printCheatSheet(provider)
} }
func keybindEditFailure(err error) map[string]any { func getWritableProvider(name string) keybinds.WritableProvider {
code := "command_failed" provider, err := keybinds.GetDefaultRegistry().Get(name)
var helperError *providers.AqueousError
if errors.As(err, &helperError) {
code = helperError.Code
}
return map[string]any{"success": false, "code": code, "message": err.Error()}
}
func runKeybindsEdit(cmd *cobra.Command, args []string) {
result, err := editKeybind(cmd, args)
if err == nil {
_ = json.NewEncoder(cmd.OutOrStdout()).Encode(result)
return
}
structured, _ := cmd.Flags().GetBool("json")
if !structured {
log.Fatalf("Failed to save keybind: %v", err)
return
}
_ = json.NewEncoder(cmd.OutOrStdout()).Encode(keybindEditFailure(err))
os.Exit(1)
}
func editKeybind(cmd *cobra.Command, args []string) (map[string]any, error) {
provider, err := keybinds.GetDefaultRegistry().Get(args[0])
if err != nil { if err != nil {
return nil, err log.Fatalf("Error: %v", err)
}
key := args[1]
if aqueous, ok := provider.(*providers.AqueousProvider); ok {
generation, _ := cmd.Flags().GetString("expected-generation")
edit := providers.AqueousBindEdit{Generation: generation, Key: key, Remove: cmd.Name() != "set"}
if !edit.Remove {
for _, flag := range []string{"desc", "allow-when-locked", "cooldown-ms", "no-repeat", "no-inhibiting", "flags"} {
if cmd.Flags().Changed(flag) {
return nil, fmt.Errorf("aqueous does not support --%s in this provider", flag)
}
}
edit.Action = args[2]
edit.OriginalKey, _ = cmd.Flags().GetString("replace-key")
}
result, err := aqueous.Edit(cmd.Context(), edit)
if err != nil {
return nil, err
}
return map[string]any{"success": true, "code": "applied", "generation": result.String("generation"), "key": key}, nil
} }
writable, ok := provider.(keybinds.WritableProvider) writable, ok := provider.(keybinds.WritableProvider)
if !ok { if !ok {
return nil, fmt.Errorf("provider %s does not support writing keybinds", args[0]) log.Fatalf("Provider %s does not support writing keybinds", name)
} }
result := map[string]any{"success": true, "key": key} return writable
switch cmd.Name() { }
case "set":
options := make(map[string]any) func runKeybindsSet(cmd *cobra.Command, args []string) {
if v, _ := cmd.Flags().GetBool("allow-when-locked"); v { providerName, key, action := args[0], args[1], args[2]
options["allow-when-locked"] = true writable := getWritableProvider(providerName)
}
if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 { if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key {
options["cooldown-ms"] = v _ = writable.RemoveBind(replaceKey)
} }
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
options["repeat"] = false options := make(map[string]any)
} if v, _ := cmd.Flags().GetBool("allow-when-locked"); v {
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v { options["allow-when-locked"] = true
options["allow-inhibiting"] = false }
} if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 {
if v, _ := cmd.Flags().GetString("flags"); v != "" { options["cooldown-ms"] = v
options["flags"] = v }
} if v, _ := cmd.Flags().GetBool("no-repeat"); v {
if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key { options["repeat"] = false
_ = writable.RemoveBind(replaceKey) }
} if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
desc, _ := cmd.Flags().GetString("desc") options["allow-inhibiting"] = false
err = writable.SetBind(key, args[2], desc, options) }
result["action"] = args[2] if v, _ := cmd.Flags().GetString("flags"); v != "" {
result["path"] = writable.GetOverridePath() options["flags"] = v
case "remove": }
err = writable.RemoveBind(key)
result["removed"] = true desc, _ := cmd.Flags().GetString("desc")
case "reset": if err := writable.SetBind(key, action, desc, options); err != nil {
err = writable.ResetBind(key) log.Fatalf("Error setting keybind: %v", err)
result["reset"] = true }
default:
return nil, fmt.Errorf("unsupported keybind operation: %s", cmd.Name()) output, _ := json.MarshalIndent(map[string]any{
} "success": true,
return result, err "key": key,
"action": action,
"path": writable.GetOverridePath(),
}, "", " ")
fmt.Fprintln(os.Stdout, string(output))
}
func runKeybindsRemove(_ *cobra.Command, args []string) {
providerName, key := args[0], args[1]
writable := getWritableProvider(providerName)
if err := writable.RemoveBind(key); err != nil {
log.Fatalf("Error removing keybind: %v", err)
}
output, _ := json.MarshalIndent(map[string]any{
"success": true,
"key": key,
"removed": true,
}, "", " ")
fmt.Fprintln(os.Stdout, string(output))
}
func runKeybindsReset(_ *cobra.Command, args []string) {
providerName, key := args[0], args[1]
writable := getWritableProvider(providerName)
if err := writable.ResetBind(key); err != nil {
log.Fatalf("Error resetting keybind: %v", err)
}
output, _ := json.MarshalIndent(map[string]any{
"success": true,
"key": key,
"reset": true,
}, "", " ")
fmt.Fprintln(os.Stdout, string(output))
} }
-25
View File
@@ -1,25 +0,0 @@
package main
import (
"errors"
"fmt"
"testing"
"github.com/duxlabs-dev/dux-shell/core/internal/keybinds/providers"
)
func TestKeybindEditFailure(t *testing.T) {
for _, tc := range []struct {
err error
code string
}{
{&providers.AqueousError{Code: "external_change", Message: "changed"}, "external_change"},
{fmt.Errorf("helper: %w", &providers.AqueousError{Code: "read_only", Message: "denied"}), "read_only"},
{errors.New("transport failed"), "command_failed"},
} {
result := keybindEditFailure(tc.err)
if result["success"] != false || result["code"] != tc.code || result["message"] != tc.err.Error() {
t.Fatalf("unexpected failure result: %#v", result)
}
}
}
+7 -49
View File
@@ -8,9 +8,9 @@ import (
"os" "os"
"time" "time"
"github.com/duxlabs-dev/dux-go/ipc" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
"github.com/duxlabs-dev/dux-shell/core/internal/matugen" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -44,18 +44,11 @@ var matugenPreviewCmd = &cobra.Command{
Run: runMatugenPreview, Run: runMatugenPreview,
} }
var matugenQtengineCmd = &cobra.Command{
Use: "qtengine",
Short: "Sync qtengine's config with the current DMS theme",
Run: runMatugenQtengine,
}
func init() { func init() {
matugenCmd.AddCommand(matugenGenerateCmd) matugenCmd.AddCommand(matugenGenerateCmd)
matugenCmd.AddCommand(matugenQueueCmd) matugenCmd.AddCommand(matugenQueueCmd)
matugenCmd.AddCommand(matugenCheckCmd) matugenCmd.AddCommand(matugenCheckCmd)
matugenCmd.AddCommand(matugenPreviewCmd) matugenCmd.AddCommand(matugenPreviewCmd)
matugenCmd.AddCommand(matugenQtengineCmd)
for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} { for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} {
cmd.Flags().String("state-dir", "", "State directory for cache files") cmd.Flags().String("state-dir", "", "State directory for cache files")
@@ -63,30 +56,21 @@ func init() {
cmd.Flags().String("config-dir", "", "User config directory") cmd.Flags().String("config-dir", "", "User config directory")
cmd.Flags().String("kind", "image", "Source type: image or hex") cmd.Flags().String("kind", "image", "Source type: image or hex")
cmd.Flags().String("value", "", "Wallpaper path or hex color") cmd.Flags().String("value", "", "Wallpaper path or hex color")
cmd.Flags().String("mode", "dark", "Color mode: dark, light, or smart (smart requires matugen 4.2+ and an image source)") cmd.Flags().String("mode", "dark", "Color mode: dark or light")
cmd.Flags().String("icon-theme", "System Default", "Icon theme name") cmd.Flags().String("icon-theme", "System Default", "Icon theme name")
cmd.Flags().String("matugen-type", "scheme-tonal-spot", "Matugen scheme type (supports scheme-smart on matugen 4.2+)") cmd.Flags().String("matugen-type", "scheme-tonal-spot", "Matugen scheme type")
cmd.Flags().Bool("run-user-templates", true, "Run user matugen templates") cmd.Flags().Bool("run-user-templates", true, "Run user matugen templates")
cmd.Flags().String("stock-colors", "", "Stock theme colors JSON") cmd.Flags().String("stock-colors", "", "Stock theme colors JSON")
cmd.Flags().Bool("sync-mode-with-portal", false, "Sync color scheme with GNOME portal") cmd.Flags().Bool("sync-mode-with-portal", false, "Sync color scheme with GNOME portal")
cmd.Flags().Bool("terminals-always-dark", false, "Force terminal themes to dark variant") cmd.Flags().Bool("terminals-always-dark", false, "Force terminal themes to dark variant")
cmd.Flags().Int("terminal-opacity", 0, "Terminal background opacity percent, 1-99 (0 or 100 leaves the terminal's own setting)")
cmd.Flags().String("terminal-palette", "", "Terminal palette: default, low, medium or high")
cmd.Flags().String("skip-templates", "", "Comma-separated list of templates to skip") cmd.Flags().String("skip-templates", "", "Comma-separated list of templates to skip")
cmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)") cmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
cmd.Flags().String("source-mode", "", "Source color selection: dominant, colorful, darkness, lightness, saturation, less-saturation, value")
cmd.Flags().String("seed-color", "", "Hex color to build the palette from instead of extracting one from the wallpaper")
cmd.Flags().String("spec", "", "Material color spec: 2021 (matugen native, default), 2025 (Material 3 Expressive) or dms (2025 accents on neutral surfaces)")
} }
matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion") matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion")
matugenQueueCmd.Flags().Duration("timeout", 90*time.Second, "Timeout for waiting") matugenQueueCmd.Flags().Duration("timeout", 90*time.Second, "Timeout for waiting")
matugenPreviewCmd.Flags().String("source-color", "", "Source color used to generate previews") matugenPreviewCmd.Flags().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().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")
} }
func buildMatugenOptions(cmd *cobra.Command) matugen.Options { func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
@@ -102,13 +86,8 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
stockColors, _ := cmd.Flags().GetString("stock-colors") stockColors, _ := cmd.Flags().GetString("stock-colors")
syncModeWithPortal, _ := cmd.Flags().GetBool("sync-mode-with-portal") syncModeWithPortal, _ := cmd.Flags().GetBool("sync-mode-with-portal")
terminalsAlwaysDark, _ := cmd.Flags().GetBool("terminals-always-dark") terminalsAlwaysDark, _ := cmd.Flags().GetBool("terminals-always-dark")
terminalOpacity, _ := cmd.Flags().GetInt("terminal-opacity")
terminalPalette, _ := cmd.Flags().GetString("terminal-palette")
skipTemplates, _ := cmd.Flags().GetString("skip-templates") skipTemplates, _ := cmd.Flags().GetString("skip-templates")
contrast, _ := cmd.Flags().GetFloat64("contrast") contrast, _ := cmd.Flags().GetFloat64("contrast")
sourceMode, _ := cmd.Flags().GetString("source-mode")
seedColor, _ := cmd.Flags().GetString("seed-color")
spec, _ := cmd.Flags().GetString("spec")
return matugen.Options{ return matugen.Options{
StateDir: stateDir, StateDir: stateDir,
@@ -124,12 +103,7 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
StockColors: stockColors, StockColors: stockColors,
SyncModeWithPortal: syncModeWithPortal, SyncModeWithPortal: syncModeWithPortal,
TerminalsAlwaysDark: terminalsAlwaysDark, TerminalsAlwaysDark: terminalsAlwaysDark,
TerminalOpacity: terminalOpacity,
TerminalPalette: terminalPalette,
SkipTemplates: skipTemplates, SkipTemplates: skipTemplates,
SourceMode: sourceMode,
SeedColor: seedColor,
Spec: spec,
} }
} }
@@ -149,7 +123,7 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
wait, _ := cmd.Flags().GetBool("wait") wait, _ := cmd.Flags().GetBool("wait")
timeout, _ := cmd.Flags().GetDuration("timeout") timeout, _ := cmd.Flags().GetDuration("timeout")
request := ipc.Request{ request := models.Request{
ID: 1, ID: 1,
Method: "matugen.queue", Method: "matugen.queue",
Params: map[string]any{ Params: map[string]any{
@@ -165,13 +139,8 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
"stockColors": opts.StockColors, "stockColors": opts.StockColors,
"syncModeWithPortal": opts.SyncModeWithPortal, "syncModeWithPortal": opts.SyncModeWithPortal,
"terminalsAlwaysDark": opts.TerminalsAlwaysDark, "terminalsAlwaysDark": opts.TerminalsAlwaysDark,
"terminalOpacity": opts.TerminalOpacity,
"terminalPalette": opts.TerminalPalette,
"skipTemplates": opts.SkipTemplates, "skipTemplates": opts.SkipTemplates,
"contrast": opts.Contrast, "contrast": opts.Contrast,
"sourceMode": opts.SourceMode,
"seedColor": opts.SeedColor,
"spec": opts.Spec,
"wait": wait, "wait": wait,
}, },
} }
@@ -243,10 +212,8 @@ func runMatugenCheck(cmd *cobra.Command, args []string) {
func runMatugenPreview(cmd *cobra.Command, args []string) { func runMatugenPreview(cmd *cobra.Command, args []string) {
sourceColor, _ := cmd.Flags().GetString("source-color") sourceColor, _ := cmd.Flags().GetString("source-color")
imagePath, _ := cmd.Flags().GetString("image")
contrast, _ := cmd.Flags().GetFloat64("contrast") contrast, _ := cmd.Flags().GetFloat64("contrast")
spec, _ := cmd.Flags().GetString("spec") previews, err := matugen.PreviewSchemes(sourceColor, contrast)
previews, err := matugen.PreviewSchemes(sourceColor, contrast, imagePath, spec)
if err != nil { if err != nil {
log.Fatalf("Failed to generate Matugen previews: %v", err) log.Fatalf("Failed to generate Matugen previews: %v", err)
} }
@@ -256,12 +223,3 @@ func runMatugenPreview(cmd *cobra.Command, args []string) {
} }
fmt.Println(string(data)) fmt.Println(string(data))
} }
func runMatugenQtengine(cmd *cobra.Command, args []string) {
configDir, _ := cmd.Flags().GetString("config-dir")
iconTheme, _ := cmd.Flags().GetString("icon-theme")
if err := matugen.SyncQtengineConfigAt(configDir, iconTheme); err != nil {
log.Fatalf("Failed to sync qtengine config: %v", err)
}
}
+26 -257
View File
@@ -3,50 +3,29 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"strconv"
"strings"
"time"
"github.com/duxlabs-dev/dux-go/ipc" "github.com/AvengeMedia/DankMaterialShell/core/internal/notify"
"github.com/duxlabs-dev/dux-shell/core/internal/notify"
"github.com/godbus/dbus/v5"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag"
) )
var ( var (
notifyAppName string notifyAppName string
notifyAppIcon string notifyIcon string
notifyIcon string notifyFile string
notifyFile string notifyTimeout int
notifyTimeout int
notifyUrgency string
notifyCategory string
notifyTransient bool
notifyHints []string
notifyActions []string
notifyPrintID bool
notifyReplaceID uint32
notifyWait bool
notifyIDFd int
notifyActionFd int
) )
var notifyCmd = &cobra.Command{ var notifyCmd = &cobra.Command{
Use: "notify <summary> [body]", Use: "notify <summary> [body]",
Short: "Send a desktop notification", Short: "Send a desktop notification",
Long: `Send a desktop notification. Flags follow notify-send. Long: `Send a desktop notification with optional actions.
If --file is provided, the notification gets "Open" and "Open Folder" actions If --file is provided, the notification will have "Open" and "Open Folder" actions.
that open the file or its directory when clicked.
Examples: Examples:
dms notify "Hello" "World" dms notify "Hello" "World"
dms notify -u critical -t 0 "Disk almost full" dms notify "File received" "photo.jpg" --file ~/Downloads/photo.jpg --icon smartphone
dms notify -a "Backup" -i drive-harddisk -c transfer.complete "Backup finished" dms notify "Download complete" --file ~/Downloads/file.zip --app "My App"`,
dms notify "File received" "photo.jpg" --file ~/Downloads/photo.jpg
dms notify -A retry=Retry -A "Dismiss" "Build failed"
dms notify -h string:x-dms-tag:build -h int:value:42 "Hinted"`,
Args: cobra.MinimumNArgs(1), Args: cobra.MinimumNArgs(1),
Run: runNotify, Run: runNotify,
} }
@@ -59,241 +38,31 @@ var genericNotifyActionCmd = &cobra.Command{
}, },
} }
var notifyFlagAliases = map[string]string{
"app": "app-name",
"timeout": "expire-time",
}
func init() { func init() {
f := notifyCmd.Flags() notifyCmd.Flags().StringVar(&notifyAppName, "app", "DMS", "Application name")
f.SetNormalizeFunc(func(fs *pflag.FlagSet, name string) pflag.NormalizedName { notifyCmd.Flags().StringVar(&notifyIcon, "icon", "", "Icon name or path")
if alias, ok := notifyFlagAliases[name]; ok { notifyCmd.Flags().StringVar(&notifyFile, "file", "", "File path (enables Open/Open Folder actions)")
return pflag.NormalizedName(alias) notifyCmd.Flags().IntVar(&notifyTimeout, "timeout", 5000, "Timeout in milliseconds")
}
return pflag.NormalizedName(name)
})
// Frees -h for --hint like notify-send; cobra skips its own help flag when one exists.
f.Bool("help", false, "Show help")
f.StringVarP(&notifyUrgency, "urgency", "u", "", "Urgency level: low, normal, critical")
f.IntVarP(&notifyTimeout, "expire-time", "t", -1, "Timeout in milliseconds (0 never expires, -1 server default)")
f.StringVarP(&notifyAppName, "app-name", "a", "DMS", "Application name")
f.StringVarP(&notifyIcon, "icon", "i", "", "Icon name or path")
f.StringVarP(&notifyAppIcon, "app-icon", "n", "", "Application icon name or path; --icon then becomes the image")
f.StringVar(&notifyCategory, "category", "", "Notification category, comma separated (-c is taken by the global --config)")
f.BoolVarP(&notifyTransient, "transient", "e", false, "Do not keep the notification in history")
f.StringArrayVarP(&notifyHints, "hint", "h", nil, "Extra hint as TYPE:NAME:VALUE (boolean, int, double, string, byte)")
f.BoolVarP(&notifyPrintID, "print-id", "p", false, "Print the notification id")
f.IntVar(&notifyIDFd, "id-fd", -1, "File descriptor to write the notification id to")
f.Uint32VarP(&notifyReplaceID, "replace-id", "r", 0, "Id of the notification to replace")
f.BoolVarP(&notifyWait, "wait", "w", false, "Wait until the notification closes")
f.StringArrayVarP(&notifyActions, "action", "A", nil, "Action as [NAME=]Text; implies --wait, prints the chosen NAME (repeatable)")
f.IntVar(&notifyActionFd, "selected-action-fd", -1, "File descriptor to write the chosen action to")
f.StringVar(&notifyFile, "file", "", "File path (enables Open/Open Folder actions)")
} }
func runNotify(cmd *cobra.Command, args []string) { func runNotify(cmd *cobra.Command, args []string) {
n, err := buildNotification(args) summary := args[0]
if err != nil { body := ""
fail(err)
}
wait := notifyWait || len(n.Actions) > 0
var waiter *notify.Waiter
if wait {
waiter, err = notify.NewWaiter()
if err != nil {
fail(err)
}
defer waiter.Close()
}
id, err := notify.Send(n)
if err != nil {
fail(err)
}
reportID(id)
if n.FilePath != "" {
watchNotificationAction(id, n.FilePath)
}
if !wait {
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 { if len(args) > 1 {
n.Body = args[1] 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) { n := notify.Notification{
switch strings.ToLower(s) { AppName: notifyAppName,
case "low", "0": Icon: notifyIcon,
return 0, nil Summary: summary,
case "normal", "1": Body: body,
return 1, nil FilePath: notifyFile,
case "critical", "2": Timeout: int32(notifyTimeout),
return 2, nil
} }
return 0, fmt.Errorf("invalid urgency %q: use low, normal or critical", s)
}
func parseHint(spec string) (string, dbus.Variant, error) { if err := notify.Send(n); err != nil {
parts := strings.SplitN(spec, ":", 3) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
if len(parts) != 3 || parts[1] == "" { os.Exit(1)
return "", dbus.Variant{}, fmt.Errorf("invalid hint %q: expected TYPE:NAME:VALUE", spec)
} }
typ, name, value := strings.ToLower(parts[0]), parts[1], parts[2]
switch typ {
case "string":
return name, dbus.MakeVariant(value), nil
case "int":
v, err := strconv.ParseInt(value, 10, 32)
if err != nil {
return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid int %q", name, value)
}
return name, dbus.MakeVariant(int32(v)), nil
case "double":
v, err := strconv.ParseFloat(value, 64)
if err != nil {
return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid double %q", name, value)
}
return name, dbus.MakeVariant(v), nil
case "byte":
v, err := strconv.ParseUint(value, 0, 8)
if err != nil {
return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid byte %q", name, value)
}
return name, dbus.MakeVariant(byte(v)), nil
case "boolean":
v, err := parseBoolean(value)
if err != nil {
return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid boolean %q", name, value)
}
return name, dbus.MakeVariant(v), nil
}
return "", dbus.Variant{}, fmt.Errorf("invalid hint type %q: use boolean, int, double, string or byte", parts[0])
}
func parseBoolean(s string) (bool, error) {
switch strings.ToLower(s) {
case "true":
return true, nil
case "false":
return false, nil
}
v, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return false, err
}
return v != 0, nil
}
func parseActions(specs []string) []string {
if len(specs) == 0 {
return nil
}
actions := make([]string, 0, len(specs)*2)
for i, spec := range specs {
name, label, hasName := strings.Cut(spec, "=")
if !hasName {
name, label = strconv.Itoa(i), spec
}
actions = append(actions, name, label)
}
return actions
}
func waitTimeout() time.Duration {
if notifyTimeout <= 0 {
return 0
}
return time.Duration(notifyTimeout) * time.Millisecond
}
func reportID(id uint32) {
if notifyPrintID {
fmt.Println(id)
}
writeFd(notifyIDFd, strconv.FormatUint(uint64(id), 10))
}
func writeFd(fd int, line string) {
if fd < 0 {
return
}
f := os.NewFile(uintptr(fd), "")
if f == nil {
return
}
fmt.Fprintln(f, line)
_ = f.Sync()
}
func fail(err error) {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
func watchNotificationAction(id uint32, path string) {
if id == 0 {
return
}
resp, ok := tryServerRequest(ipc.Request{
Method: "notify.watchAction",
Params: map[string]any{"id": id, "path": path},
})
if ok && resp.Error == "" {
return
}
notify.SpawnActionListener(id, path)
} }
+3 -3
View File
@@ -8,8 +8,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/duxlabs-dev/dux-go/ipc" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -123,7 +123,7 @@ func runOpen(target string) {
params["url"] = target params["url"] = target
} }
req := ipc.Request{ req := models.Request{
ID: 1, ID: 1,
Method: method, Method: method,
Params: params, Params: params,
-265
View File
@@ -1,265 +0,0 @@
package main
import (
"fmt"
"io"
"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/mattn/go-isatty"
"github.com/spf13/cobra"
)
var (
qrEcc string
qrVersion int
qrOutput string
qrStdout bool
qrClipboard bool
qrCopyText bool
qrRender bool
qrNoRender bool
qrInvert bool
qrQuietZone int
qrModuleSize int
qrFg string
qrBg string
qrTransparent bool
qrBorder int
qrShape string
qrLogo string
qrLogoScale int
qrWifiPassword string
qrWifiSecurity string
qrWifiHidden bool
)
var qrCmd = &cobra.Command{
Use: "qr [text]",
Short: "Generate QR codes",
Long: `Generate a QR code from text, stdin, or a WiFi network.
By default the code is rendered to the terminal when stdout is a TTY, or
written as PNG bytes to stdout when piped. Use flags to also copy to the
clipboard, save a PNG, or tune encoding and colors.
Input:
dms qr "https://example.com" # encode an argument
echo -n "data" | dms qr # encode stdin
dms qr - # encode stdin explicitly
Output (combine freely):
dms qr "text" --clipboard # copy PNG image to clipboard
dms qr "text" --copy-text # copy the source text to clipboard
dms qr "text" -o code.png # save a PNG file
dms qr "text" > code.png # PNG to stdout (piped)
dms qr "text" --render # force terminal render
Encoding & style:
--ecc L|M|Q|H # error correction (default M)
--qr-version 10 # force symbol version (1-40)
--module-size 12 --fg '#000' ... # PNG sizing and colors
--shape circle # round modules
--logo icon.png # center logo (bumps --ecc to H)
--invert # flip colors for light terminals
WiFi:
dms qr wifi MySSID -p secret # build from an explicit password
dms qr wifi MySSID # pull the saved secret from the shell`,
Args: cobra.ArbitraryArgs,
Run: runQR,
}
var qrWifiCmd = &cobra.Command{
Use: "wifi <ssid>",
Short: "Generate a WiFi QR code",
Long: `Generate a QR code that joins a WiFi network when scanned.
With --password the code is built entirely offline. Without it, the saved
credentials are fetched from the running DMS shell (like the network panel).`,
Args: cobra.ExactArgs(1),
Run: runQRWifi,
}
func init() {
qrCmd.PersistentFlags().StringVar(&qrEcc, "ecc", "", "Error correction level: L, M, Q, H (default M, or H with --logo)")
qrCmd.PersistentFlags().IntVar(&qrVersion, "qr-version", 0, "Force QR symbol version 1-40 (0 = auto)")
qrCmd.PersistentFlags().StringVarP(&qrOutput, "output", "o", "", "Write a PNG to this file")
qrCmd.PersistentFlags().BoolVar(&qrStdout, "stdout", false, "Write PNG bytes to stdout")
qrCmd.PersistentFlags().BoolVar(&qrClipboard, "clipboard", false, "Copy the PNG image to the clipboard")
qrCmd.PersistentFlags().BoolVar(&qrCopyText, "copy-text", false, "Copy the source text to the clipboard")
qrCmd.PersistentFlags().BoolVar(&qrRender, "render", false, "Force terminal rendering")
qrCmd.PersistentFlags().BoolVar(&qrNoRender, "no-render", false, "Never render to the terminal")
qrCmd.PersistentFlags().BoolVar(&qrInvert, "invert", false, "Swap colors (for light terminals)")
qrCmd.PersistentFlags().IntVar(&qrQuietZone, "quiet-zone", 2, "Terminal margin in modules")
qrCmd.PersistentFlags().IntVar(&qrModuleSize, "module-size", 0, "PNG pixels per module (0 = auto)")
qrCmd.PersistentFlags().StringVar(&qrFg, "fg", "", "Dark module color (#RGB or #RRGGBB)")
qrCmd.PersistentFlags().StringVar(&qrBg, "bg", "", "Light module color (#RGB or #RRGGBB)")
qrCmd.PersistentFlags().BoolVar(&qrTransparent, "transparent", false, "Transparent PNG background")
qrCmd.PersistentFlags().IntVar(&qrBorder, "border", -1, "PNG border in pixels (-1 = auto)")
qrCmd.PersistentFlags().StringVar(&qrShape, "shape", "square", "PNG module shape (square, circle)")
qrCmd.PersistentFlags().StringVar(&qrLogo, "logo", "", "Center a PNG/JPEG logo on the PNG output")
qrCmd.PersistentFlags().IntVar(&qrLogoScale, "logo-scale", 0, "Max logo size as 1/N of the code (0 = library default of 5)")
qrWifiCmd.Flags().StringVarP(&qrWifiPassword, "password", "p", "", "WiFi password (offline build)")
qrWifiCmd.Flags().StringVar(&qrWifiSecurity, "security", "WPA", "Security type (WPA, WEP, nopass)")
qrWifiCmd.Flags().BoolVar(&qrWifiHidden, "hidden", false, "Mark the network as hidden")
qrCmd.AddCommand(qrWifiCmd)
}
func runQR(cmd *cobra.Command, args []string) {
text := strings.Join(args, " ")
if text == "" || text == "-" {
data, err := io.ReadAll(os.Stdin)
if err != nil {
fatalf("Error reading stdin: %v", err)
}
text = strings.TrimRight(string(data), "\n")
}
if text == "" {
fatalf("Error: no input (provide text, pipe stdin, or use a subcommand)")
}
emitQR(text)
}
func runQRWifi(cmd *cobra.Command, args []string) {
ssid := args[0]
if qrWifiPassword != "" || strings.EqualFold(qrWifiSecurity, "nopass") {
emitQR(qrcode.WiFiString(qrWifiSecurity, ssid, qrWifiPassword, qrWifiHidden))
return
}
content, err := fetchWifiQRContent(ssid)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
fmt.Fprintln(os.Stderr, "Hint: pass --password to build the code without the shell.")
os.Exit(1)
}
if qrWifiHidden {
content = strings.TrimSuffix(content, ";") + "H:true;;"
}
emitQR(content)
}
func emitQR(text string) {
renderTerm := shouldRenderTerminal()
pngToStdout := qrStdout || (!renderTerm && qrOutput == "" && !qrClipboard && !qrCopyText)
if pngToStdout || qrOutput != "" || qrClipboard {
png, err := qrcode.RenderPNG(text, qrcode.ImageOptions{
ECC: effectiveEcc(),
Version: qrVersion,
ModuleSize: qrModuleSize,
Fg: qrFg,
Bg: qrBg,
Transparent: qrTransparent,
Border: qrBorder,
Shape: qrShape,
Logo: qrLogo,
LogoScale: qrLogoScale,
})
if err != nil {
fatalf("Error encoding QR: %v", err)
}
emitPNG(png, pngToStdout)
}
if qrCopyText {
if err := clipboard.CopyText(text); err != nil {
fatalf("Error copying text: %v", err)
}
}
if !renderTerm {
return
}
out, err := qrcode.RenderTerminal(text, qrcode.TermOptions{
ECC: effectiveEcc(),
Version: qrVersion,
QuietZone: qrQuietZone,
Invert: qrInvert,
Fg: qrFg,
Bg: qrBg,
})
if err != nil {
fatalf("Error rendering QR: %v", err)
}
dst := os.Stdout
if pngToStdout {
dst = os.Stderr
}
fmt.Fprint(dst, out)
}
func emitPNG(png []byte, toStdout bool) {
if qrOutput != "" {
if err := os.WriteFile(qrOutput, png, 0o644); err != nil {
fatalf("Error writing file: %v", err)
}
fmt.Fprintln(os.Stderr, qrOutput)
}
if toStdout {
os.Stdout.Write(png)
}
if qrClipboard {
if err := clipboard.Copy(png, "image/png"); err != nil {
fatalf("Error copying image: %v", err)
}
}
}
func shouldRenderTerminal() bool {
switch {
case qrNoRender:
return false
case qrRender:
return true
case qrStdout, qrOutput != "", qrClipboard, qrCopyText:
return false
default:
return isatty.IsTerminal(os.Stdout.Fd())
}
}
func effectiveEcc() string {
switch {
case qrEcc != "":
return qrEcc
case qrLogo != "":
return "H"
default:
return "M"
}
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
func fetchWifiQRContent(ssid string) (string, error) {
resp, err := sendServerRequest(ipc.Request{
ID: 1,
Method: "network.qrcode-content",
Params: map[string]any{"ssid": ssid},
})
if err != nil {
return "", err
}
if resp.Error != "" {
return "", fmt.Errorf("%s", resp.Error)
}
if resp.Result == nil {
return "", fmt.Errorf("empty response")
}
content, ok := (*resp.Result).(string)
if !ok {
return "", fmt.Errorf("unexpected response format")
}
return content, nil
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
-67
View File
@@ -1,67 +0,0 @@
package main
import (
"fmt"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/registries"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
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.",
}
var registryListCmd = &cobra.Command{
Use: "list",
Short: "List configured registries",
Run: func(cmd *cobra.Command, args []string) {
for _, s := range registries.Load(afero.NewOsFs()) {
suffix := ""
if s.Official() {
suffix = " (official)"
}
fmt.Printf("%s%s\n %s\n", s.Name, suffix, s.URL)
}
},
}
var registryAddCmd = &cobra.Command{
Use: "add <name> <url>",
Short: "Add a registry",
Long: "Add a registry by name and git URL. The repository must contain a plugins/ or themes/ directory in the registry format.",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
if err := registries.Add(afero.NewOsFs(), args[0], args[1]); err != nil {
log.Fatalf("Error adding registry: %v", err)
}
fmt.Printf("Registry added: %s\n", args[0])
},
}
var registryRemoveCmd = &cobra.Command{
Use: "remove <name>",
Short: "Remove a registry",
Args: cobra.ExactArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
var names []string
for _, s := range registries.Load(afero.NewOsFs()) {
if !s.Official() {
names = append(names, s.Name)
}
}
return names, cobra.ShellCompDirectiveNoFileComp
},
Run: func(cmd *cobra.Command, args []string) {
if err := registries.Remove(afero.NewOsFs(), args[0]); err != nil {
log.Fatalf("Error removing registry: %v", err)
}
fmt.Printf("Registry removed: %s\n", args[0])
},
}
+61 -2
View File
@@ -1,15 +1,74 @@
package main package main
import ( import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var customConfigPath string
var configPath string
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "dms", Use: "dms",
Short: "dms CLI", Short: "dms CLI",
Long: "dms is the Dux Shell management CLI and backend server.", Long: "dms is the DankMaterialShell management CLI and backend server.",
} }
func init() { func init() {
rootCmd.PersistentFlags().StringVarP(shellApp.CustomConfigVar(), "config", "c", "", "Path to a UI config dir (containing shell.qml) to use instead of the embedded UI (env: DMS_SHELL_DIR)") rootCmd.PersistentFlags().StringVarP(&customConfigPath, "config", "c", "", "Specify a custom path to the DMS config directory")
}
func findConfig(cmd *cobra.Command, args []string) error {
if customConfigPath != "" {
log.Debug("Custom config path provided via -c flag: %s", customConfigPath)
shellPath := filepath.Join(customConfigPath, "shell.qml")
info, statErr := os.Stat(shellPath)
if statErr == nil && !info.IsDir() {
configPath = customConfigPath
log.Debug("Using config from: %s", configPath)
return nil
}
if statErr != nil {
return fmt.Errorf("custom config path error: %w", statErr)
}
return fmt.Errorf("path is a directory, not a file: %s", shellPath)
}
configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path")
if data, readErr := os.ReadFile(configStateFile); readErr == nil {
if len(getAllDMSPIDs()) == 0 {
os.Remove(configStateFile)
} else {
statePath := strings.TrimSpace(string(data))
shellPath := filepath.Join(statePath, "shell.qml")
if info, statErr := os.Stat(shellPath); statErr == nil && !info.IsDir() {
log.Debug("Using config from active session state file: %s", statePath)
configPath = statePath
log.Debug("Using config from: %s", configPath)
return nil
}
os.Remove(configStateFile)
}
}
log.Debug("No custom path or active session, searching default XDG locations...")
var err error
configPath, err = config.LocateDMSConfig()
if err != nil {
return err
}
log.Debug("Using config from: %s", configPath)
return nil
} }
+98 -292
View File
@@ -2,25 +2,20 @@ package main
import ( import (
"bytes" "bytes"
"encoding/binary"
"encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime/debug" "strconv"
"strings" "strings"
"github.com/duxlabs-dev/dux-shell/core/internal/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/duxlabs-dev/dux-shell/core/internal/notify" "github.com/AvengeMedia/DankMaterialShell/core/internal/screenshot"
"github.com/duxlabs-dev/dux-shell/core/internal/qsipc"
"github.com/duxlabs-dev/dux-shell/core/internal/screenshot"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var ( var (
ssOutputName string ssOutputName string
ssSeat string
ssCursor string ssCursor string
ssFormat string ssFormat string
ssQuality int ssQuality int
@@ -32,24 +27,8 @@ var (
ssNoConfirm bool ssNoConfirm bool
ssReset bool ssReset bool
ssStdout bool ssStdout bool
ssJSON bool
ssGeometry bool
ssAllowMulti bool
ssHUD string
) )
type screenshotMetadata struct {
Status string `json:"status"`
Path string `json:"path,omitempty"`
X *int `json:"x,omitempty"`
Y *int `json:"y,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Scale float64 `json:"scale,omitempty"`
Mime string `json:"mime,omitempty"`
Error string `json:"error,omitempty"`
}
var screenshotCmd = &cobra.Command{ var screenshotCmd = &cobra.Command{
Use: "screenshot", Use: "screenshot",
Short: "Capture screenshots", Short: "Capture screenshots",
@@ -60,9 +39,8 @@ Modes:
full - Capture the focused output full - Capture the focused output
all - Capture all outputs combined all - Capture all outputs combined
output - Capture a specific output by name output - Capture a specific output by name
window - Capture the focused window (Hyprland/Mango/niri/Aqueous) window - Capture the focused window (Hyprland/Mango)
last - Capture the last selected region last - Capture the last selected region
scroll - Select a region, then scroll to capture a stitched tall image
Output format (--format): Output format (--format):
png - PNG format (default) png - PNG format (default)
@@ -74,19 +52,14 @@ Examples:
dms screenshot full # Full screen of focused output dms screenshot full # Full screen of focused output
dms screenshot all # All screens combined dms screenshot all # All screens combined
dms screenshot output -o DP-1 # Specific output dms screenshot output -o DP-1 # Specific output
dms screenshot window # Focused window dms screenshot window # Focused window (Hyprland)
dms screenshot last # Last region (pre-selected) dms screenshot last # Last region (pre-selected)
dms screenshot --reset # Reset last region pre-selection dms screenshot --reset # Reset last region pre-selection
dms screenshot --no-clipboard # Save file only dms screenshot --no-clipboard # Save file only
dms screenshot --no-file # Clipboard only dms screenshot --no-file # Clipboard only
dms screenshot --no-confirm # Region capture on mouse release dms screenshot --no-confirm # Region capture on mouse release
dms screenshot --cursor=on # Include cursor dms screenshot --cursor=on # Include cursor
dms screenshot -f jpg -q 85 # JPEG with quality 85 dms screenshot -f jpg -q 85 # JPEG with quality 85`,
dms screenshot --json # Print capture metadata as JSON
dms screenshot --allow-multiple # Skip the one-selector-at-a-time guard
dms screenshot -g # Print selected region geometry (X,Y WxH) to stdout
dms screenshot scroll # Scroll capture, Enter finishes / Esc cancels
dms screenshot scroll --interval 250`,
} }
var ssRegionCmd = &cobra.Command{ var ssRegionCmd = &cobra.Command{
@@ -124,33 +97,10 @@ If no previous region exists, falls back to interactive selection.`,
var ssWindowCmd = &cobra.Command{ var ssWindowCmd = &cobra.Command{
Use: "window", Use: "window",
Short: "Capture the focused window", Short: "Capture the focused window",
Long: `Capture the currently focused window. Supported on Hyprland, Mango, niri, and Aqueous. Aqueous requires a running DMS shell and crops the output including borders and overlapping windows.`, Long: `Capture the currently focused window. Supported on Hyprland and Mango.`,
Run: runScreenshotWindow, Run: runScreenshotWindow,
} }
var ssScrollInterval int
var ssScrollCmd = &cobra.Command{
Use: "scroll",
Short: "Capture a scrolling region stitched into one tall image",
Long: `Select a region, then scroll the content beneath with the mouse wheel or
touchpad while frames are captured and stitched vertically. Finish with the
on-screen done button; cancel with the cancel button. Enter and Esc work
everywhere: most compositors hold the keyboard on the overlay (keyboard
scrolling does not reach the app there), while Hyprland leaves the keyboard
with the application — keyboard scrolling works, and Enter/Esc act through
temporary global binds for the session. The cursor is never included in
frames.
Frames are stitched continuously while scrolling, and revisited content is
never duplicated — scrolling up past the starting point extends the image
upward. Content jumped past faster than capture can follow is skipped rather
than stitched incorrectly.
Rotated outputs are not supported.`,
Run: runScreenshotScroll,
}
var ssListCmd = &cobra.Command{ var ssListCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List available outputs", Short: "List available outputs",
@@ -161,13 +111,12 @@ var notifyActionCmd = &cobra.Command{
Use: "notify-action", Use: "notify-action",
Hidden: true, Hidden: true,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
notify.RunActionListener(args) screenshot.RunNotifyActionListener(args)
}, },
} }
func init() { func init() {
screenshotCmd.PersistentFlags().StringVarP(&ssOutputName, "output", "o", "", "Output name for 'output' mode") screenshotCmd.PersistentFlags().StringVarP(&ssOutputName, "output", "o", "", "Output name for 'output' mode")
screenshotCmd.PersistentFlags().StringVar(&ssSeat, "seat", "", "Seat for Aqueous capture (required with multiple seats)")
screenshotCmd.PersistentFlags().StringVar(&ssCursor, "cursor", "off", "Include cursor in screenshot (on/off)") screenshotCmd.PersistentFlags().StringVar(&ssCursor, "cursor", "off", "Include cursor in screenshot (on/off)")
screenshotCmd.PersistentFlags().StringVarP(&ssFormat, "format", "f", "png", "Output format (png, jpg, ppm)") screenshotCmd.PersistentFlags().StringVarP(&ssFormat, "format", "f", "png", "Output format (png, jpg, ppm)")
screenshotCmd.PersistentFlags().IntVarP(&ssQuality, "quality", "q", 90, "JPEG quality (1-100)") screenshotCmd.PersistentFlags().IntVarP(&ssQuality, "quality", "q", 90, "JPEG quality (1-100)")
@@ -179,15 +128,8 @@ func init() {
screenshotCmd.PersistentFlags().BoolVar(&ssNoConfirm, "no-confirm", false, "Region mode: capture on mouse release without Enter/Space confirmation") screenshotCmd.PersistentFlags().BoolVar(&ssNoConfirm, "no-confirm", false, "Region mode: capture on mouse release without Enter/Space confirmation")
screenshotCmd.PersistentFlags().BoolVar(&ssReset, "reset", false, "Reset saved last-region preselection before capturing") screenshotCmd.PersistentFlags().BoolVar(&ssReset, "reset", false, "Reset saved last-region preselection before capturing")
screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)") screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
screenshotCmd.PersistentFlags().BoolVar(&ssJSON, "json", false, "Print capture metadata as JSON")
screenshotCmd.PersistentFlags().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)")
screenshotCmd.AddCommand(ssRegionCmd) screenshotCmd.AddCommand(ssRegionCmd)
screenshotCmd.AddCommand(ssScrollCmd)
screenshotCmd.AddCommand(ssFullCmd) screenshotCmd.AddCommand(ssFullCmd)
screenshotCmd.AddCommand(ssAllCmd) screenshotCmd.AddCommand(ssAllCmd)
screenshotCmd.AddCommand(ssOutputCmd) screenshotCmd.AddCommand(ssOutputCmd)
@@ -202,7 +144,6 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
config := screenshot.DefaultConfig() config := screenshot.DefaultConfig()
config.Mode = mode config.Mode = mode
config.OutputName = ssOutputName config.OutputName = ssOutputName
config.Seat = ssSeat
if strings.EqualFold(ssCursor, "on") { if strings.EqualFold(ssCursor, "on") {
config.Cursor = screenshot.CursorOn config.Cursor = screenshot.CursorOn
} }
@@ -212,15 +153,6 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
config.NoConfirm = ssNoConfirm config.NoConfirm = ssNoConfirm
config.Reset = ssReset config.Reset = ssReset
config.Stdout = ssStdout config.Stdout = ssStdout
config.Geometry = ssGeometry
config.AllowMultiple = ssAllowMulti
config.HUD = ssHUD
if ssGeometry {
config.Clipboard = false
config.SaveFile = false
config.Notify = false
}
if ssOutputDir != "" { if ssOutputDir != "" {
config.OutputDir = ssOutputDir config.OutputDir = ssOutputDir
@@ -246,147 +178,61 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
} }
config.Quality = ssQuality config.Quality = ssQuality
if mode == screenshot.ModeRegion || mode == screenshot.ModeScroll {
config.SnapTargets = fetchShellSnapTargets()
}
return config return config
} }
func fetchShellSnapTargets() []screenshot.SnapTarget { // setPopoutScreenshotMode toggles the shell handshake so popouts drop their keyboard grab during region select. Best-effort.
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) { func setPopoutScreenshotMode(begin bool) {
fn := "end" fn := "end"
if begin { if begin {
fn = "begin" fn = "begin"
} }
if pid, ok := shellApp.SessionPID(); ok {
_, _, _ = qsipc.Call(qsipc.SocketPathForPID(pid), "screenshot", fn, nil)
return
}
cmdArgs := []string{"ipc"} cmdArgs := []string{"ipc"}
if err := shellApp.ResolveConfig(nil, nil); err != nil { if pid, ok := getFirstDMSPID(); ok {
return cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
} else {
if err := findConfig(nil, nil); err != nil {
return
}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", configPath)
} }
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", shellApp.ConfigPath())
cmdArgs = append(cmdArgs, "call", "screenshot", fn) cmdArgs = append(cmdArgs, "call", "screenshot", fn)
_ = exec.Command("qs", cmdArgs...).Start() _ = exec.Command("qs", cmdArgs...).Run()
}
func writeScreenshotJSON(meta screenshotMetadata) {
_ = json.NewEncoder(os.Stdout).Encode(meta)
}
func exitScreenshotError(context string, err error) {
if ssJSON {
writeScreenshotJSON(screenshotMetadata{Status: "error", Error: err.Error()})
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "Error%s: %v\n", context, err)
os.Exit(1)
}
func formatMime(format screenshot.Format) string {
switch format {
case screenshot.FormatJPEG:
return "image/jpeg"
case screenshot.FormatPPM:
return "image/x-portable-pixmap"
default:
return "image/png"
}
} }
func runScreenshot(config screenshot.Config) { func runScreenshot(config screenshot.Config) {
if ssJSON && config.Stdout {
fmt.Fprintln(os.Stderr, "Error: --json cannot be combined with --stdout")
os.Exit(1)
}
if config.Geometry {
if config.Stdout {
fmt.Fprintln(os.Stderr, "Error: --geometry cannot be combined with --stdout")
os.Exit(1)
}
if config.Mode == screenshot.ModeScroll {
fmt.Fprintln(os.Stderr, "Error: --geometry cannot be combined with scroll mode")
os.Exit(1)
}
}
// Short-lived process over a few tens of MB: let the heap grow instead of paying GC cycles mid-capture.
debug.SetGCPercent(-1)
debug.SetMemoryLimit(1 << 30)
// Region select needs the keyboard; drop popout grabs for its duration. // Region select needs the keyboard; drop popout grabs for its duration.
config.SelectorHook = setPopoutScreenshotMode result, err := func() (*screenshot.CaptureResult, error) {
result, err := screenshot.New(config).Run() interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion
if interactive {
setPopoutScreenshotMode(true)
defer setPopoutScreenshotMode(false)
}
return screenshot.New(config).Run()
}()
if err != nil { if err != nil {
exitScreenshotError("", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
} }
if result == nil { if result == nil {
if ssJSON {
writeScreenshotJSON(screenshotMetadata{Status: "aborted", Error: "User cancelled selection"})
}
if config.Geometry {
os.Exit(1)
}
os.Exit(0) os.Exit(0)
} }
if config.Geometry { defer result.Buffer.Close()
if ssJSON {
x := int(result.Region.X)
y := int(result.Region.Y)
writeScreenshotJSON(screenshotMetadata{
Status: "success",
X: &x,
Y: &y,
Width: int(result.Region.Width),
Height: int(result.Region.Height),
})
} else {
fmt.Println(result.Region.GeometryString())
}
os.Exit(0)
}
if result.Buffer != nil { if result.YInverted {
defer result.Buffer.Close() result.Buffer.FlipVertical()
if result.YInverted {
result.Buffer.FlipVertical()
}
}
if result.CopyOnly {
config.SaveFile = false
config.Clipboard = true
config.Stdout = false
} }
if config.Stdout { if config.Stdout {
if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format, result.CICP); err != nil { if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
exitScreenshotError(" writing to stdout", err) fmt.Fprintf(os.Stderr, "Error writing to stdout: %v\n", err)
os.Exit(1)
} }
return return
} }
@@ -405,81 +251,50 @@ func runScreenshot(config screenshot.Config) {
} }
filePath = filepath.Join(outputDir, filename) filePath = filepath.Join(outputDir, filename)
if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format, result.CICP); err != nil { if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format); err != nil {
exitScreenshotError(" writing file", err) fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
} os.Exit(1)
if !ssJSON {
fmt.Println(filePath)
} }
fmt.Println(filePath)
} }
if config.Clipboard { if config.Clipboard {
var err error if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
if filePath != "" && config.Format != screenshot.FormatPPM { fmt.Fprintf(os.Stderr, "Error copying to clipboard: %v\n", err)
err = copyFileToClipboard(filePath) os.Exit(1)
} else {
err = copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format, result.CICP)
} }
if err != nil { if !config.SaveFile {
exitScreenshotError(" copying to clipboard", err) fmt.Println("Copied to clipboard")
}
if !ssJSON && !config.SaveFile {
if ssStdout {
fmt.Fprintln(os.Stderr, "Copied to clipboard")
} else {
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),
})
} }
} }
if config.Notify { if config.Notify {
thumbData, thumbW, thumbH := bufferToRGBAThumbnail(result.Buffer, 640, result.Format) thumbData, thumbW, thumbH := bufferToRGBThumbnail(result.Buffer, 256, result.Format)
id := screenshot.SendNotification(screenshot.NotifyResult{ screenshot.SendNotification(screenshot.NotifyResult{
FilePath: filePath, FilePath: filePath,
Clipboard: config.Clipboard, Clipboard: config.Clipboard,
ImageData: thumbData, ImageData: thumbData,
Width: thumbW, Width: thumbW,
Height: thumbH, Height: thumbH,
}) })
watchNotificationAction(id, filePath)
} }
} }
func copyImageToClipboard(buf *screenshot.ShmBuffer, format screenshot.Format, quality int, pixelFormat uint32, cicp *screenshot.CICP) error { func copyImageToClipboard(buf *screenshot.ShmBuffer, format screenshot.Format, quality int, pixelFormat uint32) error {
var mimeType string var mimeType string
var data bytes.Buffer var data bytes.Buffer
img := screenshot.BufferToImageWithFormat(buf, pixelFormat)
switch format { switch format {
case screenshot.FormatJPEG: case screenshot.FormatJPEG:
mimeType = "image/jpeg" mimeType = "image/jpeg"
if err := screenshot.EncodeBufferJPEG(&data, buf, pixelFormat, quality); err != nil { if err := screenshot.EncodeJPEG(&data, img, quality); err != nil {
return err return err
} }
default: default:
mimeType = "image/png" mimeType = "image/png"
if err := screenshot.EncodeBufferPNG(&data, buf, pixelFormat, cicp); err != nil { if err := screenshot.EncodePNG(&data, img); err != nil {
return err return err
} }
} }
@@ -487,78 +302,75 @@ func copyImageToClipboard(buf *screenshot.ShmBuffer, format screenshot.Format, q
return clipboard.Copy(data.Bytes(), mimeType) return clipboard.Copy(data.Bytes(), mimeType)
} }
func writeImageToStdout(buf *screenshot.ShmBuffer, format screenshot.Format, quality int, pixelFormat uint32, cicp *screenshot.CICP) error { func writeImageToStdout(buf *screenshot.ShmBuffer, format screenshot.Format, quality int, pixelFormat uint32) error {
img := screenshot.BufferToImageWithFormat(buf, pixelFormat)
switch format { switch format {
case screenshot.FormatJPEG: case screenshot.FormatJPEG:
return screenshot.EncodeBufferJPEG(os.Stdout, buf, pixelFormat, quality) return screenshot.EncodeJPEG(os.Stdout, img, quality)
default: default:
return screenshot.EncodeBufferPNG(os.Stdout, buf, pixelFormat, cicp) return screenshot.EncodePNG(os.Stdout, img)
} }
} }
func bufferToRGBAThumbnail(buf *screenshot.ShmBuffer, maxSize int, pixelFormat uint32) ([]byte, int, int) { func bufferToRGBThumbnail(buf *screenshot.ShmBuffer, maxSize int, pixelFormat uint32) ([]byte, int, int) {
if buf == nil || buf.Width <= 0 || buf.Height <= 0 || maxSize <= 0 { srcW, srcH := buf.Width, buf.Height
return nil, 0, 0 scale := 1.0
if srcW > maxSize || srcH > maxSize {
if srcW > srcH {
scale = float64(maxSize) / float64(srcW)
} else {
scale = float64(maxSize) / float64(srcH)
}
} }
srcW, srcH := buf.Width, buf.Height dstW := int(float64(srcW) * scale)
longest := max(srcW, srcH) dstH := int(float64(srcH) * scale)
dstW, dstH := srcW, srcH if dstW < 1 {
if longest > maxSize { dstW = 1
dstW = max(1, srcW*maxSize/longest) }
dstH = max(1, srcH*maxSize/longest) if dstH < 1 {
dstH = 1
} }
data := buf.Data() data := buf.Data()
rgba := make([]byte, dstW*dstH*4) rgb := make([]byte, dstW*dstH*3)
is10Bit := screenshot.PixelFormat(pixelFormat).Is10Bit()
var swapRB bool var swapRB bool
switch pixelFormat { switch pixelFormat {
case uint32(screenshot.FormatABGR8888), uint32(screenshot.FormatXBGR8888), case uint32(screenshot.FormatABGR8888), uint32(screenshot.FormatXBGR8888):
uint32(screenshot.FormatABGR2101010), uint32(screenshot.FormatXBGR2101010):
swapRB = false swapRB = false
default: default:
swapRB = true swapRB = true
} }
for y := range dstH { for y := 0; y < dstH; y++ {
y0, y1 := y*srcH/dstH, (y+1)*srcH/dstH srcY := int(float64(y) / scale)
for x := range dstW { if srcY >= srcH {
x0, x1 := x*srcW/dstW, (x+1)*srcW/dstW srcY = srcH - 1
var r, g, b, samples uint64 }
for srcY := y0; srcY < y1; srcY++ { for x := 0; x < dstW; x++ {
for srcX := x0; srcX < x1; srcX++ { srcX := int(float64(x) / scale)
si := srcY*buf.Stride + srcX*4 if srcX >= srcW {
if si+3 >= len(data) { srcX = srcW - 1
continue
}
c0, c1, c2 := data[si], data[si+1], data[si+2]
if is10Bit {
v := binary.LittleEndian.Uint32(data[si:])
c0, c1, c2 = uint8(v>>2), uint8(v>>12), uint8(v>>22)
}
if swapRB {
c0, c2 = c2, c0
}
r += uint64(c0)
g += uint64(c1)
b += uint64(c2)
samples++
}
} }
if samples == 0 { si := srcY*buf.Stride + srcX*4
di := (y*dstW + x) * 3
if si+3 >= len(data) {
continue continue
} }
di := (y*dstW + x) * 4 if swapRB {
rgba[di+0] = byte((r + samples/2) / samples) rgb[di+0] = data[si+2]
rgba[di+1] = byte((g + samples/2) / samples) rgb[di+1] = data[si+1]
rgba[di+2] = byte((b + samples/2) / samples) rgb[di+2] = data[si+0]
rgba[di+3] = 255 } else {
rgb[di+0] = data[si+0]
rgb[di+1] = data[si+1]
rgb[di+2] = data[si+2]
}
} }
} }
return rgba, dstW, dstH return rgb, dstW, dstH
} }
func runScreenshotRegion(cmd *cobra.Command, args []string) { func runScreenshotRegion(cmd *cobra.Command, args []string) {
@@ -566,12 +378,6 @@ func runScreenshotRegion(cmd *cobra.Command, args []string) {
runScreenshot(config) runScreenshot(config)
} }
func runScreenshotScroll(cmd *cobra.Command, args []string) {
config := getScreenshotConfig(screenshot.ModeScroll)
config.IntervalMs = min(max(ssScrollInterval, 30), 1000)
runScreenshot(config)
}
func runScreenshotFull(cmd *cobra.Command, args []string) { func runScreenshotFull(cmd *cobra.Command, args []string) {
config := getScreenshotConfig(screenshot.ModeFullScreen) config := getScreenshotConfig(screenshot.ModeFullScreen)
runScreenshot(config) runScreenshot(config)
-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))
}
})
}
}
+2 -2
View File
@@ -8,7 +8,7 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -120,7 +120,7 @@ func showSession(id string) (sessionInfo, error) {
return sessionInfo{}, err return sessionInfo{}, err
} }
fields := map[string]string{} fields := map[string]string{}
for line := range strings.SplitSeq(string(out), "\n") { for _, line := range strings.Split(string(out), "\n") {
idx := strings.IndexByte(line, '=') idx := strings.IndexByte(line, '=')
if idx <= 0 { if idx <= 0 {
continue continue
+69 -160
View File
@@ -8,13 +8,12 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/duxlabs-dev/dux-shell/core/internal/config" "github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/duxlabs-dev/dux-shell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/duxlabs-dev/dux-shell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf" "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/duxlabs-dev/dux-shell/core/internal/privesc" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -130,7 +129,7 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
mangoFile: "layout.conf", mangoFile: "layout.conf",
niriContent: func(_ string) string { return config.NiriLayoutConfig }, niriContent: func(_ string) string { return config.NiriLayoutConfig },
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig }, hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
mangoContent: func(_ string) string { return mangoconf.Detect().Translate(config.MangoLayoutConfig) }, mangoContent: func(_ string) string { return config.MangoLayoutConfig },
}, },
"colors": { "colors": {
niriFile: "colors.kdl", niriFile: "colors.kdl",
@@ -138,20 +137,12 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
mangoFile: "colors.conf", mangoFile: "colors.conf",
niriContent: func(_ string) string { return config.NiriColorsConfig }, niriContent: func(_ string) string { return config.NiriColorsConfig },
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig }, hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
mangoContent: func(_ string) string { return mangoconf.Detect().Translate(config.MangoColorsConfig) }, mangoContent: func(_ string) string { return config.MangoColorsConfig },
}, },
"alttab": { "alttab": {
niriFile: "alttab.kdl", niriFile: "alttab.kdl",
niriContent: func(_ string) string { return config.NiriAlttabConfig }, niriContent: func(_ string) string { return config.NiriAlttabConfig },
}, },
"input": {
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 "" },
},
"outputs": { "outputs": {
niriFile: "outputs.kdl", niriFile: "outputs.kdl",
hyprFile: "outputs.lua", hyprFile: "outputs.lua",
@@ -178,43 +169,15 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
}, },
} }
var knownTerminals = []string{"ghostty", "foot", "kitty", "alacritty"} func detectTerminal() (string, error) {
terminals := []string{"ghostty", "foot", "kitty", "alacritty"}
func terminalFromEnv() string {
return strings.TrimSpace(os.Getenv("TERMINAL"))
}
func installedTerminals() []string {
var found []string var found []string
for _, t := range knownTerminals { for _, t := range terminals {
if utils.CommandExists(t) { if utils.CommandExists(t) {
found = append(found, t) found = append(found, t)
} }
} }
return found
}
func installedHyprlandVersion() string {
_, ver := getVersionFromCommand("Hyprland", "--version", distros.ParseHyprlandVersion)
return ver
}
func defaultTerminalCommand() string {
if env := terminalFromEnv(); env != "" {
return env
}
if found := installedTerminals(); len(found) > 0 {
return found[0]
}
return "ghostty"
}
func detectTerminal() (string, error) {
if env := terminalFromEnv(); env != "" {
return env, nil
}
found := installedTerminals()
switch len(found) { switch len(found) {
case 0: case 0:
return "ghostty", nil return "ghostty", nil
@@ -241,12 +204,7 @@ func detectTerminal() (string, error) {
} }
func detectCompositorForSetup() (string, error) { func detectCompositorForSetup() (string, error) {
var compositors []string compositors := greeter.DetectCompositors()
for _, candidate := range []string{"niri", "Hyprland", "mango"} {
if utils.CommandExists(candidate) {
compositors = append(compositors, candidate)
}
}
switch len(compositors) { switch len(compositors) {
case 0: case 0:
@@ -255,20 +213,11 @@ func detectCompositorForSetup() (string, error) {
return strings.ToLower(compositors[0]), nil return strings.ToLower(compositors[0]), nil
} }
fmt.Println("Multiple compositors detected:") selected, err := greeter.PromptCompositorChoice(compositors)
for i, compositor := range compositors { if err != nil {
fmt.Printf("%d) %s\n", i+1, compositor) return "", err
} }
fmt.Printf("\nChoice (1-%d): ", len(compositors)) return strings.ToLower(selected), nil
var response string
fmt.Scanln(&response)
choice := 0
fmt.Sscanf(strings.TrimSpace(response), "%d", &choice)
if choice < 1 || choice > len(compositors) {
return "", fmt.Errorf("invalid choice")
}
return strings.ToLower(compositors[choice-1]), nil
} }
func runSetupDmsConfig(name string) error { func runSetupDmsConfig(name string) error {
@@ -309,7 +258,7 @@ func runSetupDmsConfig(name string) error {
case "hyprland": case "hyprland":
dmsDir = filepath.Join(utils.XDGConfigHome(), "hypr", "dms") dmsDir = filepath.Join(utils.XDGConfigHome(), "hypr", "dms")
case "mango", "mangowc": case "mango", "mangowc":
dmsDir = filepath.Join(mangoconf.Dir(), "dms") dmsDir = filepath.Join(utils.XDGConfigHome(), "mango", "dms")
} }
if err := os.MkdirAll(dmsDir, 0o755); err != nil { if err := os.MkdirAll(dmsDir, 0o755); err != nil {
@@ -347,12 +296,8 @@ func runSetup() error {
terminal, terminalSelected := promptTerminal() terminal, terminalSelected := promptTerminal()
useSystemd := true useSystemd := true
if wmSelected { if wmSelected {
if wm == deps.WindowManagerMango && !mangoconf.SessionTargetInstalled() { if wm == deps.WindowManagerMango {
useSystemd = false useSystemd = false
fmt.Println("\nThis Mango has no mango-session.target (added in 0.17.1); DMS will start from the Mango config.")
} else if isVoidSetup() {
useSystemd = false
fmt.Println("\nVoid Linux detected; deploying non-systemd session config.")
} else { } else {
useSystemd = promptSystemd() useSystemd = promptSystemd()
} }
@@ -363,13 +308,6 @@ func runSetup() error {
return nil return nil
} }
replaceBinds := false
if wmSelected {
if path, ok := config.ExistingBinds(wm); ok {
replaceBinds = promptBinds(path)
}
}
if wmSelected || terminalSelected { if wmSelected || terminalSelected {
willBackup := checkExistingConfigs(wm, wmSelected, terminal, terminalSelected) willBackup := checkExistingConfigs(wm, wmSelected, terminal, terminalSelected)
if willBackup { if willBackup {
@@ -390,7 +328,6 @@ func runSetup() error {
fmt.Println("\nDeploying configurations...") fmt.Println("\nDeploying configurations...")
logChan := make(chan string, 100) logChan := make(chan string, 100)
deployer := config.NewConfigDeployer(logChan) deployer := config.NewConfigDeployer(logChan)
deployer.SetReplaceBinds(replaceBinds)
go func() { go func() {
for msg := range logChan { for msg := range logChan {
@@ -398,25 +335,22 @@ func runSetup() error {
} }
}() }()
ctx := context.Background()
var results []config.DeploymentResult var results []config.DeploymentResult
var err error var err error
if wmSelected { if wmSelected && terminalSelected {
terminalCommand := defaultTerminalCommand() results, err = deployer.DeployConfigurationsWithSystemd(ctx, wm, terminal, useSystemd)
if terminalSelected { } else if wmSelected {
terminalCommand = terminal.Command() results, err = deployer.DeployConfigurationsWithSystemd(ctx, wm, deps.TerminalGhostty, useSystemd)
if len(results) > 1 {
results = results[:1]
} }
if wm == deps.WindowManagerHyprland { } else if terminalSelected {
deployer.SetHyprlandVersion(installedHyprlandVersion()) results, err = deployer.DeployConfigurationsWithSystemd(ctx, deps.WindowManagerNiri, terminal, useSystemd)
if len(results) > 0 && results[0].ConfigType == "Niri" {
results = results[1:]
} }
var result config.DeploymentResult
result, err = deployer.DeployCompositor(wm, terminalCommand, useSystemd)
results = append(results, result)
}
if err == nil && terminalSelected {
var terminalResults []config.DeploymentResult
terminalResults, err = deployer.DeployTerminal(terminal)
results = append(results, terminalResults...)
} }
close(logChan) close(logChan)
@@ -438,15 +372,6 @@ func runSetup() error {
return nil return nil
} }
func isVoidSetup() bool {
osInfo, err := distros.GetOSInfo()
if err != nil {
return false
}
config, exists := distros.Registry[osInfo.Distribution.ID]
return exists && config.Family == distros.FamilyVoid
}
// Add user to the input group for the evdev manager for inut state tracking. // Add user to the input group for the evdev manager for inut state tracking.
// Caps Lock OSD and the Caps Lock bar indicator. // Caps Lock OSD and the Caps Lock bar indicator.
func ensureInputGroup() { func ensureInputGroup() {
@@ -526,17 +451,6 @@ func promptTerminal() (deps.Terminal, bool) {
} }
} }
func promptBinds(path string) bool {
fmt.Printf("\nFound your keybinds in %s.\n", path)
fmt.Println(" 1) Keep my binds as they are")
fmt.Println(" 2) Start from the DMS stock binds")
fmt.Println("Either way a timestamped backup is kept.")
fmt.Print("Choice (1-2) [1]: ")
var response string
fmt.Scanln(&response)
return strings.TrimSpace(response) == "2"
}
func promptSystemd() bool { func promptSystemd() bool {
fmt.Println("\nUse systemd for session management?") fmt.Println("\nUse systemd for session management?")
fmt.Println("1) Yes (recommended for most distros)") fmt.Println("1) Yes (recommended for most distros)")
@@ -551,54 +465,49 @@ func promptSystemd() bool {
} }
func checkExistingConfigs(wm deps.WindowManager, wmSelected bool, terminal deps.Terminal, terminalSelected bool) bool { func checkExistingConfigs(wm deps.WindowManager, wmSelected bool, terminal deps.Terminal, terminalSelected bool) bool {
if wmSelected && firstExistingPath(compositorPrimaryPaths(wm)) != "" {
return true
}
return terminalSelected && firstExistingPath([]string{terminalPrimaryPath(terminal)}) != ""
}
func compositorPrimaryPaths(wm deps.WindowManager) []string {
homeDir := os.Getenv("HOME") homeDir := os.Getenv("HOME")
switch wm { willBackup := false
case deps.WindowManagerNiri:
return []string{filepath.Join(homeDir, ".config", "niri", "config.kdl")}
case deps.WindowManagerHyprland:
return []string{
filepath.Join(homeDir, ".config", "hypr", "hyprland.lua"),
filepath.Join(homeDir, ".config", "hypr", "hyprland.conf"),
}
case deps.WindowManagerMango:
return []string{
filepath.Join(homeDir, ".config", "mango", "config.conf"),
filepath.Join(homeDir, ".config", "mango", "mango.conf"),
}
default:
return nil
}
}
func terminalPrimaryPath(terminal deps.Terminal) string { if wmSelected {
homeDir := os.Getenv("HOME") var configPaths []string
switch terminal { switch wm {
case deps.TerminalGhostty: case deps.WindowManagerNiri:
return filepath.Join(homeDir, ".config", "ghostty", "config") configPaths = []string{filepath.Join(homeDir, ".config", "niri", "config.kdl")}
case deps.TerminalKitty: case deps.WindowManagerHyprland:
return filepath.Join(homeDir, ".config", "kitty", "kitty.conf") configPaths = []string{
case deps.TerminalAlacritty: filepath.Join(homeDir, ".config", "hypr", "hyprland.lua"),
return filepath.Join(homeDir, ".config", "alacritty", "alacritty.toml") filepath.Join(homeDir, ".config", "hypr", "hyprland.conf"),
default: }
return "" case deps.WindowManagerMango:
} configPaths = []string{
} filepath.Join(homeDir, ".config", "mango", "config.conf"),
filepath.Join(homeDir, ".config", "mango", "mango.conf"),
}
}
func firstExistingPath(paths []string) string { for _, configPath := range configPaths {
for _, path := range paths { if _, err := os.Stat(configPath); err == nil {
if path == "" { willBackup = true
continue break
} }
if _, err := os.Stat(path); err == nil {
return path
} }
} }
return ""
if terminalSelected {
var configPath string
switch terminal {
case deps.TerminalGhostty:
configPath = filepath.Join(homeDir, ".config", "ghostty", "config")
case deps.TerminalKitty:
configPath = filepath.Join(homeDir, ".config", "kitty", "kitty.conf")
case deps.TerminalAlacritty:
configPath = filepath.Join(homeDir, ".config", "alacritty", "alacritty.toml")
}
if _, err := os.Stat(configPath); err == nil {
willBackup = true
}
}
return willBackup
} }
-158
View File
@@ -1,158 +0,0 @@
package main
import (
"fmt"
"github.com/duxlabs-dev/dux-shell/core/internal/config"
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
"github.com/spf13/cobra"
)
var setupHeadlessCmd = &cobra.Command{
Use: "headless",
Short: "Deploy DMS configurations without prompts",
Long: "Deploy compositor and terminal configurations non-interactively, for installers and scripts",
PersistentPreRunE: requireMutableSystemCommand,
RunE: func(cmd *cobra.Command, args []string) error {
compositor, _ := cmd.Flags().GetString("compositor")
terminal, _ := cmd.Flags().GetString("terminal")
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)
},
}
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().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 {
wm, err := parseHeadlessCompositor(compositor)
if err != nil {
return err
}
term, termSelected, err := parseHeadlessTerminal(terminal)
if err != nil {
return err
}
deployCompositor := true
deployTerminal := termSelected
if !force {
if existing := firstExistingPath(compositorPrimaryPaths(wm)); existing != "" {
if !skipExisting {
return fmt.Errorf("%s config already exists: %s (use --force to overwrite with backup, or --skip-existing)", compositor, existing)
}
fmt.Printf("⚠ Skipping %s config, already exists: %s\n", compositor, existing)
deployCompositor = false
}
if deployTerminal {
if existing := firstExistingPath([]string{terminalPrimaryPath(term)}); existing != "" {
if !skipExisting {
return fmt.Errorf("%s config already exists: %s (use --force to overwrite with backup, or --skip-existing)", terminal, existing)
}
fmt.Printf("⚠ Skipping %s config, already exists: %s\n", terminal, existing)
deployTerminal = false
}
}
}
if !deployCompositor && !deployTerminal {
return nil
}
logChan := make(chan string, 100)
logDone := make(chan struct{})
go func() {
for msg := range logChan {
fmt.Println(" " + msg)
}
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))
results = append(results, result)
deployErr = err
}
if deployErr == nil && deployTerminal {
terminalResults, err := deployer.DeployTerminal(term)
results = append(results, terminalResults...)
deployErr = err
}
close(logChan)
<-logDone
for _, result := range results {
if !result.Deployed {
continue
}
fmt.Printf("✓ %s: %s\n", result.ConfigType, result.Path)
if result.BackupPath != "" {
fmt.Printf(" Backup: %s\n", result.BackupPath)
}
}
if deployErr != nil {
return fmt.Errorf("deployment failed: %w", deployErr)
}
return nil
}
func parseHeadlessCompositor(name string) (deps.WindowManager, error) {
switch name {
case "niri":
return deps.WindowManagerNiri, nil
case "hyprland":
return deps.WindowManagerHyprland, nil
case "mango":
return deps.WindowManagerMango, nil
default:
return 0, fmt.Errorf("unknown compositor %q (expected niri, hyprland, or mango)", name)
}
}
func parseHeadlessTerminal(name string) (deps.Terminal, bool, error) {
switch name {
case "":
return deps.TerminalGhostty, false, nil
case "ghostty":
return deps.TerminalGhostty, true, nil
case "kitty":
return deps.TerminalKitty, true, nil
case "alacritty":
return deps.TerminalAlacritty, true, nil
default:
return 0, false, fmt.Errorf("unknown terminal %q (expected ghostty, kitty, or alacritty)", name)
}
}
func headlessUseSystemd(wm deps.WindowManager, noSystemd bool) bool {
if noSystemd {
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"
}
+21 -38
View File
@@ -6,15 +6,13 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"runtime"
"strings" "strings"
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/duxlabs-dev/dux-go/ipc"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/lowprio"
"github.com/duxlabs-dev/dux-shell/core/internal/server/sysupdate"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -36,7 +34,6 @@ Examples:
dms system update --check # list available updates dms system update --check # list available updates
dms system update # apply updates (interactive prompt) dms system update # apply updates (interactive prompt)
dms system update --noconfirm # apply updates without prompting dms system update --noconfirm # apply updates without prompting
dms system update --interactive # let the package manager ask its own questions
dms system update --dry # simulate without changing anything dms system update --dry # simulate without changing anything
dms system update --no-flatpak --noconfirm # apply system updates only dms system update --no-flatpak --noconfirm # apply system updates only
dms system update --interval 3600 # set the server poll interval to 1h`, dms system update --interval 3600 # set the server poll interval to 1h`,
@@ -44,46 +41,37 @@ Examples:
} }
var ( var (
sysUpdateCheck bool sysUpdateCheck bool
sysUpdateNoConfirm bool sysUpdateNoConfirm bool
sysUpdateInteractive bool sysUpdateDry bool
sysUpdateDry bool sysUpdateJSON bool
sysUpdateJSON bool sysUpdateNoFlatpak bool
sysUpdateNoFlatpak bool sysUpdateNoAUR bool
sysUpdateNoAUR bool sysUpdateIntervalS int
sysUpdateIgnore []string sysUpdateListPmTime = 5 * time.Minute
sysUpdateIntervalS int
sysUpdateListPmTime = 5 * time.Minute
) )
func init() { func init() {
systemUpdateCmd.Flags().BoolVar(&sysUpdateCheck, "check", false, "List available updates without applying") systemUpdateCmd.Flags().BoolVar(&sysUpdateCheck, "check", false, "List available updates without applying")
systemUpdateCmd.Flags().BoolVarP(&sysUpdateNoConfirm, "noconfirm", "y", false, "Apply updates without prompting") systemUpdateCmd.Flags().BoolVarP(&sysUpdateNoConfirm, "noconfirm", "y", false, "Apply updates without prompting")
systemUpdateCmd.Flags().BoolVar(&sysUpdateInteractive, "interactive", false, "Let the package manager ask its own questions instead of answering yes")
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes") systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)") systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay") systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip AUR updates") systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
systemUpdateCmd.Flags().StringSliceVar(&sysUpdateIgnore, "ignore", nil, "Skip specific packages (repeatable or comma-separated)")
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)") systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
systemCmd.AddCommand(systemUpdateCmd) systemCmd.AddCommand(systemUpdateCmd)
} }
func runSystemUpdate(cmd *cobra.Command, args []string) { func runSystemUpdate(cmd *cobra.Command, args []string) {
if sysUpdateIntervalS >= 0 { switch {
case sysUpdateIntervalS >= 0:
runSystemUpdateSetInterval(sysUpdateIntervalS) runSystemUpdateSetInterval(sysUpdateIntervalS)
return case sysUpdateCheck:
}
// Package managers started from this thread inherit its priority.
runtime.LockOSThread()
lowprio.LowerThreadPriority()
if sysUpdateCheck {
runSystemUpdateCheck() runSystemUpdateCheck()
return default:
runSystemUpdateApply()
} }
runSystemUpdateApply()
} }
func selectBackends(ctx context.Context) []sysupdate.Backend { func selectBackends(ctx context.Context) []sysupdate.Backend {
@@ -190,7 +178,7 @@ func runSystemUpdateApply() {
} }
fmt.Println() fmt.Println()
if !sysUpdateNoConfirm && !sysUpdateInteractive && !sysUpdateDry { if !sysUpdateNoConfirm && !sysUpdateDry {
if !promptYesNo("Proceed with upgrade? [Y/n]: ") { if !promptYesNo("Proceed with upgrade? [Y/n]: ") {
fmt.Println("Aborted.") fmt.Println("Aborted.")
return return
@@ -204,12 +192,10 @@ func runSystemUpdateApply() {
Targets: pkgs, Targets: pkgs,
IncludeFlatpak: !sysUpdateNoFlatpak, IncludeFlatpak: !sysUpdateNoFlatpak,
IncludeAUR: !sysUpdateNoAUR, IncludeAUR: !sysUpdateNoAUR,
Ignored: sysUpdateIgnore,
DryRun: sysUpdateDry, DryRun: sysUpdateDry,
UseSudo: true, UseSudo: true,
} }
opts.AttachStdio = sysupdate.UpgradeNeedsPrivilege(backends, pkgs, opts) opts.AttachStdio = sysupdate.UpgradeNeedsPrivilege(backends, pkgs, opts)
opts.Interactive = sysUpdateInteractive && opts.AttachStdio
onLine := func(line string) { fmt.Println(line) } onLine := func(line string) { fmt.Println(line) }
ran := false ran := false
@@ -248,15 +234,12 @@ func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupd
} }
func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package { func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
if !sysUpdateNoAUR && len(sysUpdateIgnore) == 0 { if !sysUpdateNoAUR {
return pkgs return pkgs
} }
out := pkgs[:0] out := pkgs[:0]
for _, p := range pkgs { for _, p := range pkgs {
if sysUpdateNoAUR && p.Repo == sysupdate.RepoAUR { if p.Repo == sysupdate.RepoAUR {
continue
}
if sysupdate.IsPackageIgnored(p.Name, sysUpdateIgnore) {
continue continue
} }
out = append(out, p) out = append(out, p)
@@ -265,7 +248,7 @@ func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
} }
func runSystemUpdateSetInterval(seconds int) { func runSystemUpdateSetInterval(seconds int) {
resp, err := sendServerRequest(ipc.Request{ resp, err := sendServerRequest(models.Request{
ID: 1, ID: 1,
Method: "sysupdate.setInterval", Method: "sysupdate.setInterval",
Params: map[string]any{"seconds": float64(seconds)}, Params: map[string]any{"seconds": float64(seconds)},
-134
View File
@@ -1,134 +0,0 @@
package main
import (
"fmt"
"strings"
"github.com/duxlabs-dev/dux-shell/core/internal/config"
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
"github.com/spf13/cobra"
)
var systemdCompositorFlag string
var systemdCmd = &cobra.Command{
Use: "systemd",
Short: "Start DMS as a systemd user service with the compositor session",
Long: `Manage dms.service for the compositor session.
enable makes the compositor's own session unit want dms.service, so DMS starts
with that compositor only: hyprland-session.target, niri.service or
mango-session.target. The compositor is the running one, or --compositor.`,
}
var systemdEnableCmd = &cobra.Command{
Use: "enable",
Short: "Start dms.service with the compositor session",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
wm, err := systemdCompositor()
if err != nil {
return err
}
unit, err := config.EnableSession(wm)
if err != nil {
return err
}
fmt.Printf("dms.service starts with %s\n", unit)
now, _ := cmd.Flags().GetBool("now")
if !now {
return nil
}
if err := runSystemctl("--user", "start", dmsSystemdUnit); err != nil {
return err
}
fmt.Println("dms.service started")
return nil
},
}
var systemdDisableCmd = &cobra.Command{
Use: "disable",
Short: "Stop starting dms.service with any compositor session",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
removed, err := config.DisableSession()
if err != nil {
return err
}
if len(removed) == 0 {
fmt.Println("dms.service was not wanted by any compositor session")
} else {
fmt.Printf("dms.service no longer starts with %s\n", strings.Join(removed, ", "))
}
now, _ := cmd.Flags().GetBool("now")
if !now {
return nil
}
return runSystemctl("--user", "stop", dmsSystemdUnit)
},
}
var systemdStatusCmd = &cobra.Command{
Use: "status",
Short: "Show which compositor sessions start dms.service",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
state := getServiceState("dms", true)
if !state.exists {
fmt.Println("dms.service: not installed")
return
}
fmt.Printf("dms.service: %s", state.enabled)
if state.active != "" {
fmt.Printf(", %s", state.active)
}
fmt.Println()
wanted := config.SessionWanted()
if len(wanted) == 0 {
fmt.Println("Starts with: nothing (run 'dms systemd enable')")
return
}
fmt.Printf("Starts with: %s\n", strings.Join(wanted, ", "))
},
}
func systemdLifecycleCmd(verb string) *cobra.Command {
return &cobra.Command{
Use: verb,
Short: strings.ToUpper(verb[:1]) + verb[1:] + " dms.service",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runSystemctl("--user", verb, dmsSystemdUnit)
},
}
}
func systemdCompositor() (deps.WindowManager, error) {
if systemdCompositorFlag == "" {
if wm, ok := config.RunningCompositor(); ok {
return wm, nil
}
name, err := detectCompositorForSetup()
if err != nil {
return 0, err
}
systemdCompositorFlag = name
}
switch strings.ToLower(systemdCompositorFlag) {
case "hyprland":
return deps.WindowManagerHyprland, nil
case "niri":
return deps.WindowManagerNiri, nil
case "mango", "mangowc":
return deps.WindowManagerMango, nil
}
return 0, fmt.Errorf("unknown compositor %q (hyprland, niri or mango)", systemdCompositorFlag)
}
func init() {
systemdEnableCmd.Flags().StringVar(&systemdCompositorFlag, "compositor", "", "hyprland, niri or mango (default: the running one)")
systemdEnableCmd.Flags().Bool("now", false, "Also start dms.service")
systemdDisableCmd.Flags().Bool("now", false, "Also stop dms.service")
systemdCmd.AddCommand(systemdEnableCmd, systemdDisableCmd, systemdStatusCmd, systemdLifecycleCmd("start"), systemdLifecycleCmd("stop"), systemdLifecycleCmd("restart"))
}
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"fmt" "fmt"
"os" "os"
"github.com/duxlabs-dev/dux-go/trash" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/trash"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
+24
View File
@@ -0,0 +1,24 @@
package main
import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/traywatcher"
"github.com/spf13/cobra"
)
var trayWatcherCmd = &cobra.Command{
Use: "tray-watcher",
Short: "Run a minimal StatusNotifierWatcher for early tray registration",
Long: `Run a minimal org.kde.StatusNotifierWatcher daemon.
Started early in the session (Before=graphical-session.target via
dms-tray-watcher.service), it lets tray apps launched by XDG autostart
register their items before the shell finishes loading, and keeps them
registered across shell restarts. The shell's tray host picks items up from
this watcher; if it is not running, the shell's built-in watcher takes over.`,
Run: func(cmd *cobra.Command, args []string) {
if err := traywatcher.Run(); err != nil {
log.Fatalf("%v", err)
}
},
}

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