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

Compare commits

..

6 Commits

Author SHA1 Message Date
purian23 aed731efb0 fix(clipboard): restore Save button targets in editor 2026-05-25 23:19:42 -04:00
purian23 cf0632c077 feat(Clipboard): Revive ClipboardEditor PR
- Original PR #1916 by @nabaco
2026-05-24 23:28:21 -04:00
Nachum Barcohen e92da4a15f Show full clipboard text in editor 2026-05-24 22:34:24 -04:00
Nachum Barcohen 8abdff3220 Add clipboard editor shortcuts and hints 2026-05-24 22:34:24 -04:00
Nachum Barcohen 584d57a8de Add split save menu for clipboard editor 2026-05-24 22:34:05 -04:00
Nachum Barcohen afb5e59c29 feat(clipboard): Add editing capability to clipboard entries 2026-05-24 22:34:05 -04:00
1054 changed files with 83037 additions and 186319 deletions
+23 -135
View File
@@ -2,16 +2,15 @@
name: dms-plugin-dev
description: >
Develop plugins for DankMaterialShell (DMS), a QML-based Linux desktop shell built on
Quickshell. Supports five plugin types: widget (bar + Control Center), daemon (background
service), launcher (search + actions), desktop (draggable desktop widgets), and composite
(multi-surface). Covers manifest creation, QML component development, startup checks,
settings UI, data persistence, theme integration, PopoutService usage, IPC runtime
discovery, and external command execution. Use when the user wants to create, modify,
or debug a DMS plugin, or asks about the DMS plugin API.
Quickshell. Supports four plugin types: widget (bar + Control Center), daemon (background
service), launcher (search + actions), and desktop (draggable desktop widgets). Covers
manifest creation, QML component development, settings UI, data persistence, theme
integration, PopoutService usage, and external command execution. Use when the user wants
to create, modify, or debug a DMS plugin, or asks about the DMS plugin API.
compatibility: Designed for Claude Code (or similar products)
metadata:
author: DankMaterialShell
version: "1.1"
version: "1.0"
domain: qml-desktop-development
framework: DankMaterialShell
languages: qml, javascript
@@ -38,15 +37,14 @@ integrations, and desktop widgets. Plugins are QML components discovered from
**Plugin registry:** Community plugins are available at https://plugins.danklinux.com/
**Five plugin types:**
**Four plugin types:**
| Type | Purpose | Base Component | Bar pills | CC integration |
|-------------|--------------------------------|----------------------------|-----------|----------------|
| `widget` | Bar widget + popout | `PluginComponent` | Yes | Yes |
| `daemon` | Background service | `PluginComponent` (no UI) | No | Optional |
| `launcher` | Searchable items in launcher | `Item` | No | No |
| `desktop` | Draggable desktop widget | `DesktopPluginComponent` | No | No |
| `composite` | Multi-surface plugin | One component per surface | Optional | Optional |
| Type | Purpose | Base Component | Bar pills | CC integration |
|------------|--------------------------------|----------------------------|-----------|----------------|
| `widget` | Bar widget + popout | `PluginComponent` | Yes | Yes |
| `daemon` | Background service | `PluginComponent` (no UI) | No | Optional |
| `launcher` | Searchable items in launcher | `Item` | No | No |
| `desktop` | Draggable desktop widget | `DesktopPluginComponent` | No | No |
## Step 1: Determine Plugin Type
@@ -60,9 +58,6 @@ Choose the type based on what the plugin does:
with trigger-based filtering (e.g., type `=` for calculator, `:` for emoji).
- **Shows on the desktop background?** - Use `desktop`. Draggable, resizable widget on the
desktop layer.
- **Needs multiple surfaces?** - Use `composite`. A single plugin that registers any combination
of the above (e.g., a daemon + bar widget + desktop widget). Each surface gets its own
QML component file.
## Step 2: Create the Manifest
@@ -83,7 +78,7 @@ Create `plugin.json` in your plugin directory. See [plugin-manifest-reference.md
}
```
**With settings, startup check, and permissions:**
**With settings and permissions:**
```json
{
@@ -97,31 +92,7 @@ Create `plugin.json` in your plugin directory. See [plugin-manifest-reference.md
"component": "./YourWidget.qml",
"icon": "extension",
"settings": "./Settings.qml",
"startupCheck": "./StartupCheck.qml",
"requires_dms": ">=0.1.0",
"dependencies": ["mytool"],
"permissions": ["settings_read", "settings_write"]
}
```
**Composite plugin (multi-surface):**
```json
{
"id": "myComposite",
"name": "My Composite Plugin",
"description": "Daemon + widget + desktop from one plugin",
"version": "1.0.0",
"author": "Your Name",
"type": "composite",
"capabilities": ["daemon", "dankbar-widget", "desktop-widget"],
"icon": "extension",
"components": {
"daemon": "./MyDaemon.qml",
"widget": "./MyBarWidget.qml",
"desktop": "./MyDesktopWidget.qml"
},
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write"]
}
```
@@ -129,11 +100,9 @@ Create `plugin.json` in your plugin directory. See [plugin-manifest-reference.md
**Key rules:**
- `id` must be camelCase, matching pattern `^[a-zA-Z][a-zA-Z0-9]*$`
- `version` must be semver (e.g., `1.0.0`)
- Provide either `component` (single-surface) or `components` (multi-surface), not both
- `component` / component paths must start with `./` and end with `.qml`
- `type: "launcher"` (or a `components` object with a `launcher` key) requires a `trigger` field
- `component` must start with `./` and end with `.qml`
- `type: "launcher"` requires a `trigger` field
- `settings_write` permission is **required** if the plugin has a settings component
- `dependencies` replaces the deprecated `requires` field
## Step 3: Create the Main Component
@@ -280,69 +249,7 @@ PluginComponent {
See [daemon-plugin-guide.md](references/daemon-plugin-guide.md) for event-driven patterns and process execution.
### Composite
For composite plugins, create a separate QML file per surface. Each surface uses the same
base component as the corresponding single-surface type (PluginComponent for widget/daemon,
Item for launcher, etc.). All surfaces share the same `pluginId` and `pluginService`.
```
MyCompositePlugin/
plugin.json
MyBarWidget.qml # PluginComponent (widget surface)
MyDaemon.qml # PluginComponent (daemon surface)
MyDesktopWidget.qml # Item with desktop widget properties
Settings.qml # Shared settings for all surfaces
```
Use `pluginService.pluginHasSurface(pluginId, "widget")` to check whether a specific surface
is registered for a plugin at runtime.
## Step 4: Add Startup Check (Optional)
Gate plugin activation on dependency checks by providing a `startupCheck` component. This
runs before the plugin loads and blocks activation if a required tool or condition is missing.
Create a `StartupCheck.qml` (non-visual QtObject):
```qml
import QtQuick
import qs.Common
QtObject {
function check(done) {
Proc.runCommand("myPlugin.depCheck", ["sh", "-c", "command -v mytool"], (stdout, exitCode) => {
if (exitCode === 0) {
done(null);
return;
}
done({
"title": I18n.tr("mytool is required"),
"details": I18n.tr("Install 'mytool' and re-enable this plugin.")
});
});
}
}
```
The `done` callback accepts:
- `null` - allow activation
- A string - block with a short error message
- `{ title, details }` - block with a title and expandable details body
A synchronous variant (no `done` parameter, return the result directly) is also supported.
Failed checks show a toast error and store the error in `pluginService.pluginLoadErrors`.
Add to your manifest:
```json
{
"startupCheck": "./StartupCheck.qml",
"dependencies": ["mytool"]
}
```
## Step 5: Add Settings (Optional)
## Step 4: Add Settings (Optional)
Wrap settings in `PluginSettings` with your `pluginId`. All settings auto-save and auto-load.
@@ -386,7 +293,7 @@ See [settings-components-reference.md](references/settings-components-reference.
**Important:** Your plugin must declare `"permissions": ["settings_write"]` in plugin.json, or the settings UI will show an error.
## Step 6: Use Data Persistence
## Step 5: Use Data Persistence
Three tiers of persistence:
@@ -395,7 +302,6 @@ Three tiers of persistence:
| `pluginService.savePluginData(id, key, val)` / `loadPluginData(id, key, default)` | Yes (settings.json) | User preferences, config |
| `pluginService.savePluginState(id, key, val)` / `loadPluginState(id, key, default)` | Yes (separate state file) | Runtime state, history, cache |
| `PluginGlobalVar { varName; defaultValue; value; set() }` | No (runtime only) | Cross-instance shared state |
| `pluginService.getPluginPath(id)` | N/A | Get the plugin's installation directory path |
- `pluginData` is a reactive property on PluginComponent, auto-loaded from settings
- React to settings changes with `Connections { target: pluginService; function onPluginDataChanged(id) { ... } }`
@@ -403,7 +309,7 @@ Three tiers of persistence:
See [data-persistence-guide.md](references/data-persistence-guide.md) for details and examples.
## Step 7: Theme Integration
## Step 6: Theme Integration
Always use `Theme.*` properties from `qs.Common` - never hardcode colors or sizes.
@@ -418,7 +324,7 @@ Always use `Theme.*` properties from `qs.Common` - never hardcode colors or size
See [theme-reference.md](references/theme-reference.md) for the complete property list.
## Step 8: Add Popout Content (Widgets Only)
## Step 7: Add Popout Content (Widgets Only)
Add a popout that opens when the bar pill is clicked:
@@ -454,7 +360,7 @@ PluginComponent {
Calculate available content height: `popoutHeight - headerHeight - detailsHeight - spacing`
## Step 9: Control Center Integration (Widgets Only)
## Step 8: Control Center Integration (Widgets Only)
Add your widget to the Control Center grid:
@@ -483,7 +389,7 @@ PluginComponent {
**CC sizing:** 25% width = SmallToggleButton (icon only), 50% width = ToggleButton or CompoundPill (if ccDetailContent is defined).
## Step 10: External Commands and Clipboard
## Step 9: External Commands and Clipboard
**Run commands and capture output:**
@@ -514,33 +420,18 @@ Quickshell.execDetached(["dms", "cl", "copy", textToCopy])
**Do NOT use** `globalThis.clipboard` or browser JavaScript APIs - they don't exist in the QML runtime.
## Step 11: Validate and Test
## Step 10: Validate and Test
1. Validate `plugin.json` against the schema at [assets/plugin-schema.json](assets/plugin-schema.json)
2. Run the shell with verbose output: `qs -v -p $CONFIGPATH/quickshell/dms/shell.qml`
3. Open Settings > Plugins > Scan for Plugins
4. Enable your plugin and add it to the DankBar layout
**Runtime plugin discovery via IPC:**
Plugins can be scanned, rescanned, and reloaded at runtime without restarting the shell:
```bash
dms ipc plugin-scan scan # Trigger a full rescan of all plugin directories
dms ipc plugin-scan rescan <id> # Force rescan of a specific plugin
dms ipc plugin-scan reload <id> # Force reload of a loaded plugin
dms ipc plugin-scan list # List all known plugins (TSV: id, loaded, type, name)
dms ipc plugin-scan status <id> # Get status of a specific plugin (TSV: loaded, type, error)
```
Plugin IDs are validated against `^[a-zA-Z0-9_\-:]{1,64}$`.
**Common issues:**
- Plugin not detected: check plugin.json syntax with `jq . plugin.json`
- Widget not showing: ensure it's enabled AND added to a DankBar section
- Settings error: verify `settings_write` permission is declared
- Data not persisting: check pluginService injection and permissions
- Startup check failing: check `pluginService.pluginLoadErrors` or run `dms ipc plugin-scan status <id>`
## Common Mistakes
@@ -554,9 +445,6 @@ Plugin IDs are validated against `^[a-zA-Z0-9_\-:]{1,64}$`.
8. **Forgetting `categories` in launcher items** - Items won't display without it
9. **Not handling null pluginService** - Always use optional chaining or null checks
10. **Using `PluginComponent` for launchers** - Launchers use plain `Item`, not `PluginComponent`
11. **Using `requires` instead of `dependencies`** - `requires` is deprecated; use `dependencies`
12. **Providing both `component` and `components`** - Use one or the other, not both
13. **Missing `trigger` on composite with launcher surface** - Still required when `components` has a `launcher` key
## Quick Reference: Imports
@@ -11,7 +11,8 @@
"version",
"author",
"type",
"capabilities"
"capabilities",
"component"
],
"properties": {
"id": {
@@ -41,8 +42,8 @@
},
"type": {
"type": "string",
"description": "Plugin type. Use 'composite' (or any value) together with 'components' to provide multiple surfaces from one plugin.",
"enum": ["widget", "daemon", "launcher", "desktop", "composite"]
"description": "Plugin type",
"enum": ["widget", "daemon", "launcher", "desktop"]
},
"capabilities": {
"type": "array",
@@ -54,37 +55,9 @@
},
"component": {
"type": "string",
"description": "Relative path to main QML component file. Required unless 'components' is provided.",
"description": "Relative path to main QML component file",
"pattern": "^\\./.*\\.qml$"
},
"components": {
"type": "object",
"description": "Map of surface name to relative QML component path, for multi-surface (composite) plugins. Provide any subset of surfaces; each is loaded independently.",
"properties": {
"widget": {
"type": "string",
"description": "Bar/Control Center widget component (PluginComponent)",
"pattern": "^\\./.*\\.qml$"
},
"desktop": {
"type": "string",
"description": "Desktop widget component",
"pattern": "^\\./.*\\.qml$"
},
"daemon": {
"type": "string",
"description": "Background daemon component (instantiated once)",
"pattern": "^\\./.*\\.qml$"
},
"launcher": {
"type": "string",
"description": "Launcher provider component (requires 'trigger')",
"pattern": "^\\./.*\\.qml$"
}
},
"additionalProperties": false,
"minProperties": 1
},
"trigger": {
"type": "string",
"description": "Trigger string for launcher activation (required for launcher type)"
@@ -98,26 +71,14 @@
"description": "Path to settings component QML file",
"pattern": "^\\./.*\\.qml$"
},
"startupCheck": {
"type": "string",
"description": "Path to a non-visual (QtObject) component exposing a check(done) function that gates activation. done(null) allows; done(error) blocks, where error is a string or { title, details }.",
"pattern": "^\\./.*\\.qml$"
},
"requires_dms": {
"type": "string",
"description": "Minimum DMS version requirement (e.g., '>=0.1.18', '>0.1.0')",
"pattern": "^(>=?|<=?|=|>|<)\\d+\\.\\d+\\.\\d+$"
},
"dependencies": {
"type": "array",
"description": "Array of required system tools/dependencies (registry metadata)",
"items": {
"type": "string"
}
},
"requires": {
"type": "array",
"description": "Deprecated alias for 'dependencies'.",
"description": "Array of required system tools/dependencies",
"items": {
"type": "string"
}
@@ -148,29 +109,6 @@
"then": {
"required": ["trigger"]
}
},
{
"if": {
"required": ["components"],
"properties": {
"components": {
"required": ["launcher"]
}
}
},
"then": {
"required": ["trigger"]
}
},
{
"anyOf": [
{
"required": ["component"]
},
{
"required": ["components"]
}
]
}
],
"additionalProperties": true
@@ -166,16 +166,6 @@ function increment() {
| Cross-instance sync (multi-monitor data) | `PluginGlobalVar` or `getGlobalVar`/`setGlobalVar` | No (runtime only) | All instances |
| Quick reactive reads from settings | `pluginData` property | N/A (read-only) | Per instance |
## Plugin Path
Retrieve a plugin's installation directory at runtime:
```qml
var dir = pluginService.getPluginPath(pluginId)
```
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
1. **pluginData is reactive** - bindings update automatically when data changes
@@ -9,22 +9,15 @@
| `description` | string | Short description (shown in UI) | Non-empty |
| `version` | string | Semantic version | Pattern `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$` |
| `author` | string | Creator name or email | Non-empty |
| `type` | string | Plugin type | One of: `widget`, `daemon`, `launcher`, `desktop`, `composite` |
| `type` | string | Plugin type | One of: `widget`, `daemon`, `launcher`, `desktop` |
| `capabilities` | array | Plugin capabilities | At least 1 string item |
One of `component` or `components` is required (not both):
| Field | Type | Description | Validation |
|-------|------|-------------|------------|
| `component` | string | Path to main QML file (single-surface plugins) | Must start with `./`, end with `.qml` |
| `components` | object | Map of surface name to QML path (multi-surface plugins) | At least 1 entry; keys: `widget`, `desktop`, `daemon`, `launcher` |
| `component` | string | Path to main QML file | Must start with `./`, end with `.qml` |
## Conditional Requirements
| Condition | Required Field | Description |
|-----------|---------------|-------------|
| `type: "launcher"` | `trigger` | Trigger string for launcher activation (e.g., `=`, `#`, `!`) |
| `components` has `launcher` key | `trigger` | Same requirement applies to composite plugins with a launcher surface |
## Optional Fields
@@ -32,10 +25,8 @@ One of `component` or `components` is required (not both):
|-------|------|-------------|
| `icon` | string | Material Design icon name (displayed in plugin list UI) |
| `settings` | string | Path to settings QML file (must start with `./`, end with `.qml`) |
| `startupCheck` | string | Path to a QtObject component that gates plugin activation via a `check(done)` function (must start with `./`, end with `.qml`). See Startup Check section below. |
| `requires_dms` | string | Minimum DMS version (e.g., `>=0.1.18`), pattern `^(>=?\|<=?\|=\|>\|<)\d+\.\d+\.\d+$` |
| `dependencies` | array | System tool dependencies (e.g., `["curl", "jq"]`). Registry metadata. |
| `requires` | array | Deprecated alias for `dependencies` |
| `requires` | array | System tool dependencies (e.g., `["curl", "jq"]`) |
| `permissions` | array | Required permissions |
| `trigger` | string | Launcher trigger string (required for launcher type) |
@@ -62,65 +53,6 @@ Capabilities are free-form strings that describe what the plugin does. Common va
- `ai` - AI/LLM integration
- `slideout` - uses slideout panel
## Startup Check
The `startupCheck` field points to a non-visual `QtObject` component that gates plugin activation on dependency checks. The component must expose a `check(done)` function:
```qml
import QtQuick
import qs.Common
QtObject {
function check(done) {
Proc.runCommand("myPlugin.depCheck", ["sh", "-c", "command -v mytool"], (stdout, exitCode) => {
if (exitCode === 0) {
done(null);
return;
}
done({
"title": I18n.tr("mytool is required"),
"details": I18n.tr("Install 'mytool' and re-enable this plugin.")
});
});
}
}
```
The `done` callback accepts:
- `null` - allow activation
- A string - block with a short error message
- `{ title, details }` - block with a title and expandable details
A synchronous variant (no `done` parameter, return the result directly) is also supported.
Failed startup checks show a toast error and store the error in `pluginService.pluginLoadErrors`.
## Components (Composite Plugins)
The `components` field maps surface names to QML paths, allowing a single plugin to register multiple surfaces:
```json
{
"id": "myComposite",
"name": "My Composite Plugin",
"description": "Daemon + widget + desktop from one plugin",
"version": "1.0.0",
"author": "Developer Name",
"type": "composite",
"capabilities": ["daemon", "dankbar-widget", "desktop-widget"],
"icon": "extension",
"components": {
"daemon": "./MyDaemon.qml",
"widget": "./MyBarWidget.qml",
"desktop": "./MyDesktopWidget.qml"
},
"settings": "./Settings.qml",
"permissions": ["settings_read", "settings_write"]
}
```
Valid surface keys: `widget`, `desktop`, `daemon`, `launcher`. Provide any subset. Each surface is loaded independently in the appropriate registry.
## Complete Example
```json
@@ -135,9 +67,8 @@ Valid surface keys: `widget`, `desktop`, `daemon`, `launcher`. Provide any subse
"component": "./MyWidget.qml",
"icon": "extension",
"settings": "./Settings.qml",
"startupCheck": "./StartupCheck.qml",
"requires_dms": ">=0.1.18",
"dependencies": ["curl", "jq"],
"requires": ["curl", "jq"],
"permissions": ["settings_read", "settings_write", "process", "network"]
}
```
@@ -235,12 +235,10 @@ Conditionally show/hide the bar pill:
```qml
PluginComponent {
visibilityCommand: "pgrep -x myapp"
visibilityInterval: 5 // seconds between checks
visibilityInterval: 5000 // check every 5 seconds
}
```
**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.
## Popout Namespace
For plugins with multiple popout instances, use `layerNamespacePlugin` to isolate popout state:
-31
View File
@@ -1,31 +0,0 @@
## Description
<!-- What does this PR do and why? -->
## Type of change
<!-- Check all that apply. -->
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that changes existing behavior)
- [ ] Refactor / internal cleanup
- [ ] Documentation
- [ ] Other
## Related issues
<!-- e.g. "Fixes #123", "Closes #123". Leave blank if none. -->
## Screenshots / video
<!-- Include screenshots or a video for any user-facing or visual change. -->
## Checklist
- [ ] My code follows the conventions in CONTRIBUTING.md
- [ ] I have tested my changes locally
- [ ] New user-facing strings are wrapped in `I18n.tr()` with translator context, reusing existing terms where possible
- [ ] Go changes: ran `make fmt`, added/updated tests, `make test` passes, and `go mod tidy` is clean
- [ ] QML changes: ran `make lint-qml` with no new warnings
- [ ] I have opened a corresponding pull request in dlx-docs to document any new behaviors: https://github.com/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: ["*"]
-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
-56
View File
@@ -1,56 +0,0 @@
name: Sync flake.lock to dank-qml-common
on:
workflow_dispatch:
push:
paths:
- "dank-qml-common"
branches:
- master
permissions:
contents: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v1
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 dank-qml-common --object-only)
flake_rev=$(python3 -c "import json; print(json.load(open('flake.lock'))['nodes']['dank-qml-common']['locked']['rev'])")
[ "$submodule_rev" = "$flake_rev" ] && { echo "flake.lock already matches $submodule_rev"; exit 0; }
nix flake lock --override-input dank-qml-common "github:AvengeMedia/dank-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 "dms-ci[bot]"
git config user.email "dms-ci[bot]@users.noreply.github.com"
git add flake.lock
git commit -m "nix: sync flake.lock to dank-qml-common submodule"
git pull --rebase origin ${{ github.ref_name }}
git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:${{ github.ref_name }}
+2 -55
View File
@@ -7,17 +7,14 @@ on:
permissions:
contents: write
issues: write
jobs:
update-stable:
# skip prerelease tags
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
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
@@ -31,54 +28,4 @@ jobs:
- name: Push to stable branch
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
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"
run: git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:refs/heads/stable --force
+2 -35
View File
@@ -8,7 +8,7 @@ on:
- "core/**"
- ".github/workflows/go-ci.yml"
pull_request:
branches: [master, main, "stable-*"]
branches: [master, main]
paths:
- "core/**"
- ".github/workflows/go-ci.yml"
@@ -27,8 +27,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
- name: Install flatpak
run: sudo apt update && sudo apt install -y flatpak
@@ -50,39 +48,8 @@ jobs:
- name: Build dms
run: go build -v ./cmd/dms
- name: Build dms (embedded shell)
run: make build
- 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
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
- 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:
pull_request:
branches: [master, main, "stable-*"]
branches: [master, main]
paths:
- "flake.*"
- "distro/nix/**"
@@ -33,6 +33,8 @@ jobs:
- name: Run NixOS service start test
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
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 "dms-ci[bot]"
git config user.email "dms-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 release/*)"
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 "dms-ci[bot]"
git config user.email "dms-ci[bot]@users.noreply.github.com"
for sha in $(jq -r '.[].id' <<<"$COMMITS"); do
git cat-file -e "$sha" 2>/dev/null || continue
# 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 "dms-ci[bot]"
git config user.email "dms-ci[bot]@users.noreply.github.com"
targets=$(jq -r '.[] | select(startswith("port ")) | sub("^port +"; "")' <<<"$LABELS" |
sed -E 's,^stable-,,' | sort -u)
[ -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
+2 -4
View File
@@ -3,15 +3,13 @@ name: Pre-commit Checks
on:
push:
pull_request:
branches: [master, main, "stable-*"]
branches: [master, main]
jobs:
pre-commit-check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
- name: Install flatpak
run: sudo apt update && sudo apt install -y flatpak
@@ -28,4 +26,4 @@ jobs:
go-version-file: core/go.mod
- name: run pre-commit hooks
uses: j178/prek-action@v2
uses: j178/prek-action@v1
+50 -130
View File
@@ -7,16 +7,10 @@ on:
description: "Tag to release (e.g., v1.0.1)"
required: true
type: string
notify_issues:
description: "Comment on open related/fixes issues asking reporters to retest"
required: false
type: boolean
default: false
permissions:
contents: write
actions: write
issues: write
concurrency:
group: release-${{ inputs.tag }}
@@ -27,11 +21,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- { goos: linux, arch: amd64 }
- { goos: linux, arch: arm64 }
- { goos: freebsd, arch: amd64 }
- { goos: freebsd, arch: arm64 }
arch: [amd64, arm64]
defaults:
run:
@@ -39,8 +29,6 @@ jobs:
env:
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:
- name: Checkout
@@ -48,7 +36,6 @@ jobs:
with:
ref: ${{ inputs.tag }}
fetch-depth: 0
submodules: recursive
- name: Set up Go
uses: actions/setup-go@v6
@@ -56,7 +43,6 @@ jobs:
go-version-file: ./core/go.mod
- name: Format check
if: matrix.goos == 'linux'
run: |
if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then
echo "The following files are not formatted:"
@@ -65,43 +51,38 @@ jobs:
fi
- name: Run tests
if: matrix.goos == 'linux'
run: go test -v ./...
- name: Build dankinstall (${{ env.ASSET }})
if: matrix.goos == 'linux'
- name: Build dankinstall (${{ matrix.arch }})
env:
GOOS: ${{ matrix.goos }}
GOOS: linux
CGO_ENABLED: 0
GOARCH: ${{ matrix.arch }}
run: |
set -eux
cd cmd/dankinstall
go build -trimpath -ldflags "-s -w -X main.Version=${TAG}" \
-o ../../dankinstall-${{ env.ASSET }}
-o ../../dankinstall-${{ matrix.arch }}
cd ../..
gzip -9 -k dankinstall-${{ env.ASSET }}
sha256sum dankinstall-${{ env.ASSET }}.gz > dankinstall-${{ env.ASSET }}.gz.sha256
gzip -9 -k dankinstall-${{ matrix.arch }}
sha256sum dankinstall-${{ matrix.arch }}.gz > dankinstall-${{ matrix.arch }}.gz.sha256
- name: Sync embedded shell
run: make sync-shell
- name: Build dms (${{ env.ASSET }})
- name: Build dms (${{ matrix.arch }})
env:
GOOS: ${{ matrix.goos }}
GOOS: linux
CGO_ENABLED: 0
GOARCH: ${{ matrix.arch }}
run: |
set -eux
cd cmd/dms
go build -trimpath -tags withshell -ldflags "-s -w -X main.Version=${TAG}" \
-o ../../dms-${{ env.ASSET }}
go build -trimpath -ldflags "-s -w -X main.Version=${TAG}" \
-o ../../dms-${{ matrix.arch }}
cd ../..
gzip -9 -k dms-${{ env.ASSET }}
sha256sum dms-${{ env.ASSET }}.gz > dms-${{ env.ASSET }}.gz.sha256
gzip -9 -k dms-${{ matrix.arch }}
sha256sum dms-${{ matrix.arch }}.gz > dms-${{ matrix.arch }}.gz.sha256
- name: Generate shell completions
if: matrix.goos == 'linux' && matrix.arch == 'amd64'
if: matrix.arch == 'amd64'
run: |
set -eux
chmod +x dms-amd64
@@ -109,58 +90,46 @@ jobs:
./dms-amd64 completion fish > completion.fish
./dms-amd64 completion zsh > completion.zsh
- name: Build dms-distropkg (${{ env.ASSET }})
- name: Build dms-distropkg (${{ matrix.arch }})
env:
GOOS: ${{ matrix.goos }}
GOOS: linux
CGO_ENABLED: 0
GOARCH: ${{ matrix.arch }}
run: |
set -eux
cd cmd/dms
go build -trimpath -tags 'distro_binary withshell' -ldflags "-s -w -X main.Version=${TAG}" \
-o ../../dms-distropkg-${{ env.ASSET }}
go build -trimpath -tags distro_binary -ldflags "-s -w -X main.Version=${TAG}" \
-o ../../dms-distropkg-${{ matrix.arch }}
cd ../..
gzip -9 -k dms-distropkg-${{ env.ASSET }}
sha256sum dms-distropkg-${{ env.ASSET }}.gz > dms-distropkg-${{ env.ASSET }}.gz.sha256
gzip -9 -k dms-distropkg-${{ matrix.arch }}
sha256sum dms-distropkg-${{ matrix.arch }}.gz > dms-distropkg-${{ matrix.arch }}.gz.sha256
- name: Upload artifacts (${{ env.ASSET }})
if: matrix.goos == 'linux' && matrix.arch == 'arm64'
- name: Upload artifacts (${{ matrix.arch }})
if: matrix.arch == 'arm64'
uses: actions/upload-artifact@v5
with:
name: core-assets-${{ env.ASSET }}
name: core-assets-${{ matrix.arch }}
path: |
core/dankinstall-${{ env.ASSET }}.gz
core/dankinstall-${{ env.ASSET }}.gz.sha256
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
- 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
core/dankinstall-${{ matrix.arch }}.gz
core/dankinstall-${{ matrix.arch }}.gz.sha256
core/dms-${{ matrix.arch }}.gz
core/dms-${{ matrix.arch }}.gz.sha256
core/dms-distropkg-${{ matrix.arch }}.gz
core/dms-distropkg-${{ matrix.arch }}.gz.sha256
if-no-files-found: error
- name: Upload artifacts with completions
if: matrix.goos == 'linux' && matrix.arch == 'amd64'
if: matrix.arch == 'amd64'
uses: actions/upload-artifact@v5
with:
name: core-assets-${{ env.ASSET }}
name: core-assets-${{ matrix.arch }}
path: |
core/dankinstall-${{ env.ASSET }}.gz
core/dankinstall-${{ env.ASSET }}.gz.sha256
core/dms-${{ env.ASSET }}.gz
core/dms-${{ env.ASSET }}.gz.sha256
core/dms-distropkg-${{ env.ASSET }}.gz
core/dms-distropkg-${{ env.ASSET }}.gz.sha256
core/dankinstall-${{ matrix.arch }}.gz
core/dankinstall-${{ matrix.arch }}.gz.sha256
core/dms-${{ matrix.arch }}.gz
core/dms-${{ matrix.arch }}.gz.sha256
core/dms-distropkg-${{ matrix.arch }}.gz
core/dms-distropkg-${{ matrix.arch }}.gz.sha256
core/completion.bash
core/completion.fish
core/completion.zsh
@@ -216,13 +185,11 @@ jobs:
with:
ref: ${{ inputs.tag }}
fetch-depth: 0
submodules: recursive
- name: Fetch updated tag after version bump
run: |
git fetch origin --force tag ${TAG}
git checkout ${TAG}
git submodule update --init --recursive
- name: Set up Go
uses: actions/setup-go@v6
@@ -238,23 +205,13 @@ jobs:
- name: Generate Changelog
id: changelog
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "")
CHANGELOG=""
if [ -n "$PREVIOUS_TAG" ]; then
# PR-based notes with author credits; falls back to raw git log below
CHANGELOG=$(python3 scripts/release-notes.py "${PREVIOUS_TAG}..${TAG}" --format github --bare 2>/dev/null || true)
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
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
cat > RELEASE_BODY.md << 'EOF'
@@ -269,23 +226,16 @@ jobs:
### Complete Packages
- **`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-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
- **`dms-cli-amd64.gz`** - DMS CLI binary for x86_64 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-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-arm64.gz`** - Installer binary for ARM64 systems
- **`dms-cli-<version>.tar.gz`** - Go source code with vendored modules (for distro packaging)
- **`dms-qml.tar.gz`** - QML source code only
- **`dms-source.tar.gz`** - Full repository source with bundled DankCommon (for distro packaging)
### Checksums
- **`*.sha256`** - SHA256 checksums for verifying download integrity
@@ -333,22 +283,6 @@ jobs:
# Copy completions
cp _core_assets/completion.* _release_assets/ 2>/dev/null || true
# Replace the DankCommon symlink with real submodule content for packaging
rm quickshell/DankCommon
cp -r dank-qml-common/DankCommon quickshell/DankCommon
# Create full source tarball (GitHub tag archives never contain submodule content)
VERSION_NUM=${TAG#v}
tar --exclude='.git' \
--exclude='.github' \
--exclude='dank-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)
# Copy root LICENSE and CONTRIBUTING.md to quickshell/ for packaging
cp LICENSE CONTRIBUTING.md quickshell/
@@ -365,8 +299,8 @@ jobs:
# Generate checksum for QML package
(cd _release_assets && sha256sum dms-qml.tar.gz > dms-qml.tar.gz.sha256)
# Create full packages for each os/architecture
for arch in amd64 arm64 freebsd-amd64 freebsd-arm64; do
# Create full packages for each architecture
for arch in amd64 arm64; do
mkdir -p _temp_full/dms
mkdir -p _temp_full/bin
mkdir -p _temp_full/completions
@@ -409,16 +343,18 @@ jobs:
## Installation Steps
The Quickshell UI is embedded in the `dms` binary. The bundled `dms/`
tree is optional — pass it with `-c` or `DMS_SHELL_DIR` to run a
modified copy instead of the embedded UI.
1. **Install quickshell assets:**
```bash
mkdir -p ~/.config/quickshell
cp -r dms ~/.config/quickshell/
```
1. **Install the DMS CLI binaries:**
2. **Install the DMS CLI binaries:**
```bash
sudo install -m 755 bin/dms /usr/local/bin/dms
```
2. **Install shell completions (optional):**
3. **Install shell completions (optional):**
```bash
# Bash
sudo install -m 644 completions/completion.bash /usr/share/bash-completion/completions/dms
@@ -430,7 +366,7 @@ jobs:
sudo install -m 644 completions/completion.zsh /usr/share/zsh/site-functions/_dms
```
3. **Start the shell:**
4. **Start the shell:**
```bash
dms run
```
@@ -481,19 +417,3 @@ jobs:
prerelease: ${{ contains(env.TAG, '-') }}
env:
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"
+10 -2
View File
@@ -4,12 +4,14 @@ on:
workflow_dispatch:
inputs:
package:
description: 'Package to build'
description: 'Package to build (dms, dms-greeter, or both)'
required: false
default: 'dms'
type: choice
options:
- dms
- dms-greeter
- both
version:
description: 'Versioning (e.g., 1.0.3, leave empty for latest release)'
required: false
@@ -29,7 +31,11 @@ jobs:
id: set-packages
run: |
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:
needs: determine-packages
@@ -157,6 +163,8 @@ jobs:
PACKAGE="${{ matrix.package }}"
if [ "$PACKAGE" = "dms" ]; then
COPR_PROJECT="avengemedia/dms"
elif [ "$PACKAGE" = "dms-greeter" ]; then
COPR_PROJECT="avengemedia/danklinux"
else
echo "❌ Unknown package: $PACKAGE"
exit 1
+57 -12
View File
@@ -9,6 +9,7 @@ on:
type: choice
options:
- dms
- dms-greeter
- dms-git
- all
default: "dms"
@@ -23,7 +24,6 @@ jobs:
check-updates:
name: Check for updates
runs-on: ubuntu-latest
if: github.repository == 'AvengeMedia/DankMaterialShell'
outputs:
has_updates: ${{ steps.check.outputs.has_updates }}
@@ -35,7 +35,6 @@ jobs:
uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive
- name: Check for updates
id: check
@@ -74,12 +73,27 @@ jobs:
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
REBUILD="${{ github.event.inputs.rebuild_release }}"
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]] && [[ -z "${{ github.event.inputs.package }}" ]]; then
# Run from tag with no package specified - update stable package
echo "packages=dms" >> $GITHUB_OUTPUT
# Run from tag with no package specified - update both stable packages
echo "packages=dms dms-greeter" >> $GITHUB_OUTPUT
VERSION="${GITHUB_REF#refs/tags/}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "has_updates=true" >> $GITHUB_OUTPUT
@@ -105,7 +119,7 @@ jobs:
echo "🔄 Manual rebuild requested: $PKG (db$REBUILD)"
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=()
if check_dms_stable; then
PACKAGES_TO_UPDATE+=("dms")
@@ -113,6 +127,10 @@ jobs:
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
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
echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT
@@ -121,7 +139,7 @@ jobs:
else
echo "packages=" >> $GITHUB_OUTPUT
echo "has_updates=false" >> $GITHUB_OUTPUT
echo "✓ All packages up to date"
echo "✓ Both packages up to date"
fi
elif [[ "$PKG" == "dms-git" ]]; then
@@ -145,6 +163,18 @@ jobs:
echo "has_updates=false" >> $GITHUB_OUTPUT
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
# Unknown package - proceed anyway
echo "packages=$PKG" >> $GITHUB_OUTPUT
@@ -168,7 +198,6 @@ jobs:
uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive
- name: Wait before OBS upload
run: sleep 3
@@ -189,9 +218,9 @@ jobs:
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
# 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
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/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
if [[ -n "$LATEST_TAG" ]]; then
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
@@ -257,7 +286,7 @@ jobs:
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE"
} > "distro/debian/dms-git/debian/changelog"
- name: Update stable version (dms)
- name: Update stable version (dms + dms-greeter)
if: steps.packages.outputs.version != ''
run: |
VERSION="${{ steps.packages.outputs.version }}"
@@ -293,12 +322,25 @@ jobs:
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 <AvengeMedia.US@gmail.com> $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)
for service in distro/debian/*/_service; do
if [[ -f "$service" ]]; then
# Update tar_scm revision parameter (for dms-git)
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|/tags/v[0-9.]\+\.tar\.gz|/tags/$VERSION.tar.gz|g" "$service"
fi
@@ -359,7 +401,7 @@ jobs:
UPLOADED_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
for PKG in $PACKAGES; do
echo ""
@@ -462,6 +504,9 @@ jobs:
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
;;
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
done
echo "" >> $GITHUB_STEP_SUMMARY
+3 -3
View File
@@ -9,6 +9,7 @@ on:
type: choice
options:
- dms
- dms-greeter
- dms-git
- all
default: "dms"
@@ -23,7 +24,6 @@ jobs:
check-updates:
name: Check package/series updates
runs-on: ubuntu-latest
if: github.repository == 'AvengeMedia/DankMaterialShell'
outputs:
has_updates: ${{ steps.check.outputs.has_updates }}
@@ -35,7 +35,6 @@ jobs:
uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive
- name: Install dependencies
run: |
@@ -94,7 +93,6 @@ jobs:
uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive
- name: Set up Go
uses: actions/setup-go@v6
@@ -134,6 +132,7 @@ jobs:
case "$PACKAGE" in
dms) PPA_NAME="dms" ;;
dms-git) PPA_NAME="dms-git" ;;
dms-greeter) PPA_NAME="danklinux" ;;
*) echo "::error::Unknown package $PACKAGE"; exit 1 ;;
esac
@@ -148,3 +147,4 @@ jobs:
echo "- **Target:** ${{ matrix.target }}" >> "$GITHUB_STEP_SUMMARY"
echo "- **DMS PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages" >> "$GITHUB_STEP_SUMMARY"
echo "- **DMS-Git PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages" >> "$GITHUB_STEP_SUMMARY"
echo "- **DankLinux PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages" >> "$GITHUB_STEP_SUMMARY"
-313
View File
@@ -1,313 +0,0 @@
name: Void Linux XBPS Repository
on:
schedule:
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
# release trigger disabled; dispatch manually after a release
# release:
# types: [published]
workflow_dispatch:
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:
description: 'Force rebuilding packages even if they already exist in the repository'
type: boolean
required: false
default: false
build_git:
description: 'Build dms-git package'
type: boolean
required: false
default: true
build_dms:
description: 'Build stable dms package'
type: boolean
required: false
default: true
permissions:
contents: read
concurrency:
group: void-xbps-${{ github.ref }}
cancel-in-progress: false
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:
name: Build & Deploy XBPS packages
runs-on: ubuntu-latest
needs: preflight
if: github.repository == 'AvengeMedia/DankMaterialShell' && needs.preflight.outputs.should_build == 'true'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
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:
- name: Checkout repository
uses: actions/checkout@v6
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
submodules: recursive
- name: Record checked-out source commit
run: echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
- name: Download current R2 repository
run: |
aws --version
bash .github/scripts/publish-void-r2.sh download
- name: Install XBPS Static Utilities
run: |
mkdir -p ${{ github.workspace }}/xbps
curl -s -L https://repo-default.voidlinux.org/static/xbps-static-latest.x86_64-musl.tar.xz | tar -xJ -C ${{ github.workspace }}/xbps
echo "${{ github.workspace }}/xbps/usr/bin" >> $GITHUB_PATH
- name: Clone void-packages
run: |
git clone --depth=1 https://github.com/void-linux/void-packages.git
- name: Inject templates
run: |
cp -R distro/void/srcpkgs/dms void-packages/srcpkgs/
cp -R distro/void/srcpkgs/dms-git void-packages/srcpkgs/
- name: Enable unprivileged user namespaces (Ubuntu 24.04)
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
- name: Bootstrap xbps-src
run: |
cd void-packages
./xbps-src binary-bootstrap
- name: Configure signing keys and trust
run: |
# Write private key; extract and register the public key
echo "${{ secrets.XBPS_PRIVATE_KEY }}" > /tmp/xbps_privkey.pem
chmod 600 /tmp/xbps_privkey.pem
# Extract public key in PEM format
openssl rsa -in /tmp/xbps_privkey.pem -pubout -out /tmp/dms-key.pub
rm -f /tmp/xbps_privkey.pem
# Compute MD5 fingerprint in colon-separated hex format
FINGERPRINT=$(openssl rsa -pubin -in /tmp/dms-key.pub -outform DER 2>/dev/null | openssl dgst -md5 -c | tr '[:upper:]' '[:lower:]' | awk '{print $NF}')
# Format key in XML property list (plist) format as expected by xbps
mkdir -p /tmp/keys
cat <<EOF > "/tmp/keys/${FINGERPRINT}.plist"
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>public-key</key>
<data>$(base64 -w0 /tmp/dms-key.pub)</data>
<key>public-key-size</key>
<integer>4096</integer>
<key>signature-by</key>
<string>AvengeMedia</string>
</dict>
</plist>
EOF
# Copy keys to all host and chroot trust stores
sudo mkdir -p /var/db/xbps/keys
sudo cp "/tmp/keys/${FINGERPRINT}.plist" "/var/db/xbps/keys/${FINGERPRINT}.plist"
mkdir -p void-packages/masterdir/var/db/xbps/keys
cp "/tmp/keys/${FINGERPRINT}.plist" "void-packages/masterdir/var/db/xbps/keys/${FINGERPRINT}.plist"
mkdir -p void-packages/etc/xbps.d/keys
cp "/tmp/keys/${FINGERPRINT}.plist" "void-packages/etc/xbps.d/keys/${FINGERPRINT}.plist"
mkdir -p void-packages/common/repo-keys
cp "/tmp/keys/${FINGERPRINT}.plist" "void-packages/common/repo-keys/${FINGERPRINT}.plist"
rm -rf /tmp/keys /tmp/dms-key.pub
- name: Configure repositories
run: |
# Append the repository to repos-remote templates so xbps-src translates it automatically
echo "repository=https://void.danklinux.com/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
# Add any existing compiled packages to the build cache directory to avoid rebuilds
if [ -d "r2-repo/current" ]; then
mkdir -p void-packages/hostdir/binpkgs
cp -L r2-repo/current/*.xbps void-packages/hostdir/binpkgs/ 2>/dev/null || true
xbps-rindex -a void-packages/hostdir/binpkgs/*.xbps 2>/dev/null || true
fi
- name: Build packages
run: |
mkdir -p r2-repo/current
BUILD_DMS="${{ github.event.inputs.build_dms || 'true' }}"
BUILD_GIT="${{ github.event.inputs.build_git || 'true' }}"
FORCE_REBUILD="${{ github.event.inputs.force_rebuild || 'false' }}"
if [ "${{ github.event_name }}" = "schedule" ]; then
BUILD_DMS="false"
BUILD_GIT="true"
fi
if [ "${{ github.event_name }}" = "release" ]; then
BUILD_DMS="true"
BUILD_GIT="false"
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 "DMS stable build enabled: $BUILD_DMS"
echo "Git build enabled: $BUILD_GIT"
echo "Force rebuild: $FORCE_REBUILD"
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)
if [ "$BUILD_GIT" = "true" ]; then
# Calculate dynamic git version (tag.commits.hash)
GIT_VER=$(git -C .. describe --tags --always | sed 's/^v//; s/-/./g')
echo "🔨 Preparing dms-git version $GIT_VER"
# Stage source tarball in the xbps-src source cache.
# --transform adds a top-level directory so xbps-src can extract
# into $wrksrc (create_wrksrc=yes handles the rest).
SRC_CACHE="hostdir/sources/dms-git-${GIT_VER}"
mkdir -p "$SRC_CACHE"
tar -czhf "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" \
--exclude=void-packages \
--exclude=r2-repo \
--exclude=.git \
--exclude=danklinux \
-C .. .
CHECKSUM=$(sha256sum "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" | cut -d' ' -f1)
# Dynamically patch template version, checksum, and distfiles
sed -i "s/^version=.*/version=${GIT_VER}/" srcpkgs/dms-git/template
sed -i "s/^checksum=.*/checksum=${CHECKSUM}/" srcpkgs/dms-git/template
sed -i "s|^distfiles=.*|distfiles=\"dms-git-${GIT_VER}.tar.gz\"|" srcpkgs/dms-git/template
EXPECTED_GIT_FILE="dms-git-${GIT_VER}_1.x86_64.xbps"
if [ -f "../r2-repo/current/$EXPECTED_GIT_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
echo "✅ $EXPECTED_GIT_FILE already exists, skipping build."
else
echo "🔨 Compiling dms-git..."
./xbps-src pkg dms-git
rm -f "../r2-repo/current/${EXPECTED_GIT_FILE}"
cp -L "hostdir/binpkgs/${EXPECTED_GIT_FILE}" ../r2-repo/current/
fi
fi
# 2. Build stable dms package
if [ "$BUILD_DMS" = "true" ]; then
STABLE_VER=$(grep -E '^version=' srcpkgs/dms/template | cut -d= -f2 | tr -d '"')
STABLE_REV=$(grep -E '^revision=' srcpkgs/dms/template | cut -d= -f2 | tr -d '"')
EXPECTED_DMS_FILE="dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps"
if [ -f "../r2-repo/current/$EXPECTED_DMS_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
echo "✅ $EXPECTED_DMS_FILE already exists, skipping build."
else
echo "🔨 Compiling dms ($STABLE_VER)..."
./xbps-src pkg dms
rm -f "../r2-repo/current/${EXPECTED_DMS_FILE}"
cp -L hostdir/binpkgs/dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps ../r2-repo/current/
fi
fi
- name: Index and sign repository
run: |
cd r2-repo/current
rm -f *.sig
# Guard: nothing to index if no .xbps files exist
if ! ls *.xbps 1>/dev/null 2>&1; then
echo "⚠️ No .xbps files found to index, skipping."
exit 0
fi
# Regenerate repo index
xbps-rindex -a $(pwd)/*.xbps
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
echo "${{ secrets.XBPS_PRIVATE_KEY }}" > /tmp/xbps_privkey.pem
chmod 600 /tmp/xbps_privkey.pem
xbps-rindex --sign --signedby "AvengeMedia <AvengeMedia.US@gmail.com>" --privkey /tmp/xbps_privkey.pem $(pwd)
xbps-rindex --sign-pkg --privkey /tmp/xbps_privkey.pem $(pwd)/*.xbps
rm -f /tmp/xbps_privkey.pem
- name: Publish repository to R2
run: bash .github/scripts/publish-void-r2.sh publish
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v3
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
-16
View File
@@ -110,24 +110,8 @@ bin/
# Core dumps
core.*
# prek-installed local git hooks (generated from .pre-commit-config.yaml)
.githooks/
# direnv
.envrc
.direnv/
quickshell/dms-plugins
__pycache__
.vscode/
# Void (xbps) build artifacts
*.xbps
distro/void/temp/
distro/void/hostdir/
distro/void/masterdir*/
# Often gets built
core/dms
core/internal/shellembed/dist
-4
View File
@@ -1,4 +0,0 @@
[submodule "dank-qml-common"]
path = dank-qml-common
url = https://github.com/AvengeMedia/dank-qml-common.git
branch = master
-24
View File
@@ -20,30 +20,6 @@ repos:
language: system
files: ^core/.*\.(go|mod|sum)$
pass_filenames: false
- repo: local
hooks:
- id: settings-search-index
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'
language: system
files: ^quickshell/(Modules/Settings/.*\.qml|Modals/Settings/SettingsSidebar\.qml|translations/extract_settings_index\.py)$
pass_filenames: false
- repo: local
hooks:
- id: i18n-term-freeze
name: i18n term freeze (no new I18n.tr/qsTr terms)
entry: python3 quickshell/translations/check_term_freeze.py
language: system
files: ^quickshell/(.*\.qml|translations/(term_freeze\.json|check_term_freeze\.py|extract_translations\.py))$
pass_filenames: false
- repo: local
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
hooks:
- id: no-console-in-qml
-8
View File
@@ -1,13 +1,5 @@
This file is more of a quick reference so I know what to account for before next releases.
# Next
- 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
- Overhauled shadows
- App ID changed to com.danklinux.dms - breaking for window rules
-51
View File
@@ -6,14 +6,6 @@ To contribute fork this repository, make your changes, and open a pull request.
## Setup
Clone with submodules — the shared widget library ([dank-qml-common](https://github.com/AvengeMedia/dank-qml-common)) is vendored at `dank-qml-common/` and symlinked into `quickshell/DankCommon`:
```bash
git clone --recurse-submodules https://github.com/AvengeMedia/DankMaterialShell.git
# or, in an existing clone:
git submodule update --init
```
Install [prek](https://prek.j178.dev/) then activate pre-commit hooks:
```bash
@@ -36,47 +28,6 @@ This will provide:
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/AvengeMedia/dankgo) for logging, XDG paths, the IPC transport, and the quickshell process lifecycle. To develop against a local dankgo checkout, create a gitignored `go.work` at the repo root:
```
go 1.26.1
use (
./core
../dankgo
)
```
## Shared widgets (dank-qml-common)
Everything under `quickshell/DankCommon/` (core widgets, the file browser, scroll physics, bundled fonts) is shared across the DMS suite and lives in the `dank-qml-common` submodule. It is a normal git worktree:
1. Edit files under `dank-qml-common/` (or through the `quickshell/DankCommon` symlink — same files) and test in the running shell; hot reload works as usual. For isolated widget work, the library is its own runnable config with a gallery: `qs -c dank-qml-common`.
2. Commit and PR those changes in the `dank-qml-common` repo: `cd dank-qml-common && git switch -c my-change`, push, open the PR there.
3. Once merged, bump the pointer here: `make update-common` (updates the submodule and the nix flake input together), then commit alongside any DMS-side changes. If you only bump the submodule, CI syncs `flake.lock` to it automatically on master.
The submodule URL in `.gitmodules` is HTTPS so CI and anonymous clones keep working. To push over SSH instead of being prompted for credentials, add a push rewrite to your git config — fetches stay HTTPS, pushes use SSH:
```bash
git config --global url."git@github.com:AvengeMedia/".pushInsteadOf "https://github.com/AvengeMedia/"
```
Shared widgets read app-provided singletons (`Theme`, `SettingsData`, ...) through a documented contract — see the dank-qml-common README. If your change needs a new contract property, add it to the library's stub singletons in the same PR, then to `quickshell/Common/` here when you bump.
Files in `quickshell/Widgets/`, `quickshell/Common/`, and `quickshell/Modals/FileBrowser/` that moved to the library remain in place as thin wrappers, so `import qs.Widgets`, `qs.Common`, and `qs.Modals.FileBrowser` keep working for the shell and for plugins.
## 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.
@@ -153,8 +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)
Strings inside `quickshell/DankCommon/` are owned by the dank-qml-common repo but stay in the DMS POEditor project — extraction here deliberately skips them, and `scripts/i18nsync.py sync` uploads the union of app terms and the submodule's terms instead (common terms carry the `dank-qml-common` tag). On download the sync splits the exports: app translations go to `quickshell/translations/poexports/`, common translations go to `dank-qml-common/DankCommon/translations/poexports/` for you to commit in that repo and bump. At runtime `I18n` merges both catalogs (app terms win). Other apps (dankcalendar) keep their own POEditor projects and merge the `dank-qml-common`-tagged terms from the DMS project.
### GO (`core` directory)
1. Install the [Go Extension](https://code.visualstudio.com/docs/languages/go)
+9 -30
View File
@@ -6,8 +6,8 @@ BINARY_NAME=dms
CORE_DIR=core
BUILD_DIR=$(CORE_DIR)/bin
PREFIX ?= /usr/local
INSTALL_DIR=$(DESTDIR)$(PREFIX)/bin
DATA_DIR=$(DESTDIR)$(PREFIX)/share
INSTALL_DIR=$(PREFIX)/bin
DATA_DIR=$(PREFIX)/share
ICON_DIR=$(DATA_DIR)/icons/hicolor/scalable/apps
USER_HOME := $(if $(SUDO_USER),$(shell getent passwd $(SUDO_USER) | cut -d: -f6),$(HOME))
@@ -18,7 +18,7 @@ SHELL_INSTALL_DIR=$(DATA_DIR)/quickshell/dms
ASSETS_DIR=assets
APPLICATIONS_DIR=$(DATA_DIR)/applications
.PHONY: all build dev run 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
.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
@@ -27,12 +27,6 @@ build:
@$(MAKE) -C $(CORE_DIR) build
@echo "Build complete"
dev:
@$(MAKE) -C $(CORE_DIR) dev
run: dev
@$(BUILD_DIR)/$(BINARY_NAME) run -c $(CURDIR)/$(SHELL_DIR)
clean:
@echo "Cleaning build artifacts..."
@$(MAKE) -C $(CORE_DIR) clean
@@ -41,12 +35,6 @@ clean:
lint-qml:
@./quickshell/scripts/qmllint-entrypoints.sh
# Pull the latest dank-qml-common and pin it everywhere it is consumed
# (submodule pointer + nix flake input). Commit both in one change.
update-common:
git submodule update --remote --merge dank-qml-common
nix --extra-experimental-features 'nix-command flakes' flake update dank-qml-common
# Installation targets
install-bin:
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
@@ -55,9 +43,8 @@ install-bin:
install-shell:
@echo "Installing shell files to $(SHELL_INSTALL_DIR)..."
@test -e $(SHELL_DIR)/DankCommon/Widgets/DankIcon.qml || { echo "DankCommon missing: run git submodule update --init"; exit 1; }
@mkdir -p $(SHELL_INSTALL_DIR)
@cp -rL $(SHELL_DIR)/* $(SHELL_INSTALL_DIR)/
@cp -r $(SHELL_DIR)/* $(SHELL_INSTALL_DIR)/
@rm -rf $(SHELL_INSTALL_DIR)/.git* $(SHELL_INSTALL_DIR)/.github
@echo "Shell files installed"
@@ -72,17 +59,13 @@ install-completions:
@echo "Shell completions installed"
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..."
@mkdir -p $(SYSTEMD_USER_DIR)
@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
@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"
endif
install-icon:
@echo "Installing icon..."
@@ -91,12 +74,10 @@ install-icon:
@echo "Icon installed"
install-desktop:
@echo "Installing desktop entries..."
@echo "Installing desktop entry..."
@install -D -m 644 $(ASSETS_DIR)/dms-open.desktop $(APPLICATIONS_DIR)/dms-open.desktop
@install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
@install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.notepad.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
@echo "Desktop entries installed"
@echo "Desktop entry installed"
install: install-bin install-shell install-completions install-systemd install-icon install-desktop
@echo ""
@@ -135,12 +116,10 @@ uninstall-icon:
@echo "Icon removed"
uninstall-desktop:
@echo "Removing desktop entries..."
@echo "Removing desktop entry..."
@rm -f $(APPLICATIONS_DIR)/dms-open.desktop
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
@echo "Desktop entries removed"
@echo "Desktop entry removed"
uninstall: uninstall-systemd uninstall-desktop uninstall-icon uninstall-completions uninstall-shell uninstall-bin
@echo ""
+1 -12
View File
@@ -98,7 +98,7 @@ Notification center with grouping, rich text support, and keyboard navigation.
MPRIS player controls, calendar sync, weather widgets, and clipboard history with image previews.
**Session Management**
Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and a settings front-end for [dank-greeter](https://github.com/AvengeMedia/dank-greeter).
Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and greeter support.
**Plugin System**
Extend functionality with the [plugin registry](https://plugins.danklinux.com).
@@ -132,17 +132,6 @@ dms plugins search # Browse plugin registry
- **Plugins:** [Development guide](https://danklinux.com/docs/dankmaterialshell/plugins-overview)
- **Support:** [Ko-fi](https://ko-fi.com/avengemediallc)
## Dank Projects
DMS is one piece of the suite. The rest lives in its own repos:
- [dank-greeter](https://github.com/AvengeMedia/dank-greeter) - greetd login screen with the Dank Material aesthetic. The Greeter tab in DMS settings is the front-end for it.
- [dankcalendar](https://github.com/AvengeMedia/dankcalendar) - Local, Google, Microsoft, and CalDAV calendars for the dank desktop.
- [dgop](https://github.com/AvengeMedia/dgop) - System monitoring CLI and API that powers the process list and dashboard widgets.
- [dsearch](https://github.com/AvengeMedia/danksearch) - Fast file search that powers file results in the launcher.
- [dank-qml-common](https://github.com/AvengeMedia/dank-qml-common) - Shared QML widgets and components used by DMS, dank-greeter, and dankcalendar.
- [dankgo](https://github.com/AvengeMedia/dankgo) - Common Go modules behind the single binary apps.
## Development
See component-specific documentation:
-11
View File
@@ -1,11 +0,0 @@
[Desktop Entry]
Type=Application
Name=DankMaterialShell
GenericName=Desktop Shell
Comment=Material 3 inspired shell for Wayland compositors
Exec=dms run
Icon=danklogo
Terminal=false
NoDisplay=true
StartupNotify=false
Categories=Utility;
-12
View File
@@ -1,12 +0,0 @@
[Desktop Entry]
Type=Application
Name=DMS Notepad
GenericName=Text Editor
Comment=Quick notes and text files in the dank desktop shell
Exec=dms ipc call notepad openFile %f
Icon=danklogo
Terminal=false
NoDisplay=true
Categories=Utility;TextEditor;
MimeType=text/plain;text/markdown;
StartupNotify=false
+77 -80
View File
@@ -1,85 +1,82 @@
dir: internal/mocks/{{.InterfaceDirRelative}}
structname: Mock{{.InterfaceName}}
pkgname: '{{.SrcPackageName}}'
filename: 'mock_{{.InterfaceName}}.go'
template: testify
template-data:
unroll-variadic: true
with-expecter: true
dir: "internal/mocks/{{.InterfaceDirRelative}}"
mockname: "Mock{{.InterfaceName}}"
outpkg: "{{.PackageName}}"
packages:
github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation:
config:
dir: internal/mocks/geolocation
pkgname: mocks_geolocation
interfaces:
Client: {}
github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups:
config:
dir: internal/mocks/cups
pkgname: mocks_cups
interfaces:
CUPSClientInterface: {}
PkHelper:
config:
dir: internal/mocks/cups_pkhelper
pkgname: mocks_cups_pkhelper
github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev:
config:
dir: internal/mocks/evdev
pkgname: mocks_evdev
interfaces:
EvdevDevice: {}
github.com/AvengeMedia/DankMaterialShell/core/internal/server/network:
config:
dir: internal/mocks/network
pkgname: mocks_network
interfaces:
Backend: {}
github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext:
config:
dir: internal/mocks/wlcontext
pkgname: mocks_wlcontext
interfaces:
WaylandContext: {}
github.com/AvengeMedia/DankMaterialShell/core/internal/utils:
config:
dir: internal/mocks/utils
pkgname: mocks_utils
interfaces:
AppChecker: {}
github.com/AvengeMedia/DankMaterialShell/core/internal/version:
config:
dir: internal/mocks/version
pkgname: mocks_version
interfaces:
VersionFetcher: {}
github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client:
config:
dir: internal/mocks/wlclient
pkgname: mocks_wlclient
interfaces:
WaylandDisplay: {}
github.com/AvengeMedia/DankMaterialShell/core/internal/plugins:
interfaces:
GitClient: {}
github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness:
config:
dir: internal/mocks/brightness
pkgname: mocks_brightness
interfaces:
DBusConn: {}
github.com/Wifx/gonetworkmanager/v2:
interfaces:
AccessPoint: {}
ActiveConnection: {}
Connection: {}
Device: {}
DeviceWireless: {}
IP4Config: {}
NetworkManager: {}
Settings: {}
github.com/godbus/dbus/v5:
interfaces:
BusObject: {}
NetworkManager:
Device:
DeviceWireless:
AccessPoint:
Connection:
Settings:
ActiveConnection:
IP4Config:
net:
interfaces:
Conn: {}
Conn:
github.com/AvengeMedia/danklinux/internal/plugins:
interfaces:
GitClient:
github.com/godbus/dbus/v5:
interfaces:
BusObject:
github.com/AvengeMedia/danklinux/internal/server/brightness:
config:
dir: "internal/mocks/brightness"
outpkg: mocks_brightness
interfaces:
DBusConn:
github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation:
config:
dir: "internal/mocks/geolocation"
outpkg: mocks_geolocation
interfaces:
Client:
github.com/AvengeMedia/DankMaterialShell/core/internal/server/network:
config:
dir: "internal/mocks/network"
outpkg: mocks_network
interfaces:
Backend:
github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups:
config:
dir: "internal/mocks/cups"
outpkg: mocks_cups
interfaces:
CUPSClientInterface:
PkHelper:
config:
dir: "internal/mocks/cups_pkhelper"
outpkg: mocks_cups_pkhelper
github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev:
config:
dir: "internal/mocks/evdev"
outpkg: mocks_evdev
interfaces:
EvdevDevice:
github.com/AvengeMedia/DankMaterialShell/core/internal/version:
config:
dir: "internal/mocks/version"
outpkg: mocks_version
interfaces:
VersionFetcher:
github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext:
config:
dir: "internal/mocks/wlcontext"
outpkg: mocks_wlcontext
interfaces:
WaylandContext:
github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client:
config:
dir: "internal/mocks/wlclient"
outpkg: mocks_wlclient
interfaces:
WaylandDisplay:
github.com/AvengeMedia/DankMaterialShell/core/internal/utils:
config:
dir: "internal/mocks/utils"
outpkg: mocks_utils
interfaces:
AppChecker:
+1 -1
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/golangci/golangci-lint
rev: v2.12.2
rev: v2.10.1
hooks:
- id: golangci-lint-fmt
require_serial: true
+26 -50
View File
@@ -3,8 +3,6 @@ BINARY_NAME_INSTALL=dankinstall
SOURCE_DIR=cmd/dms
SOURCE_DIR_INSTALL=cmd/dankinstall
BUILD_DIR=bin
SHELL_SRC=../quickshell
EMBED_DIR=internal/shellembed/dist
PREFIX ?= /usr/local
INSTALL_DIR=$(PREFIX)/bin
@@ -23,46 +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)
ARCH ?= all
# Target OSes for dist builds
DIST_OSES ?= linux freebsd
ifeq ($(ARCH),all)
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
.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
# Default target
all: build
# Copy the quickshell UI into the embed dir (gitignored) so tagged builds
# can bake it into the binary. Dev-only files are stripped; scripts/ is kept
# minus its dev entries, since Theme.qml and BluetoothService.qml run
# gtk.sh/qt.sh/bluez-card-profile.lua out of the resolved shell dir.
# tar -h dereferences the DankCommon submodule symlink; go:embed rejects
# symlinks. .qmlls.ini is excluded at copy time: it's a symlink into the
# quickshell runtime VFS and dereferencing it fails whenever the shell isn't
# running.
sync-shell:
@test -e $(SHELL_SRC)/DankCommon/Widgets/DankIcon.qml || { echo "DankCommon missing: run git submodule update --init"; exit 1; }
@rm -rf $(EMBED_DIR)
@mkdir -p $(EMBED_DIR)
@tar -C $(SHELL_SRC) --exclude=.qmlls.ini -chf - . | tar -C $(EMBED_DIR) -xf -
@rm -rf $(EMBED_DIR)/.git* $(EMBED_DIR)/.github
@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: sync-shell
build:
@echo "Building $(BINARY_NAME)..."
@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)"
dankinstall:
@@ -71,18 +40,26 @@ dankinstall:
CGO_ENABLED=0 $(GO) build $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME_INSTALL) ./$(SOURCE_DIR_INSTALL)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME_INSTALL)"
# Build distro binaries (no update/greeter support) for each DIST_OSES/DIST_ARCHS pair
dist: sync-shell
@echo "Building $(BINARY_NAME) for distribution ($(DIST_OSES) x $(DIST_ARCHS))..."
# Build distro binaries for amd64 and arm64 (Linux only, no update/greeter support)
dist:
ifeq ($(ARCH),all)
@echo "Building $(BINARY_NAME) for distribution (amd64 and arm64)..."
@mkdir -p $(BUILD_DIR)
@for os in $(DIST_OSES); do \
for arch in $(DIST_ARCHS); do \
echo "Building for $$os/$$arch..."; \
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; \
echo " $(BUILD_DIR)/$(BINARY_NAME)-$$os-$$arch"; \
done; \
done
@echo "Distribution builds complete"
@echo "Building for linux/amd64..."
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 linux/arm64..."
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build -tags distro_binary $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(SOURCE_DIR)
@echo "Distribution builds complete:"
@echo " $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64"
@echo " $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
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
@@ -122,7 +99,7 @@ uninstall-dankinstall:
clean:
@echo "Cleaning build artifacts..."
@rm -rf $(BUILD_DIR) $(EMBED_DIR)
@rm -rf $(BUILD_DIR)
@echo "Clean complete"
test:
@@ -164,11 +141,10 @@ print-version:
help:
@echo "Available targets:"
@echo " all - Build the main binary (dms) (default)"
@echo " build - Build the main binary (dms) with the embedded UI"
@echo " sync-shell - Copy quickshell/ into the embed dir (runs before tagged builds)"
@echo " build - Build the main binary (dms)"
@echo " dankinstall - Build dankinstall binary"
@echo " dist - Build dms for linux/freebsd amd64/arm64 (no update/greeter)"
@echo " Use ARCH=amd64 or ARCH=arm64 and/or DIST_OSES=linux to narrow"
@echo " dist - Build dms for linux amd64/arm64 (no update/greeter)"
@echo " Use ARCH=amd64 or ARCH=arm64 to build only one"
@echo " build-all - Build both binaries"
@echo " install - Install dms to $(INSTALL_DIR)"
@echo " install-all - Install both dms and dankinstall to $(INSTALL_DIR)"
+6 -9
View File
@@ -10,7 +10,7 @@ Go-based backend for DankMaterialShell providing system integration, IPC, and in
Command-line interface and daemon for shell management and system control.
**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
@@ -78,7 +78,7 @@ Custom IPC via unix socket (JSON API) for shell communication.
- `dms brightness [list|set]` - Control display/monitor brightness
- `dms color pick` - Native color picker (see below)
- `dms update` - Update DMS and dependencies (disabled in distro packages)
- `dms greeter` - Deprecated; forwards to the standalone [dms-greeter](https://github.com/AvengeMedia/dank-greeter) binary
- `dms greeter install` - Install greetd greeter (disabled in distro packages)
### Color Picker
@@ -109,7 +109,7 @@ make test # Run tests
**Distribution build:**
```bash
make dist # Build without update features
make dist # Build without update/greeter features
```
Produces `bin/dms-linux-amd64` and `bin/dms-linux-arm64`
@@ -170,7 +170,7 @@ sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c hyprland -t ki
| `--exclude-deps <name,...>` | | Skip specific dependencies |
| `--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`) |
| `--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.
Configuration files are not replaced by default unless `--replace-configs` or `--replace-configs-all` is specified.
@@ -193,7 +193,7 @@ Set the `DANKINSTALL_LOG_DIR` environment variable to override the log directory
## Supported Distributions
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo, Void (and derivatives)
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives)
**Arch Linux**
Uses `pacman` for system packages, builds AUR packages via `makepkg`, no AUR helper dependency.
@@ -206,7 +206,7 @@ Uses COPR repositories (`avengemedia/danklinux`, `avengemedia/dms`).
Requires PPA support. Most packages built from source (slow first install).
**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**
Most packages available in standard repos. Minimal building required.
@@ -214,7 +214,4 @@ Most packages available in standard repos. Minimal building required.
**Gentoo**
Uses Portage with GURU overlay. Automatically configures USE flags. Variable success depending on system configuration.
**Void Linux**
Uses XBPS with the DMS and DankLinux self-hosted repositories.
See installer output for distribution-specific details during installation.
+2 -10
View File
@@ -23,8 +23,6 @@ var (
replaceConfigs []string
replaceConfigsAll bool
yes bool
danksearch bool
dankcalendar bool
)
var rootCmd = &cobra.Command{
@@ -44,15 +42,13 @@ configure passwordless sudo for your user.`,
}
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 or hyprland (enables headless mode)")
rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)")
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(&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().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")
}
func main() {
@@ -78,8 +74,6 @@ func runDankinstall(cmd *cobra.Command, args []string) error {
"replace-configs",
"replace-configs-all",
"yes",
"danksearch",
"dankcalendar",
}
var set []string
for _, name := range headlessOnly {
@@ -101,7 +95,7 @@ func runDankinstall(cmd *cobra.Command, args []string) error {
func runHeadless() error {
// Validate required flags
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 or hyprland)")
}
if term == "" {
return fmt.Errorf("--term is required for headless mode (ghostty, kitty, or alacritty)")
@@ -115,8 +109,6 @@ func runHeadless() error {
ReplaceConfigs: replaceConfigs,
ReplaceConfigsAll: replaceConfigsAll,
Yes: yes,
DankSearch: danksearch,
DankCalendar: dankcalendar,
}
runner := headless.NewRunner(cfg)
+1 -166
View File
@@ -1,11 +1,7 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
@@ -16,7 +12,7 @@ import (
var authCmd = &cobra.Command{
Use: "auth",
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{
@@ -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() {
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)")
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 {
@@ -212,31 +75,3 @@ func syncAuthInTerminal(nonInteractive bool) error {
shellCmd := shellSyncCmd + `; echo; echo "Authentication sync finished. Closing in 3 seconds..."; sleep 3`
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)")
}
+1 -18
View File
@@ -63,15 +63,6 @@ var clipPasteCmd = &cobra.Command{
Run: runClipPaste,
}
var clipSendPasteCmd = &cobra.Command{
Use: "send-paste",
Short: "Send a paste keystroke to the focused window",
Long: "Emulate ctrl+v (or ctrl+shift+v with --shift) via a virtual keyboard. Works without server.",
Run: runClipSendPaste,
}
var clipSendPasteShift bool
var clipWatchCmd = &cobra.Command{
Use: "watch [command]",
Short: "Watch clipboard for changes",
@@ -225,10 +216,8 @@ func init() {
clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration")
clipSendPasteCmd.Flags().BoolVarP(&clipSendPasteShift, "shift", "s", false, "Send ctrl+shift+v (terminal paste)")
clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd)
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipSendPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd)
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd)
}
func runClipCopy(cmd *cobra.Command, args []string) {
@@ -325,12 +314,6 @@ func runClipPaste(cmd *cobra.Command, args []string) {
os.Stdout.Write(data)
}
func runClipSendPaste(cmd *cobra.Command, args []string) {
if err := clipboard.SendPasteKeystroke(clipSendPasteShift); err != nil {
log.Fatalf("send-paste: %v", err)
}
}
func runClipWatch(cmd *cobra.Command, args []string) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
+1 -9
View File
@@ -19,12 +19,7 @@ var (
var colorCmd = &cobra.Command{
Use: "color",
Short: "Color utilities",
Long: `Color utilities including picking colors from the screen.
This is the screen eyedropper CLI. To open the in-shell color modal, use:
dms ipc call color-picker toggle
See: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc`,
Long: "Color utilities including picking colors from the screen",
}
var colorPickCmd = &cobra.Command{
@@ -34,9 +29,6 @@ var colorPickCmd = &cobra.Command{
Click on any pixel to capture its color, or press Escape to cancel.
This is the screen eyedropper CLI. To open the in-shell color modal, use:
dms ipc call color-picker toggle
Output format flags (mutually exclusive, default: --hex):
--hex - Hexadecimal (#RRGGBB)
--rgb - RGB values (R G B)
+86 -271
View File
@@ -6,10 +6,10 @@ import (
"regexp"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/plugins"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
"github.com/AvengeMedia/DankMaterialShell/core/internal/shellembed"
"github.com/spf13/cobra"
)
@@ -19,16 +19,68 @@ var versionCmd = &cobra.Command{
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{
Use: "ipc",
Use: "ipc [target] [function] [args...]",
Short: "Send IPC commands to running DMS shell",
Long: `Send IPC commands to the running DMS shell.
dms ipc call <target> <function> [args...] invoke a command
dms ipc list list all targets and functions
Full reference: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc`,
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
_ = findConfig(cmd, args)
return getShellIPCCompletions(args, toComplete), cobra.ShellCompDirectiveNoFileComp
},
Run: func(cmd *cobra.Command, args []string) {
@@ -36,21 +88,11 @@ Full reference: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc`,
},
}
var ipcListCmd = &cobra.Command{
Use: "list",
Short: "List all IPC targets and functions",
Run: func(cmd *cobra.Command, args []string) {
printIPCHelp()
},
}
func init() {
ipcCmd.AddCommand(ipcListCmd)
ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
_ = findConfig(cmd, args)
printIPCHelp()
})
pluginsUpdateCmd.Flags().BoolP("all", "a", false, "Update all installed plugins")
pluginsUpdateCmd.Flags().Bool("check", false, "Check for available updates without applying them")
}
var debugSrvCmd = &cobra.Command{
@@ -129,22 +171,10 @@ var pluginsUninstallCmd = &cobra.Command{
}
var pluginsUpdateCmd = &cobra.Command{
Use: "update [plugin-id]",
Short: "Update a plugin by ID, or all plugins",
Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). If --all or -a is specified, all installed plugins will be updated.",
Args: func(cmd *cobra.Command, args []string) error {
updateAll, _ := cmd.Flags().GetBool("all")
if updateAll {
if len(args) > 0 {
return fmt.Errorf("cannot specify plugin ID when using --all/-a")
}
return nil
}
if len(args) != 1 {
return fmt.Errorf("requires exactly 1 arg (plugin ID) or use --all/-a")
}
return nil
},
Use: "update <plugin-id>",
Short: "Update a plugin by ID",
Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). Plugin names are also supported.",
Args: cobra.ExactArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
@@ -152,26 +182,6 @@ var pluginsUpdateCmd = &cobra.Command{
return getInstalledPluginIDs(), cobra.ShellCompDirectiveNoFileComp
},
Run: func(cmd *cobra.Command, args []string) {
checkOnly, _ := cmd.Flags().GetBool("check")
updateAll, _ := cmd.Flags().GetBool("all")
if checkOnly {
if updateAll {
if err := checkAllPluginsCLI(); err != nil {
log.Fatalf("Error checking updates: %v", err)
}
return
}
if err := checkPluginCLI(args[0]); err != nil {
log.Fatalf("Error checking update: %v", err)
}
return
}
if updateAll {
if err := updateAllPluginsCLI(); err != nil {
log.Fatalf("Error updating plugins: %v", err)
}
return
}
if err := updatePluginCLI(args[0]); err != nil {
log.Fatalf("Error updating plugin: %v", err)
}
@@ -207,41 +217,29 @@ func formatVersion(version string) string {
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 {
if ver := parseBaseVersion(shellembed.Version()); ver != "" {
return ver
paths := []string{
"/usr/share/quickshell/dms/VERSION",
"/usr/local/share/quickshell/dms/VERSION",
"/etc/xdg/quickshell/dms/VERSION",
}
for _, path := range shellVersionPaths {
content, err := os.ReadFile(path)
if err != nil {
continue
}
if ver := parseBaseVersion(string(content)); ver != "" {
return ver
for _, path := range paths {
if content, err := os.ReadFile(path); err == nil {
ver := strings.TrimSpace(string(content))
ver = strings.TrimPrefix(ver, "v")
if re := regexp.MustCompile(`^([\d.]+)`); re.MatchString(ver) {
if matches := re.FindStringSubmatch(ver); matches != nil {
return matches[1]
}
}
}
}
// Fallback
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 {
server.CLIVersion = Version
return server.Start(true)
@@ -269,13 +267,6 @@ func browsePlugins() error {
return nil
}
feedback := plugins.FetchFeedback()
nameByID := make(map[string]string, len(pluginList))
for _, plugin := range pluginList {
nameByID[plugin.ID] = plugin.Name
}
fmt.Printf("\nAvailable Plugins (%d):\n\n", len(pluginList))
for _, plugin := range pluginList {
installed, _ := manager.IsInstalled(plugin)
@@ -299,26 +290,6 @@ func browsePlugins() error {
if len(plugin.Dependencies) > 0 {
fmt.Printf(" Dependencies: %s\n", strings.Join(plugin.Dependencies, ", "))
}
if fb, ok := feedback[plugin.ID]; ok {
fmt.Printf(" Upvotes: %d\n", fb.Upvotes)
if len(fb.Status) > 0 {
fmt.Printf(" Status: %s\n", strings.Join(fb.Status, ", "))
}
if fb.IssueURL != "" {
fmt.Printf(" Discuss: %s\n", fb.IssueURL)
}
if len(fb.Similar) > 0 {
names := make([]string, len(fb.Similar))
for i, id := range fb.Similar {
if name, ok := nameByID[id]; ok {
names[i] = name
} else {
names[i] = id
}
}
fmt.Printf(" Related: %s\n", strings.Join(names, ", "))
}
}
fmt.Println()
}
@@ -359,11 +330,7 @@ func listInstalledPlugins() error {
fmt.Printf("\nInstalled Plugins (%d):\n\n", len(installedNames))
for _, id := range installedNames {
if plugin, ok := pluginMap[id]; ok {
hasUpdateStr := ""
if hasUpdates, _, err := manager.HasUpdates(id, plugin); err == nil && hasUpdates {
hasUpdateStr = " (update available)"
}
fmt.Printf(" %s%s\n", plugin.Name, hasUpdateStr)
fmt.Printf(" %s\n", plugin.Name)
fmt.Printf(" ID: %s\n", plugin.ID)
fmt.Printf(" Category: %s\n", plugin.Category)
fmt.Printf(" Author: %s\n", plugin.Author)
@@ -543,164 +510,13 @@ func updatePluginCLI(idOrName string) error {
return nil
}
func updateAllPluginsCLI() error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
installed, err := manager.ListInstalled()
if err != nil {
return fmt.Errorf("failed to list installed plugins: %w", err)
}
pluginList, _ := registry.List()
var errs []error
for _, pluginID := range installed {
plugin := plugins.FindByIDOrName(pluginID, pluginList)
if plugin != nil {
fmt.Printf("Updating plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
if err := manager.Update(*plugin); err != nil {
if strings.Contains(err.Error(), "cannot update system plugin") {
fmt.Printf("Skipping system plugin: %s\n", plugin.Name)
} else {
errs = append(errs, fmt.Errorf("failed to update %s: %w", plugin.Name, err))
}
} else {
fmt.Printf("Plugin updated successfully: %s\n", plugin.Name)
}
} else {
fmt.Printf("Updating plugin: %s\n", pluginID)
if err := manager.UpdateByIDOrName(pluginID); err != nil {
if strings.Contains(err.Error(), "cannot update system plugin") {
fmt.Printf("Skipping system plugin: %s\n", pluginID)
} else {
errs = append(errs, fmt.Errorf("failed to update %s: %w", pluginID, err))
}
} else {
fmt.Printf("Plugin updated successfully: %s\n", pluginID)
}
}
}
if len(errs) > 0 {
for _, err := range errs {
fmt.Fprintf(os.Stderr, "%v\n", err)
}
return fmt.Errorf("failed to update some plugins")
}
return nil
}
func checkPluginCLI(idOrName string) error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
pluginList, _ := registry.List()
plugin := plugins.FindByIDOrName(idOrName, pluginList)
if plugin != nil {
installed, err := manager.IsInstalled(*plugin)
if err != nil {
return fmt.Errorf("failed to check install status: %w", err)
}
if !installed {
return fmt.Errorf("plugin not installed: %s", plugin.Name)
}
hasUpdates, _, err := manager.HasUpdates(plugin.ID, *plugin)
if err != nil {
return fmt.Errorf("failed to check updates: %w", err)
}
if hasUpdates {
fmt.Printf("Update available for plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
} else {
fmt.Printf("Plugin is up to date: %s\n", plugin.Name)
}
return nil
}
dummyPlugin := plugins.Plugin{ID: idOrName}
hasUpdates, _, err := manager.HasUpdates(idOrName, dummyPlugin)
if err != nil {
return fmt.Errorf("failed to check updates: %w", err)
}
if hasUpdates {
fmt.Printf("Update available for plugin: %s\n", idOrName)
} else {
fmt.Printf("Plugin is up to date: %s\n", idOrName)
}
return nil
}
func checkAllPluginsCLI() error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
installed, err := manager.ListInstalled()
if err != nil {
return fmt.Errorf("failed to list installed plugins: %w", err)
}
pluginList, _ := registry.List()
var count int
for _, pluginID := range installed {
plugin := plugins.FindByIDOrName(pluginID, pluginList)
var hasUpdates bool
var name string
if plugin != nil {
name = plugin.Name
hasUpdates, _, _ = manager.HasUpdates(pluginID, *plugin)
} else {
name = pluginID
dummyPlugin := plugins.Plugin{ID: pluginID}
hasUpdates, _, _ = manager.HasUpdates(pluginID, dummyPlugin)
}
if hasUpdates {
fmt.Printf("Update available for plugin: %s (ID: %s)\n", name, pluginID)
count++
}
}
if count > 0 {
fmt.Printf("\nFound %d plugin(s) with available updates.\n", count)
} else {
fmt.Println("All plugins are up to date.")
}
return nil
}
func getCommonCommands() []*cobra.Command {
commands := shellApp.Commands()
return append(commands, []*cobra.Command{
return []*cobra.Command{
versionCmd,
runCmd,
restartCmd,
restartDetachedCmd,
killCmd,
ipcCmd,
debugSrvCmd,
pluginsCmd,
@@ -711,7 +527,6 @@ func getCommonCommands() []*cobra.Command {
greeterCmd,
setupCmd,
colorCmd,
qrCmd,
screenshotCmd,
notifyActionCmd,
notifyCmd,
@@ -727,5 +542,5 @@ func getCommonCommands() []*cobra.Command {
trashCmd,
systemCmd,
switchUserCmd,
}...)
}
}
+15 -16
View File
@@ -37,11 +37,9 @@ var resolveIncludeCmd = &cobra.Command{
"cursor.lua",
"windowrules.lua",
"cursor.kdl",
"layout.kdl",
"outputs.kdl",
"binds.kdl",
"cursor.conf",
"layout.conf",
"outputs.conf",
"binds.conf",
}, cobra.ShellCompDirectiveNoFileComp
@@ -56,10 +54,8 @@ func init() {
}
type IncludeResult struct {
Exists bool `json:"exists"`
Included bool `json:"included"`
ConfigFormat string `json:"configFormat,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
Exists bool `json:"exists"`
Included bool `json:"included"`
}
func runResolveInclude(cmd *cobra.Command, args []string) {
@@ -74,7 +70,7 @@ func runResolveInclude(cmd *cobra.Command, args []string) {
result, err = checkHyprlandInclude(filename)
case "niri":
result, err = checkNiriInclude(filename)
case "mangowc", "mango":
case "mangowc", "dwl", "mango":
result, err = checkMangoWCInclude(filename)
default:
log.Fatalf("Unknown compositor: %s", compositor)
@@ -89,7 +85,10 @@ func runResolveInclude(cmd *cobra.Command, args []string) {
}
func checkHyprlandInclude(filename string) (IncludeResult, error) {
configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
if err != nil {
return IncludeResult{}, err
}
targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{}
@@ -107,8 +106,6 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
mainLua := filepath.Join(configDir, "hyprland.lua")
if _, err := os.Stat(mainLua); err == nil {
result.ConfigFormat = "lua"
result.ReadOnly = false
processedLua := make(map[string]bool)
if luaconfig.RequiresTarget(mainLua, targetAbs, processedLua) {
result.Included = true
@@ -118,10 +115,6 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
mainConf := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(mainConf); err == nil {
if result.ConfigFormat == "" {
result.ConfigFormat = "hyprlang"
result.ReadOnly = true
}
processed := make(map[string]bool)
if hyprlandFindIncludeHyprlang(mainConf, targetRel, processed) {
result.Included = true
@@ -190,7 +183,10 @@ func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]b
}
func checkNiriInclude(filename string) (IncludeResult, error) {
configDir := filepath.Join(utils.XDGConfigHome(), "niri")
configDir, err := utils.ExpandPath("$HOME/.config/niri")
if err != nil {
return IncludeResult{}, err
}
targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{}
@@ -266,7 +262,10 @@ func niriFindInclude(filePath, target string, processed map[string]bool) bool {
}
func checkMangoWCInclude(filename string) (IncludeResult, error) {
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
configDir, err := utils.ExpandPath("$HOME/.config/mango")
if err != nil {
return IncludeResult{}, err
}
targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{}
+7 -140
View File
@@ -125,7 +125,6 @@ const (
catConfigFiles
catServices
catEnvironment
catFonts
)
func (c category) String() string {
@@ -148,8 +147,6 @@ func (c category) String() string {
return "Services"
case catEnvironment:
return "Environment"
case catFonts:
return "Fonts"
default:
return "Unknown"
}
@@ -216,7 +213,6 @@ func runDoctor(cmd *cobra.Command, args []string) {
checkConfigurationFiles(),
checkSystemdServices(),
checkEnvironmentVars(),
checkFonts(),
)
switch {
@@ -320,7 +316,6 @@ func checkEnvironmentVars() []checkResult {
var results []checkResult
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME")...)
results = append(results, checkEnvVar("QS_ICON_THEME")...)
results = append(results, checkXDGMenuPrefix()...)
return results
}
@@ -335,29 +330,6 @@ func checkEnvVar(name string) []checkResult {
return nil
}
func checkXDGMenuPrefix() []checkResult {
menuPrefix := os.Getenv("XDG_MENU_PREFIX")
if menuPrefix != "" {
if checkXDGMenuFile(menuPrefix) {
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusInfo, menuPrefix, "", doctorDocsURL + "#xdg-menu-prefix"}}
}
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusWarn, fmt.Sprintf("%s (menu file not found)", menuPrefix), fmt.Sprintf("Dolphin 'Open with…' dialog may be empty. Ensure /etc/xdg/menus/%sapplications.menu exists.", menuPrefix), doctorDocsURL + "#xdg-menu-prefix"}}
}
if _, err := exec.LookPath("keditfiletype"); err == nil {
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusWarn, "Not set", "Dolphin file associations and 'Open with…' dialog may be empty. Set XDG_MENU_PREFIX=plasma- in your compositor's environment block.", doctorDocsURL + "#xdg-menu-prefix"}}
}
if doctorVerbose {
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusInfo, "Not set", "", doctorDocsURL + "#xdg-menu-prefix"}}
}
return nil
}
func checkXDGMenuFile(prefix string) bool {
menuPath := fmt.Sprintf("/etc/xdg/menus/%sapplications.menu", prefix)
_, err := os.Stat(menuPath)
return err == nil
}
func readOSRelease() map[string]string {
result := make(map[string]string)
data, err := os.ReadFile("/etc/os-release")
@@ -401,12 +373,12 @@ func checkVersions(qsMissingFeatures bool) []checkResult {
}
func getDMSShellVersion() (version, path string) {
if err := shellApp.ResolveConfig(nil, nil); err == nil && shellApp.ConfigPath() != "" {
versionFile := filepath.Join(shellApp.ConfigPath(), "VERSION")
if err := findConfig(nil, nil); err == nil && configPath != "" {
versionFile := filepath.Join(configPath, "VERSION")
if data, err := os.ReadFile(versionFile); err == nil {
return strings.TrimSpace(string(data)), shellApp.ConfigPath()
return strings.TrimSpace(string(data)), configPath
}
return "installed", shellApp.ConfigPath()
return "installed", configPath
}
if dmsPath, err := config.LocateDMSConfig(); err == nil {
@@ -450,8 +422,8 @@ func checkDMSInstallation() []checkResult {
var results []checkResult
dmsPath := ""
if err := shellApp.ResolveConfig(nil, nil); err == nil && shellApp.ConfigPath() != "" {
dmsPath = shellApp.ConfigPath()
if err := findConfig(nil, nil); err == nil && configPath != "" {
dmsPath = configPath
} else if path, err := config.LocateDMSConfig(); err == nil {
dmsPath = path
}
@@ -581,8 +553,6 @@ func detectRunningWM() string {
return "Hyprland"
case os.Getenv("NIRI_SOCKET") != "":
return "niri"
case os.Getenv("MANGO_INSTANCE_SIGNATURE") != "":
return "MangoWC"
case os.Getenv("MIRACLESOCK") != "":
return "Miracle WM"
case os.Getenv("XDG_CURRENT_DESKTOP") != "":
@@ -833,8 +803,6 @@ func detectNetworkBackend(stackResult *network.DetectResult) string {
return "systemd-networkd"
case network.BackendConnMan:
return "ConnMan"
case network.BackendWpaSupplicant:
return "wpa_supplicant"
default:
return ""
}
@@ -912,7 +880,6 @@ func checkOptionalDependencies() []checkResult {
{"cava", "cava", "Audio visualizer", true},
{"khal", "khal", "Calendar events", false},
{"danksearch", "dsearch", "File search", false},
{"dankcalendar", "dcal", "Calendar app", false},
{"fprintd", "fprintd-list", "Fingerprint auth", false},
}
@@ -980,12 +947,9 @@ func checkSystemdServices() []checkResult {
message = fmt.Sprintf("%s, %s", dmsState.enabled, dmsState.active)
}
switch {
case dmsState.active == "failed":
status = statusError
case dmsState.active == "active":
case dmsState.enabled == "disabled":
status, message = statusWarn, "Disabled"
case dmsState.active == "inactive":
case dmsState.active == "failed" || dmsState.active == "inactive":
status = statusError
}
results = append(results, checkResult{catServices, "dms.service", status, message, "", doctorDocsURL + "#services"})
@@ -1168,100 +1132,3 @@ func formatResultsPlain(results []checkResult) string {
return sb.String()
}
func checkFonts() []checkResult {
var results []checkResult
url := doctorDocsURL + "#fonts"
configDir, err := os.UserConfigDir()
if err != nil {
return nil
}
settingsPath := filepath.Join(configDir, "DankMaterialShell", "settings.json")
fontFamily := "Inter Variable"
monoFontFamily := "Fira Code"
if data, err := os.ReadFile(settingsPath); err == nil {
var settings struct {
FontFamily string `json:"fontFamily"`
MonoFontFamily string `json:"monoFontFamily"`
}
if err := json.Unmarshal(data, &settings); err == nil {
if settings.FontFamily != "" {
fontFamily = settings.FontFamily
}
if settings.MonoFontFamily != "" {
monoFontFamily = settings.MonoFontFamily
}
}
}
if !utils.CommandExists("fc-list") {
results = append(results, checkResult{catFonts, "Fontconfig Tools", statusWarn, "fc-list not installed", "Cannot verify if fonts are cached.", url})
return results
}
// Retrieve font list
output, err := exec.Command("fc-list", ":", "family").Output()
if err != nil {
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Failed to query font list", "Fontconfig cache query failed. Try running 'fc-cache -fv'.", url})
return results
}
outStr := string(output)
if len(strings.TrimSpace(outStr)) == 0 {
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Cache is empty", "No fonts found in fontconfig cache. Try running 'fc-cache -fv'.", url})
return results
}
lowerFonts := strings.ToLower(outStr)
// Helper to check if a font exists
hasFont := func(name string) bool {
target := strings.ToLower(strings.TrimSpace(name))
if target == "" {
return false
}
for _, line := range strings.Split(lowerFonts, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Each line can have comma-separated families
families := strings.Split(line, ",")
for _, fam := range families {
if strings.TrimSpace(fam) == target {
return true
}
}
}
return false
}
// Normal Font Check
if hasFont(fontFamily) {
results = append(results, checkResult{catFonts, "Normal Font", statusOK, fontFamily, "Available", url})
} else {
results = append(results, checkResult{
catFonts, "Normal Font", statusWarn,
fmt.Sprintf("'%s' not found", fontFamily),
"Font is not registered. Try running 'fc-cache -fv' or install the font.",
url,
})
}
// Monospace Font Check
if hasFont(monoFontFamily) {
results = append(results, checkResult{catFonts, "Monospace Font", statusOK, monoFontFamily, "Available", url})
} else {
results = append(results, checkResult{
catFonts, "Monospace Font", statusWarn,
fmt.Sprintf("'%s' not found", monoFontFamily),
"Font is not registered. Try running 'fc-cache -fv' or install the font.",
url,
})
}
return results
}
+3 -22
View File
@@ -26,7 +26,7 @@ var updateCmd = &cobra.Command{
Use: "update",
Short: "Update DankMaterialShell to the latest version",
Long: "Update DankMaterialShell to the latest version using the appropriate package manager for your distribution",
PreRunE: shellApp.ResolveConfig,
PreRunE: findConfig,
Run: func(cmd *cobra.Command, args []string) {
runUpdate()
},
@@ -98,7 +98,7 @@ func runUpdate() {
}
log.Info("Update complete! Restarting DMS...")
shellApp.Restart()
restartShell()
}
func updateArchLinux() error {
@@ -250,10 +250,6 @@ func updateOtherDistros() error {
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 != "" {
latestTagCmd := exec.Command("git", "tag", "-l", "v*", "--sort=-version:refname")
latestTagOutput, err := latestTagCmd.Output()
@@ -295,10 +291,6 @@ func updateOtherDistros() error {
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)
return nil
}
@@ -328,21 +320,10 @@ func updateOtherDistros() error {
return fmt.Errorf("update cancelled")
}
if err := updateSubmodules(); err != nil {
return fmt.Errorf("failed to update submodules: %w", err)
}
fmt.Println("\nUpdate complete!")
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 {
fmt.Println("\nWould you like to backup and re-clone the repository? (y/N): ")
reader := bufio.NewReader(os.Stdin)
@@ -361,7 +342,7 @@ func offerReclone(dmsPath string) bool {
}
fmt.Println("Cloning fresh copy...")
cloneCmd := exec.Command("git", "clone", "--recurse-submodules", "https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath)
cloneCmd := exec.Command("git", "clone", "https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath)
cloneCmd.Stdout = os.Stdout
cloneCmd.Stderr = os.Stderr
if err := cloneCmd.Run(); err != nil {
File diff suppressed because it is too large Load Diff
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"errors"
"reflect"
"testing"
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
)
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")
}
}
-23
View File
@@ -38,17 +38,10 @@ var matugenCheckCmd = &cobra.Command{
Run: runMatugenCheck,
}
var matugenPreviewCmd = &cobra.Command{
Use: "preview",
Short: "Preview Matugen scheme colors without applying them",
Run: runMatugenPreview,
}
func init() {
matugenCmd.AddCommand(matugenGenerateCmd)
matugenCmd.AddCommand(matugenQueueCmd)
matugenCmd.AddCommand(matugenCheckCmd)
matugenCmd.AddCommand(matugenPreviewCmd)
for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} {
cmd.Flags().String("state-dir", "", "State directory for cache files")
@@ -69,8 +62,6 @@ func init() {
matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion")
matugenQueueCmd.Flags().Duration("timeout", 90*time.Second, "Timeout for waiting")
matugenPreviewCmd.Flags().String("source-color", "", "Source color used to generate previews")
matugenPreviewCmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
}
func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
@@ -209,17 +200,3 @@ func runMatugenCheck(cmd *cobra.Command, args []string) {
}
fmt.Println(string(data))
}
func runMatugenPreview(cmd *cobra.Command, args []string) {
sourceColor, _ := cmd.Flags().GetString("source-color")
contrast, _ := cmd.Flags().GetFloat64("contrast")
previews, err := matugen.PreviewSchemes(sourceColor, contrast)
if err != nil {
log.Fatalf("Failed to generate Matugen previews: %v", err)
}
data, err := json.Marshal(previews)
if err != nil {
log.Fatalf("Failed to marshal Matugen previews: %v", err)
}
fmt.Println(string(data))
}
-265
View File
@@ -1,265 +0,0 @@
package main
import (
"fmt"
"io"
"os"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/qrcode"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
)
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(models.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
}
+60 -1
View File
@@ -1,9 +1,19 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra"
)
var customConfigPath string
var configPath string
var rootCmd = &cobra.Command{
Use: "dms",
Short: "dms CLI",
@@ -11,5 +21,54 @@ var rootCmd = &cobra.Command{
}
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
}
+15 -141
View File
@@ -2,12 +2,9 @@ package main
import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
@@ -28,19 +25,8 @@ var (
ssNoConfirm bool
ssReset bool
ssStdout bool
ssJSON bool
)
type screenshotMetadata struct {
Status string `json:"status"`
Path string `json:"path,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{
Use: "screenshot",
Short: "Capture screenshots",
@@ -51,9 +37,8 @@ Modes:
full - Capture the focused output
all - Capture all outputs combined
output - Capture a specific output by name
window - Capture the focused window (Hyprland/Mango/niri)
window - Capture the focused window (Hyprland/DWL)
last - Capture the last selected region
scroll - Select a region, then scroll to capture a stitched tall image
Output format (--format):
png - PNG format (default)
@@ -72,10 +57,7 @@ Examples:
dms screenshot --no-file # Clipboard only
dms screenshot --no-confirm # Region capture on mouse release
dms screenshot --cursor=on # Include cursor
dms screenshot -f jpg -q 85 # JPEG with quality 85
dms screenshot --json # Print capture metadata as JSON
dms screenshot scroll # Scroll capture, Enter finishes / Esc cancels
dms screenshot scroll --interval 250`,
dms screenshot -f jpg -q 85 # JPEG with quality 85`,
}
var ssRegionCmd = &cobra.Command{
@@ -113,33 +95,10 @@ If no previous region exists, falls back to interactive selection.`,
var ssWindowCmd = &cobra.Command{
Use: "window",
Short: "Capture the focused window",
Long: `Capture the currently focused window. Supported on Hyprland, Mango, and niri.`,
Long: `Capture the currently focused window. Supported on Hyprland and DWL.`,
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{
Use: "list",
Short: "List available outputs",
@@ -167,12 +126,8 @@ func init() {
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(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
screenshotCmd.PersistentFlags().BoolVar(&ssJSON, "json", false, "Print capture metadata as JSON")
ssScrollCmd.Flags().IntVar(&ssScrollInterval, "interval", 45, "Capture interval in milliseconds (30-1000)")
screenshotCmd.AddCommand(ssRegionCmd)
screenshotCmd.AddCommand(ssScrollCmd)
screenshotCmd.AddCommand(ssFullCmd)
screenshotCmd.AddCommand(ssAllCmd)
screenshotCmd.AddCommand(ssOutputCmd)
@@ -224,76 +179,15 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
return config
}
// setPopoutScreenshotMode toggles the shell handshake so popouts drop their keyboard grab during region select. Best-effort.
func setPopoutScreenshotMode(begin bool) {
fn := "end"
if begin {
fn = "begin"
}
cmdArgs := []string{"ipc"}
if pid, ok := shellApp.SessionPID(); ok {
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
} else {
if err := shellApp.ResolveConfig(nil, nil); err != nil {
return
}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", shellApp.ConfigPath())
}
cmdArgs = append(cmdArgs, "call", "screenshot", fn)
_ = 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) {
if ssJSON && config.Stdout {
fmt.Fprintln(os.Stderr, "Error: --json cannot be combined with --stdout")
os.Exit(1)
}
// Region select needs the keyboard; drop popout grabs for its duration.
result, err := func() (*screenshot.CaptureResult, error) {
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion || config.Mode == screenshot.ModeScroll
if interactive {
setPopoutScreenshotMode(true)
defer setPopoutScreenshotMode(false)
}
return screenshot.New(config).Run()
}()
sc := screenshot.New(config)
result, err := sc.Run()
if err != nil {
exitScreenshotError("", err)
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if result == nil {
if ssJSON {
writeScreenshotJSON(screenshotMetadata{Status: "aborted", Error: "User cancelled selection"})
}
os.Exit(0)
}
@@ -305,7 +199,8 @@ func runScreenshot(config screenshot.Config) {
if config.Stdout {
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
}
@@ -325,37 +220,22 @@ func runScreenshot(config screenshot.Config) {
filePath = filepath.Join(outputDir, filename)
if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format); err != nil {
exitScreenshotError(" writing file", err)
}
if !ssJSON {
fmt.Println(filePath)
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
os.Exit(1)
}
fmt.Println(filePath)
}
if config.Clipboard {
if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
exitScreenshotError(" copying to clipboard", err)
fmt.Fprintf(os.Stderr, "Error copying to clipboard: %v\n", err)
os.Exit(1)
}
if !ssJSON && !config.SaveFile {
if !config.SaveFile {
fmt.Println("Copied to clipboard")
}
}
if ssJSON {
scale := result.Scale
if scale <= 0 {
scale = 1.0
}
writeScreenshotJSON(screenshotMetadata{
Status: "success",
Path: filePath,
Width: result.Buffer.Width,
Height: result.Buffer.Height,
Scale: scale,
Mime: formatMime(config.Format),
})
}
if config.Notify {
thumbData, thumbW, thumbH := bufferToRGBThumbnail(result.Buffer, 256, result.Format)
screenshot.SendNotification(screenshot.NotifyResult{
@@ -466,12 +346,6 @@ func runScreenshotRegion(cmd *cobra.Command, args []string) {
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) {
config := getScreenshotConfig(screenshot.ModeFullScreen)
runScreenshot(config)
+38 -100
View File
@@ -10,7 +10,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
@@ -100,72 +100,56 @@ var setupWindowrulesCmd = &cobra.Command{
}
type dmsConfigSpec struct {
niriFile string
hyprFile string
mangoFile string
niriContent func(terminal string) string
hyprContent func(terminal string) string
mangoContent func(terminal string) string
niriFile string
hyprFile string
niriContent func(terminal string) string
hyprContent func(terminal string) string
}
var dmsConfigSpecs = map[string]dmsConfigSpec{
"binds": {
niriFile: "binds.kdl",
hyprFile: "binds.lua",
mangoFile: "binds.conf",
niriFile: "binds.kdl",
hyprFile: "binds.lua",
niriContent: func(t string) string {
return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t)
},
hyprContent: func(t string) string {
return strings.ReplaceAll(config.DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", t)
},
mangoContent: func(t string) string {
return strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", t)
},
},
"layout": {
niriFile: "layout.kdl",
hyprFile: "layout.lua",
mangoFile: "layout.conf",
niriContent: func(_ string) string { return config.NiriLayoutConfig },
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
mangoContent: func(_ string) string { return config.MangoLayoutConfig },
niriFile: "layout.kdl",
hyprFile: "layout.lua",
niriContent: func(_ string) string { return config.NiriLayoutConfig },
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
},
"colors": {
niriFile: "colors.kdl",
hyprFile: "colors.lua",
mangoFile: "colors.conf",
niriContent: func(_ string) string { return config.NiriColorsConfig },
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
mangoContent: func(_ string) string { return config.MangoColorsConfig },
niriFile: "colors.kdl",
hyprFile: "colors.lua",
niriContent: func(_ string) string { return config.NiriColorsConfig },
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
},
"alttab": {
niriFile: "alttab.kdl",
niriContent: func(_ string) string { return config.NiriAlttabConfig },
},
"outputs": {
niriFile: "outputs.kdl",
hyprFile: "outputs.lua",
mangoFile: "outputs.conf",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSOutputsLuaConfig },
mangoContent: func(_ string) string { return "" },
niriFile: "outputs.kdl",
hyprFile: "outputs.lua",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSOutputsLuaConfig },
},
"cursor": {
niriFile: "cursor.kdl",
hyprFile: "cursor.lua",
mangoFile: "cursor.conf",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSCursorLuaConfig },
mangoContent: func(_ string) string { return "" },
niriFile: "cursor.kdl",
hyprFile: "cursor.lua",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSCursorLuaConfig },
},
"windowrules": {
niriFile: "windowrules.kdl",
hyprFile: "windowrules.lua",
mangoFile: "windowrules.conf",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSWindowRulesLuaConfig },
mangoContent: func(_ string) string { return "" },
niriFile: "windowrules.kdl",
hyprFile: "windowrules.lua",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSWindowRulesLuaConfig },
},
}
@@ -204,34 +188,20 @@ func detectTerminal() (string, error) {
}
func detectCompositorForSetup() (string, error) {
var compositors []string
for _, candidate := range []string{"niri", "Hyprland", "mango"} {
if utils.CommandExists(candidate) {
compositors = append(compositors, candidate)
}
}
compositors := greeter.DetectCompositors()
switch len(compositors) {
case 0:
return "", fmt.Errorf("no supported compositors found (niri, Hyprland, or mango required)")
return "", fmt.Errorf("no supported compositors found (niri or Hyprland required)")
case 1:
return strings.ToLower(compositors[0]), nil
}
fmt.Println("Multiple compositors detected:")
for i, compositor := range compositors {
fmt.Printf("%d) %s\n", i+1, compositor)
selected, err := greeter.PromptCompositorChoice(compositors)
if err != nil {
return "", err
}
fmt.Printf("\nChoice (1-%d): ", len(compositors))
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
return strings.ToLower(selected), nil
}
func runSetupDmsConfig(name string) error {
@@ -254,9 +224,6 @@ func runSetupDmsConfig(name string) error {
case "hyprland":
filename = spec.hyprFile
contentFn = spec.hyprContent
case "mango", "mangowc":
filename = spec.mangoFile
contentFn = spec.mangoContent
default:
return fmt.Errorf("unsupported compositor: %s", compositor)
}
@@ -268,11 +235,9 @@ func runSetupDmsConfig(name string) error {
var dmsDir string
switch compositor {
case "niri":
dmsDir = filepath.Join(utils.XDGConfigHome(), "niri", "dms")
dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "niri", "dms")
case "hyprland":
dmsDir = filepath.Join(utils.XDGConfigHome(), "hypr", "dms")
case "mango", "mangowc":
dmsDir = filepath.Join(utils.XDGConfigHome(), "mango", "dms")
dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "hypr", "dms")
}
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
@@ -308,17 +273,7 @@ func runSetup() error {
wm, wmSelected := promptCompositor()
terminal, terminalSelected := promptTerminal()
useSystemd := true
if wmSelected {
if wm == deps.WindowManagerMango {
useSystemd = false
} else if isVoidSetup() {
useSystemd = false
fmt.Println("\nVoid Linux detected; deploying non-systemd session config.")
} else {
useSystemd = promptSystemd()
}
}
useSystemd := promptSystemd()
if !wmSelected && !terminalSelected {
fmt.Println("No configurations selected. Exiting.")
@@ -389,15 +344,6 @@ func runSetup() error {
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.
// Caps Lock OSD and the Caps Lock bar indicator.
func ensureInputGroup() {
@@ -433,11 +379,10 @@ func promptCompositor() (deps.WindowManager, bool) {
fmt.Println("Select compositor:")
fmt.Println("1) Niri")
fmt.Println("2) Hyprland")
fmt.Println("3) Mango")
fmt.Println("4) None")
fmt.Println("3) None")
var response string
fmt.Print("\nChoice (1-4): ")
fmt.Print("\nChoice (1-3): ")
fmt.Scanln(&response)
response = strings.TrimSpace(response)
@@ -446,8 +391,6 @@ func promptCompositor() (deps.WindowManager, bool) {
return deps.WindowManagerNiri, true
case "2":
return deps.WindowManagerHyprland, true
case "3":
return deps.WindowManagerMango, true
default:
return deps.WindowManagerNiri, false
}
@@ -504,11 +447,6 @@ func checkExistingConfigs(wm deps.WindowManager, wmSelected bool, terminal deps.
filepath.Join(homeDir, ".config", "hypr", "hyprland.lua"),
filepath.Join(homeDir, ".config", "hypr", "hyprland.conf"),
}
case deps.WindowManagerMango:
configPaths = []string{
filepath.Join(homeDir, ".config", "mango", "config.conf"),
filepath.Join(homeDir, ".config", "mango", "mango.conf"),
}
}
for _, configPath := range configPaths {
+2 -12
View File
@@ -47,7 +47,6 @@ var (
sysUpdateJSON bool
sysUpdateNoFlatpak bool
sysUpdateNoAUR bool
sysUpdateIgnore []string
sysUpdateIntervalS int
sysUpdateListPmTime = 5 * time.Minute
)
@@ -59,7 +58,6 @@ func init() {
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip 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)")
systemCmd.AddCommand(systemUpdateCmd)
@@ -194,7 +192,6 @@ func runSystemUpdateApply() {
Targets: pkgs,
IncludeFlatpak: !sysUpdateNoFlatpak,
IncludeAUR: !sysUpdateNoAUR,
Ignored: sysUpdateIgnore,
DryRun: sysUpdateDry,
UseSudo: true,
}
@@ -237,19 +234,12 @@ func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupd
}
func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
if !sysUpdateNoAUR && len(sysUpdateIgnore) == 0 {
if !sysUpdateNoAUR {
return pkgs
}
ignored := make(map[string]bool, len(sysUpdateIgnore))
for _, name := range sysUpdateIgnore {
ignored[name] = true
}
out := pkgs[:0]
for _, p := range pkgs {
if sysUpdateNoAUR && p.Repo == sysupdate.RepoAUR {
continue
}
if ignored[p.Name] {
if p.Repo == sysupdate.RepoAUR {
continue
}
out = append(out, p)
+22 -49
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
@@ -27,7 +26,7 @@ var windowrulesListCmd = &cobra.Command{
Args: cobra.MaximumNArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -41,7 +40,7 @@ var windowrulesAddCmd = &cobra.Command{
Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -55,7 +54,7 @@ var windowrulesUpdateCmd = &cobra.Command{
Args: cobra.ExactArgs(3),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -69,7 +68,7 @@ var windowrulesRemoveCmd = &cobra.Command{
Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -83,7 +82,7 @@ var windowrulesReorderCmd = &cobra.Command{
Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -121,9 +120,6 @@ func getCompositor(args []string) string {
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
return "hyprland"
}
if os.Getenv("MANGO_INSTANCE_SIGNATURE") != "" {
return "mango"
}
return ""
}
@@ -143,14 +139,17 @@ func writeRuleSuccess(id, path string) {
func runWindowrulesList(cmd *cobra.Command, args []string) {
compositor := getCompositor(args)
if compositor == "" {
log.Fatalf("Could not detect compositor. Please specify: hyprland, niri, or mango")
log.Fatalf("Could not detect compositor. Please specify: hyprland or niri")
}
var result WindowRulesListResult
switch compositor {
case "niri":
configDir := filepath.Join(utils.XDGConfigHome(), "niri")
configDir, err := utils.ExpandPath("$HOME/.config/niri")
if err != nil {
log.Fatalf("Failed to expand niri config path: %v", err)
}
parseResult, err := providers.ParseNiriWindowRules(configDir)
if err != nil {
@@ -183,7 +182,10 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
result.DMSStatus = parseResult.DMSStatus
case "hyprland":
configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
if err != nil {
log.Fatalf("Failed to expand hyprland config path: %v", err)
}
parseResult, err := providers.ParseHyprlandWindowRules(configDir)
if err != nil {
@@ -215,38 +217,6 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
result.Rules = allRules
result.DMSStatus = parseResult.DMSStatus
case "mango", "mangowc":
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
parseResult, err := providers.ParseMangoWindowRules(configDir)
if err != nil {
log.Fatalf("Failed to parse mango window rules: %v", err)
}
allRules := providers.ConvertMangoRulesToWindowRules(parseResult.Rules)
provider := providers.NewMangoWritableProvider(configDir)
dmsRules, _ := provider.LoadDMSRules()
dmsRuleMap := make(map[int]windowrules.WindowRule)
for i, dr := range dmsRules {
dmsRuleMap[i] = dr
}
dmsIdx := 0
for i, r := range allRules {
if r.Source == "dms/windowrules.conf" {
if dmr, ok := dmsRuleMap[dmsIdx]; ok {
allRules[i].ID = dmr.ID
allRules[i].Name = dmr.Name
}
dmsIdx++
}
}
result.Rules = allRules
result.DMSStatus = parseResult.DMSStatus
default:
log.Fatalf("Unknown compositor: %s", compositor)
}
@@ -345,14 +315,17 @@ func runWindowrulesReorder(cmd *cobra.Command, args []string) {
func getWindowRulesProvider(compositor string) windowrules.WritableProvider {
switch compositor {
case "niri":
configDir := filepath.Join(utils.XDGConfigHome(), "niri")
configDir, err := utils.ExpandPath("$HOME/.config/niri")
if err != nil {
return nil
}
return providers.NewNiriWritableProvider(configDir)
case "hyprland":
configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
if err != nil {
return nil
}
return providers.NewHyprlandWritableProvider(configDir)
case "mango", "mangowc":
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
return providers.NewMangoWritableProvider(configDir)
default:
return nil
}
-24
View File
@@ -1,24 +0,0 @@
package main
import (
"encoding/json"
"testing"
)
func TestDefaultImmutablePolicyAllowsSyncButBlocksEnable(t *testing.T) {
var policyFile cliPolicyFile
if err := json.Unmarshal(defaultCLIPolicyJSON, &policyFile); err != nil {
t.Fatalf("failed to parse embedded CLI policy: %v", err)
}
if policyFile.BlockedCommands == nil {
t.Fatal("embedded CLI policy has no blocked_commands")
}
blocked := normalizeBlockedCommands(*policyFile.BlockedCommands)
if !commandBlockedByPolicy("greeter enable", blocked) {
t.Fatal("expected greeter enable to remain blocked on immutable/image-based systems")
}
if commandBlockedByPolicy("greeter sync", blocked) {
t.Fatal("expected greeter sync to remain available on immutable/image-based systems")
}
}
+9 -1
View File
@@ -12,7 +12,15 @@ import (
var Version = "dev"
func init() {
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
authCmd.AddCommand(authSyncCmd)
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
updateCmd.AddCommand(updateCheckCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
+9 -1
View File
@@ -12,7 +12,15 @@ import (
var Version = "dev"
func init() {
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode")
runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process")
runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)")
runCmd.Flags().String("log-level", "", "Log level: debug, info, warn, error, fatal (overrides DMS_LOG_LEVEL)")
runCmd.Flags().String("log-file", "", "Append logs to this file in addition to stderr (overrides DMS_LOG_FILE)")
runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
authCmd.AddCommand(authSyncCmd)
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
rootCmd.AddCommand(getCommonCommands()...)
-10
View File
@@ -12,10 +12,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
)
// maxIPCMessageSize allows room for a 50 MB clipboard entry plus JSON/base64
// overhead in the line-delimited IPC response.
const maxIPCMessageSize = 96 * 1024 * 1024
func sendServerRequest(req models.Request) (*models.Response[any], error) {
socketPath := getServerSocketPath()
@@ -26,7 +22,6 @@ func sendServerRequest(req models.Request) (*models.Response[any], error) {
defer conn.Close()
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, bufio.MaxScanTokenSize), maxIPCMessageSize)
scanner.Scan() // discard initial capabilities message
reqData, err := json.Marshal(req)
@@ -66,7 +61,6 @@ func sendServerRequestFireAndForget(req models.Request) error {
defer conn.Close()
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, bufio.MaxScanTokenSize), maxIPCMessageSize)
scanner.Scan() // discard initial capabilities message
reqData, err := json.Marshal(req)
@@ -101,10 +95,6 @@ func getServerSocketPath() string {
runtimeDir = os.TempDir()
}
if sessionSock, ok := shellApp.SessionSocketPath(); ok {
return sessionSock
}
entries, err := os.ReadDir(runtimeDir)
if err != nil {
return filepath.Join(runtimeDir, "danklinux.sock")
+597 -160
View File
@@ -2,28 +2,554 @@ package main
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
)
type ipcTargets map[string]map[string][]string
var qsHasAnyDisplay = sync.OnceValue(func() bool {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
// getProcessExitCode returns the exit code from a ProcessState.
// For normal exits, returns the exit code directly.
// For signal termination, returns 128 + signal number (Unix convention).
func getProcessExitCode(state *os.ProcessState) int {
if state == nil {
return 1
}
if code := state.ExitCode(); code != -1 {
return code
}
// Process was killed by signal - extract signal number
if status, ok := state.Sys().(syscall.WaitStatus); ok {
if status.Signaled() {
return 128 + int(status.Signal())
}
}
return 1
}
var isSessionManaged bool
func execDetachedRestart(targetPID int) {
selfPath, err := os.Executable()
if err != nil {
return
}
cmd := exec.Command(selfPath, "restart-detached", strconv.Itoa(targetPID))
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
}
cmd.Start()
}
func runDetachedRestart(targetPIDStr string) {
targetPID, err := strconv.Atoi(targetPIDStr)
if err != nil {
return
}
time.Sleep(200 * time.Millisecond)
proc, err := os.FindProcess(targetPID)
if err == nil {
proc.Signal(syscall.SIGTERM)
}
time.Sleep(500 * time.Millisecond)
killShell()
runShellDaemon(false)
}
func getRuntimeDir() string {
if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" {
return runtime
}
return os.TempDir()
}
func appendLogEnv(env []string) []string {
if v := os.Getenv("DMS_LOG_LEVEL"); v != "" {
env = append(env, "DMS_LOG_LEVEL="+v)
}
if v := os.Getenv("DMS_LOG_FILE"); v != "" {
env = append(env, "DMS_LOG_FILE="+v)
}
return env
}
func hasSystemdRun() bool {
_, err := exec.LookPath("systemd-run")
return err == nil
}
func getPIDFilePath() string {
return filepath.Join(getRuntimeDir(), fmt.Sprintf("danklinux-%d.pid", os.Getpid()))
}
func writePIDFile(childPID int) error {
pidFile := getPIDFilePath()
return os.WriteFile(pidFile, []byte(strconv.Itoa(childPID)), 0o644)
}
func removePIDFile() {
pidFile := getPIDFilePath()
os.Remove(pidFile)
}
func getAllDMSPIDs() []int {
dir := getRuntimeDir()
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var pids []int
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), "danklinux-") || !strings.HasSuffix(entry.Name(), ".pid") {
continue
}
pidFile := filepath.Join(dir, entry.Name())
data, err := os.ReadFile(pidFile)
if err != nil {
continue
}
childPID, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
os.Remove(pidFile)
continue
}
proc, err := os.FindProcess(childPID)
if err != nil {
os.Remove(pidFile)
continue
}
if err := proc.Signal(syscall.Signal(0)); err != nil {
os.Remove(pidFile)
continue
}
pids = append(pids, childPID)
parentPIDStr := strings.TrimPrefix(entry.Name(), "danklinux-")
parentPIDStr = strings.TrimSuffix(parentPIDStr, ".pid")
if parentPID, err := strconv.Atoi(parentPIDStr); err == nil {
if parentProc, err := os.FindProcess(parentPID); err == nil {
if err := parentProc.Signal(syscall.Signal(0)); err == nil {
pids = append(pids, parentPID)
}
}
}
}
return pids
}
func runShellInteractive(session bool) {
isSessionManaged = session
go printASCII()
fmt.Fprintf(os.Stderr, "dms %s\n", Version)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cmd := exec.CommandContext(ctx, "qs", "ipc", "--help")
cmd.WaitDelay = 500 * time.Millisecond
out, err := cmd.Output()
socketPath := server.GetSocketPath()
configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path")
if err := os.WriteFile(configStateFile, []byte(configPath), 0o644); err != nil {
log.Warnf("Failed to write config state file: %v", err)
}
defer os.Remove(configStateFile)
errChan := make(chan error, 2)
go func() {
defer func() {
if r := recover(); r != nil {
errChan <- fmt.Errorf("server panic: %v", r)
}
}()
server.CLIVersion = Version
if err := server.Start(false); err != nil {
errChan <- fmt.Errorf("server error: %w", err)
}
}()
log.Infof("Spawning quickshell with -p %s", configPath)
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
if os.Getenv("QT_LOGGING_RULES") == "" {
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
}
}
if isSessionManaged && hasSystemdRun() {
cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope")
}
homeDir, err := os.UserHomeDir()
if err == nil && os.Getenv("DMS_DISABLE_HOT_RELOAD") == "" {
if !strings.HasPrefix(configPath, homeDir) {
cmd.Env = append(cmd.Env, "DMS_DISABLE_HOT_RELOAD=1")
}
}
if os.Getenv("QT_QPA_PLATFORMTHEME") == "" {
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME=gtk3")
}
if os.Getenv("QT_QPA_PLATFORMTHEME_QT6") == "" {
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME_QT6=gtk3")
}
if os.Getenv("QT_QPA_PLATFORM") == "" {
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
}
cmd.Env = appendLogEnv(cmd.Env)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
log.Fatalf("Error starting quickshell: %v", err)
}
// Write PID file for the quickshell child process
if err := writePIDFile(cmd.Process.Pid); err != nil {
log.Warnf("Failed to write PID file: %v", err)
}
defer removePIDFile()
defer func() {
if cmd.Process != nil {
cmd.Process.Signal(syscall.SIGTERM)
}
}()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGUSR1)
go func() {
if err := cmd.Wait(); err != nil {
errChan <- fmt.Errorf("quickshell exited: %w", err)
} else {
errChan <- fmt.Errorf("quickshell exited")
}
}()
for {
select {
case sig := <-sigChan:
if sig == syscall.SIGUSR1 {
if isSessionManaged {
log.Infof("Received SIGUSR1, exiting for systemd restart...")
cancel()
cmd.Process.Signal(syscall.SIGTERM)
os.Remove(socketPath)
os.Exit(1)
}
log.Infof("Received SIGUSR1, spawning detached restart process...")
execDetachedRestart(os.Getpid())
return
}
// Check if qs already crashed before we got SIGTERM (systemd sends SIGTERM when D-Bus name is released)
select {
case <-errChan:
cancel()
os.Remove(socketPath)
os.Exit(getProcessExitCode(cmd.ProcessState))
case <-time.After(500 * time.Millisecond):
}
log.Infof("\nReceived signal %v, shutting down...", sig)
cancel()
cmd.Process.Signal(syscall.SIGTERM)
os.Remove(socketPath)
return
case err := <-errChan:
log.Error(err)
cancel()
if cmd.Process != nil {
cmd.Process.Signal(syscall.SIGTERM)
}
os.Remove(socketPath)
os.Exit(getProcessExitCode(cmd.ProcessState))
}
}
}
func restartShell() {
pids := getAllDMSPIDs()
if len(pids) == 0 {
log.Info("No running DMS shell instances found. Starting daemon...")
runShellDaemon(false)
return
}
currentPid := os.Getpid()
uniquePids := make(map[int]bool)
for _, pid := range pids {
if pid != currentPid {
uniquePids[pid] = true
}
}
for pid := range uniquePids {
proc, err := os.FindProcess(pid)
if err != nil {
log.Errorf("Error finding process %d: %v", pid, err)
continue
}
if err := proc.Signal(syscall.Signal(0)); err != nil {
continue
}
if err := proc.Signal(syscall.SIGUSR1); err != nil {
log.Errorf("Error sending SIGUSR1 to process %d: %v", pid, err)
} else {
log.Infof("Sent SIGUSR1 to DMS process with PID %d", pid)
}
}
}
func killShell() {
pids := getAllDMSPIDs()
if len(pids) == 0 {
log.Info("No running DMS shell instances found.")
return
}
currentPid := os.Getpid()
uniquePids := make(map[int]bool)
for _, pid := range pids {
if pid != currentPid {
uniquePids[pid] = true
}
}
for pid := range uniquePids {
proc, err := os.FindProcess(pid)
if err != nil {
log.Errorf("Error finding process %d: %v", pid, err)
continue
}
if err := proc.Signal(syscall.Signal(0)); err != nil {
continue
}
if err := proc.Kill(); err != nil {
log.Errorf("Error killing process %d: %v", pid, err)
} else {
log.Infof("Killed DMS process with PID %d", pid)
}
}
dir := getRuntimeDir()
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), "danklinux-") && strings.HasSuffix(entry.Name(), ".pid") {
pidFile := filepath.Join(dir, entry.Name())
os.Remove(pidFile)
}
}
}
func runShellDaemon(session bool) {
isSessionManaged = session
isDaemonChild := slices.Contains(os.Args, "--daemon-child")
if !isDaemonChild {
fmt.Fprintf(os.Stderr, "dms %s\n", Version)
cmd := exec.Command(os.Args[0], "run", "-d", "--daemon-child")
cmd.Env = os.Environ()
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
}
if err := cmd.Start(); err != nil {
log.Fatalf("Error starting daemon: %v", err)
}
log.Infof("DMS shell daemon started (PID: %d)", cmd.Process.Pid)
return
}
fmt.Fprintf(os.Stderr, "dms %s\n", Version)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
socketPath := server.GetSocketPath()
configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path")
if err := os.WriteFile(configStateFile, []byte(configPath), 0o644); err != nil {
log.Warnf("Failed to write config state file: %v", err)
}
defer os.Remove(configStateFile)
errChan := make(chan error, 2)
go func() {
defer func() {
if r := recover(); r != nil {
errChan <- fmt.Errorf("server panic: %v", r)
}
}()
server.CLIVersion = Version
if err := server.Start(false); err != nil {
errChan <- fmt.Errorf("server error: %w", err)
}
}()
log.Infof("Spawning quickshell with -p %s", configPath)
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
if os.Getenv("QT_LOGGING_RULES") == "" {
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
}
}
// ! TODO - remove when QS 0.3 is up and we can use the pragma
cmd.Env = append(cmd.Env, "QS_APP_ID=com.danklinux.dms")
if isSessionManaged && hasSystemdRun() {
cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope")
}
homeDir, err := os.UserHomeDir()
if err == nil && os.Getenv("DMS_DISABLE_HOT_RELOAD") == "" {
if !strings.HasPrefix(configPath, homeDir) {
cmd.Env = append(cmd.Env, "DMS_DISABLE_HOT_RELOAD=1")
}
}
if os.Getenv("QT_QPA_PLATFORMTHEME") == "" {
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME=gtk3")
}
if os.Getenv("QT_QPA_PLATFORMTHEME_QT6") == "" {
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME_QT6=gtk3")
}
if os.Getenv("QT_QPA_PLATFORM") == "" {
cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb")
}
cmd.Env = appendLogEnv(cmd.Env)
devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0)
if err != nil {
log.Fatalf("Error opening /dev/null: %v", err)
}
defer devNull.Close()
cmd.Stdin = devNull
cmd.Stdout = devNull
cmd.Stderr = devNull
if err := cmd.Start(); err != nil {
log.Fatalf("Error starting daemon: %v", err)
}
// Write PID file for the quickshell child process
if err := writePIDFile(cmd.Process.Pid); err != nil {
log.Warnf("Failed to write PID file: %v", err)
}
defer removePIDFile()
defer func() {
if cmd.Process != nil {
cmd.Process.Signal(syscall.SIGTERM)
}
}()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGUSR1)
go func() {
if err := cmd.Wait(); err != nil {
errChan <- fmt.Errorf("quickshell exited: %w", err)
} else {
errChan <- fmt.Errorf("quickshell exited")
}
}()
for {
select {
case sig := <-sigChan:
if sig == syscall.SIGUSR1 {
if isSessionManaged {
log.Infof("Received SIGUSR1, exiting for systemd restart...")
cancel()
cmd.Process.Signal(syscall.SIGTERM)
os.Remove(socketPath)
os.Exit(1)
}
log.Infof("Received SIGUSR1, spawning detached restart process...")
execDetachedRestart(os.Getpid())
return
}
// Check if qs already crashed before we got SIGTERM (systemd sends SIGTERM when D-Bus name is released)
select {
case <-errChan:
cancel()
os.Remove(socketPath)
os.Exit(getProcessExitCode(cmd.ProcessState))
case <-time.After(500 * time.Millisecond):
}
cancel()
cmd.Process.Signal(syscall.SIGTERM)
os.Remove(socketPath)
return
case <-errChan:
cancel()
if cmd.Process != nil {
cmd.Process.Signal(syscall.SIGTERM)
}
os.Remove(socketPath)
os.Exit(getProcessExitCode(cmd.ProcessState))
}
}
}
var qsHasAnyDisplay = sync.OnceValue(func() bool {
out, err := exec.Command("qs", "ipc", "--help").Output()
if err != nil {
return false
}
@@ -59,34 +585,13 @@ func parseTargetsFromIPCShowOutput(output string) ipcTargets {
return targets
}
func buildQsIPCBaseArgs() ([]string, error) {
cmdArgs := []string{"ipc"}
switch pid, ok := shellApp.SessionPID(); {
case ok:
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
default:
if err := shellApp.ResolveConfig(nil, nil); err != nil {
return nil, err
}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", shellApp.ConfigPath())
}
return cmdArgs, nil
}
func getShellIPCCompletions(args []string, _ string) []string {
baseArgs, err := buildQsIPCBaseArgs()
if err != nil {
log.Debugf("Error building IPC args for completions: %v", err)
return nil
cmdArgs := []string{"ipc"}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs := append(baseArgs, "show")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "qs", cmdArgs...)
cmd.WaitDelay = 500 * time.Millisecond
cmdArgs = append(cmdArgs, "-p", configPath, "show")
cmd := exec.Command("qs", cmdArgs...)
var targets ipcTargets
if output, err := cmd.Output(); err == nil {
@@ -102,7 +607,7 @@ func getShellIPCCompletions(args []string, _ string) []string {
if len(args) == 0 {
targetNames := make([]string, 0)
targetNames = append(targetNames, "call", "list")
targetNames = append(targetNames, "call")
for k := range targets {
targetNames = append(targetNames, k)
}
@@ -128,6 +633,43 @@ func getShellIPCCompletions(args []string, _ string) []string {
return nil
}
func getFirstDMSPID() (int, bool) {
dir := getRuntimeDir()
entries, err := os.ReadDir(dir)
if err != nil {
return 0, false
}
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), "danklinux-") || !strings.HasSuffix(entry.Name(), ".pid") {
continue
}
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
continue
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
continue
}
proc, err := os.FindProcess(pid)
if err != nil {
continue
}
if proc.Signal(syscall.Signal(0)) != nil {
continue
}
return pid, true
}
return 0, false
}
func runShellIPCCommand(args []string) {
if len(args) == 0 {
printIPCHelp()
@@ -138,11 +680,23 @@ func runShellIPCCommand(args []string) {
args = append([]string{"call"}, args...)
}
baseArgs, err := buildQsIPCBaseArgs()
if err != nil {
log.Fatalf("Error finding config: %v", err)
cmdArgs := []string{"ipc"}
switch pid, ok := getFirstDMSPID(); {
case ok:
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
default:
if err := findConfig(nil, nil); err != nil {
log.Fatalf("Error finding config: %v", err)
}
// ! TODO - remove check when QS 0.3 is released
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs = append(cmdArgs, "-p", configPath)
}
cmdArgs := append(baseArgs, args...)
cmdArgs = append(cmdArgs, args...)
cmd := exec.Command("qs", cmdArgs...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
@@ -154,20 +708,19 @@ func runShellIPCCommand(args []string) {
}
func printIPCHelp() {
fmt.Println("Usage: dms ipc call <target> <function> [args...]")
fmt.Println("Usage: dms ipc <target> <function> [args...]")
fmt.Println()
baseArgs, err := buildQsIPCBaseArgs()
if err != nil {
printIPCHelpFailure(err)
return
cmdArgs := []string{"ipc"}
if qsHasAnyDisplay() {
cmdArgs = append(cmdArgs, "--any-display")
}
cmdArgs := append(baseArgs, "show")
cmdArgs = append(cmdArgs, "-p", configPath, "show")
cmd := exec.Command("qs", cmdArgs...)
output, err := cmd.Output()
if err != nil {
printIPCHelpFailure(err)
fmt.Println("Could not retrieve available IPC targets (is DMS running?)")
return
}
@@ -195,119 +748,3 @@ func printIPCHelp() {
fmt.Printf(" %-16s %s\n", targetName, strings.Join(funcNames, ", "))
}
}
func printIPCHelpFailure(err error) {
fmt.Println("Could not retrieve IPC targets.")
if err != nil {
fmt.Printf(" %v\n", err)
}
fmt.Println()
fmt.Println(" Full docs: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc")
fmt.Println(" Try: dms ipc call <target> <function>")
}
// ensureFontCache rebuilds the fontconfig cache if user-configured fonts are missing while skipping defaults
func ensureFontCache() {
if _, err := exec.LookPath("fc-list"); err != nil {
return
}
if _, err := exec.LookPath("fc-cache"); err != nil {
return
}
var fontsToCheck []string
if configDir, err := os.UserConfigDir(); err == nil {
settingsPath := filepath.Join(configDir, "DankMaterialShell", "settings.json")
if data, err := os.ReadFile(settingsPath); err == nil {
var settings struct {
FontFamily string `json:"fontFamily"`
MonoFontFamily string `json:"monoFontFamily"`
}
if err := json.Unmarshal(data, &settings); err == nil {
if settings.FontFamily != "" && settings.FontFamily != "Inter Variable" {
fontsToCheck = append(fontsToCheck, settings.FontFamily)
}
if settings.MonoFontFamily != "" && settings.MonoFontFamily != "Fira Code" {
fontsToCheck = append(fontsToCheck, settings.MonoFontFamily)
}
}
}
}
if len(fontsToCheck) == 0 {
return
}
output, err := exec.Command("fc-list", ":", "family").Output()
if err != nil || len(strings.TrimSpace(string(output))) == 0 {
log.Warnf("Font cache appears empty or corrupt, rebuilding...")
rebuildFontCache()
return
}
cacheFonts := strings.ToLower(string(output))
var missing []string
for _, font := range fontsToCheck {
if !fontInCache(strings.ToLower(font), cacheFonts) {
missing = append(missing, font)
}
}
if len(missing) > 0 {
log.Warnf("Font(s) not found in cache: %s — rebuilding...", strings.Join(missing, ", "))
rebuildFontCache()
}
}
func fontInCache(target, cache string) bool {
for _, line := range strings.Split(cache, "\n") {
for _, fam := range strings.Split(strings.TrimSpace(line), ",") {
if strings.TrimSpace(fam) == target {
return true
}
}
}
return false
}
func rebuildFontCache() {
cmd := exec.Command("fc-cache", "-f")
if output, err := cmd.CombinedOutput(); err != nil {
log.Warnf("Failed to rebuild font cache: %v\n%s", err, string(output))
} else {
log.Infof("Font cache rebuilt successfully")
}
}
// logStartupFailure logs diagnostic advice if qs crashes within 5s of launch.
func logStartupFailure(exitCode int, uptime time.Duration, stderrTail string) {
if uptime >= 5*time.Second || exitCode == 0 || exitCode > 128 {
return
}
if containsFontCrashSignature(stderrTail) {
log.Errorf("DMS startup failed due to a potential font/rendering crash. Try running 'fc-cache -fv' and restarting DMS.")
} else {
log.Errorf("DMS startup failed (exit code %d). Run 'dms doctor' for more diagnostics.", exitCode)
}
}
func containsFontCrashSignature(logStr string) bool {
logStr = strings.ToLower(logStr)
signatures := []string{
"fontconfig",
"freetype",
"ft_load_glyph",
"ft_face",
"fc-list",
"fc-cache",
"glyph",
"typeface",
}
for _, sig := range signatures {
if strings.Contains(logStr, sig) {
return true
}
}
return false
}
-82
View File
@@ -1,82 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
"github.com/AvengeMedia/DankMaterialShell/core/internal/shellembed"
"github.com/AvengeMedia/dankgo/shellapp"
)
var shellApp = shellapp.New(shellapp.Config{
ID: "danklinux",
EnvPrefix: "DMS",
QSAppID: "com.danklinux.dms",
Version: Version,
Embedded: embeddedShell{},
Boot: bootBackend,
PreLaunch: preLaunch,
ExtraEnv: dmsExtraEnv,
OnUIExit: logStartupFailure,
})
type embeddedShell struct{}
func (embeddedShell) Available() bool { return shellembed.Available() }
func (embeddedShell) Extract(baseDir string) (string, error) { return shellembed.Extract(baseDir) }
func (embeddedShell) Prune(baseDir, keep string) { shellembed.Prune(baseDir, keep) }
type dmsBackend struct {
srv *server.Server
done chan error
}
func (b *dmsBackend) SocketPath() string { return b.srv.SocketPath() }
func (b *dmsBackend) Close() { b.srv.Close() }
func (b *dmsBackend) Done() <-chan error { return b.done }
func bootBackend(ctx context.Context) (shellapp.Backend, error) {
config.CleanupStrayHyprlandConfFile(log.Infof)
server.CLIVersion = Version
srv := server.New()
if err := srv.Listen(); err != nil {
return nil, err
}
backend := &dmsBackend{srv: srv, done: make(chan error, 1)}
go func() {
defer func() {
if r := recover(); r != nil {
backend.done <- fmt.Errorf("server panic: %v", r)
}
}()
backend.done <- srv.Serve(false)
}()
return backend, nil
}
func preLaunch() {
go printASCII()
ensureFontCache()
}
func dmsExtraEnv(string) []string {
var env []string
if selfPath, err := os.Executable(); err == nil {
env = append(env, "DMS_EXECUTABLE="+selfPath)
}
if os.Getenv("QSG_USE_SIMPLE_ANIMATION_DRIVER") == "" {
env = append(env, "QSG_USE_SIMPLE_ANIMATION_DRIVER=1")
}
return env
}
+61
View File
@@ -1,7 +1,9 @@
package main
import (
"fmt"
"os/exec"
"slices"
"strings"
)
@@ -26,3 +28,62 @@ func isArchPackageInstalled(packageName string) bool {
err := cmd.Run()
return err == nil
}
type systemdServiceState struct {
Name string
EnabledState string
NeedsDisable bool
Exists bool
}
// checkSystemdServiceEnabled returns (state, should_disable, error) for a systemd service
func checkSystemdServiceEnabled(serviceName string) (string, bool, error) {
cmd := exec.Command("systemctl", "is-enabled", serviceName)
output, err := cmd.Output()
stateStr := strings.TrimSpace(string(output))
if err != nil {
knownStates := []string{"disabled", "masked", "masked-runtime", "not-found", "enabled", "enabled-runtime", "static", "indirect", "alias"}
isKnownState := slices.Contains(knownStates, stateStr)
if !isKnownState {
return stateStr, false, fmt.Errorf("systemctl is-enabled failed: %w (output: %s)", err, stateStr)
}
}
shouldDisable := false
switch stateStr {
case "enabled", "enabled-runtime", "static", "indirect", "alias":
shouldDisable = true
case "disabled", "masked", "masked-runtime", "not-found":
shouldDisable = false
default:
shouldDisable = true
}
return stateStr, shouldDisable, nil
}
func getSystemdServiceState(serviceName string) (*systemdServiceState, error) {
state := &systemdServiceState{
Name: serviceName,
Exists: false,
}
enabledState, needsDisable, err := checkSystemdServiceEnabled(serviceName)
if err != nil {
return nil, fmt.Errorf("failed to check enabled state: %w", err)
}
state.EnabledState = enabledState
state.NeedsDisable = needsDisable
if enabledState == "not-found" {
state.Exists = false
return state, nil
}
state.Exists = true
return state, nil
}
+27 -27
View File
@@ -1,13 +1,14 @@
module github.com/AvengeMedia/DankMaterialShell/core
go 1.26.4
go 1.26.1
require (
github.com/Wifx/gonetworkmanager/v2 v2.2.0
github.com/alecthomas/chroma/v2 v2.27.0
github.com/alecthomas/chroma/v2 v2.24.1
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v1.0.0
github.com/fsnotify/fsnotify v1.10.1
github.com/godbus/dbus/v5 v5.2.2
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847
@@ -17,12 +18,13 @@ require (
github.com/stretchr/testify v1.11.1
github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/standard v1.3.0
github.com/yuin/goldmark v1.8.4
github.com/yuin/goldmark v1.8.2
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.etcd.io/bbolt v1.5.0
go.etcd.io/bbolt v1.4.3
go4.org/mem v0.0.0-20240501181205-ae6ca9944745
golang.org/x/image v0.44.0
tailscale.com v1.100.0
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
golang.org/x/image v0.39.0
tailscale.com v1.96.5
)
require (
@@ -30,28 +32,28 @@ require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/charmbracelet/log v1.0.0 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.4 // indirect
github.com/coder/websocket v1.8.15 // indirect
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca // indirect
github.com/dlclark/regexp2/v2 v2.5.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fogleman/gg v1.3.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-git/gcfg/v2 v2.0.2 // indirect
github.com/go-git/go-billy/v6 v6.0.0-alpha.1 // indirect
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 // indirect
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/mdlayher/netlink v1.11.2 // indirect
github.com/mdlayher/socket v0.6.1 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/mdlayher/netlink v1.11.1 // indirect
github.com/mdlayher/socket v0.6.0 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
@@ -61,15 +63,13 @@ require (
github.com/x448/float16 v0.8.4 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
)
require (
github.com/AvengeMedia/dankgo v0.0.0-20260718184203-ed59ffc6e599
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
@@ -79,22 +79,22 @@ require (
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-git/go-git/v6 v6.0.0-alpha.4
github.com/go-git/go-git/v6 v6.0.0-alpha.2
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0
github.com/mattn/go-isatty v0.0.23
github.com/mattn/go-isatty v0.0.22
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/muesli/termenv v0.16.0
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/afero v1.15.0
github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.47.0
golang.org/x/text v0.40.0
golang.org/x/sys v0.43.0
golang.org/x/text v0.36.0
gopkg.in/yaml.v3 v3.0.1
)
+56 -56
View File
@@ -1,7 +1,5 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/AvengeMedia/dankgo v0.0.0-20260718184203-ed59ffc6e599 h1:hxy5SRGREsQ6v4xUOxWnWVPWD6ZU+H0jA+LHcTwXbCg=
github.com/AvengeMedia/dankgo v0.0.0-20260718184203-ed59ffc6e599/go.mod h1:7p7cfydr4WM1G6eOPFlANXF3IV5du3FoA4CbDPprHAo=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
@@ -13,8 +11,8 @@ github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukD
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
@@ -50,23 +48,25 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U=
github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca h1:h1Awca4lQOspNR/2eeo04Ricn5NixDX9mb17WSAgLhQ=
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca/go.mod h1:2TGl1jRJrRpbzykmg7asHm3h08TqutUgQqY5v9k/g3c=
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d h1:QRKpU+9ZBDs62LyBfwhZkJdB5DJX2Sm3p4kUh7l1aA0=
github.com/dblohm7/wingoes v0.0.0-20250822163801-6d8e6105c62d/go.mod h1:SUxUaAK/0UG5lYyZR1L1nC4AaYYvSSYTWQSH3FPcxKU=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.5.1 h1:E5Ug7Dh264W1ymdySmiHNcDG7fmsR307APCE5R07a20=
github.com/dlclark/regexp2/v2 v2.5.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
@@ -85,14 +85,14 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
github.com/go-git/go-billy/v6 v6.0.0-alpha.1 h1:xVjAR4oUvrKy7/Xuw/lLlV3gkxR3KO2H8W+MamuVVsQ=
github.com/go-git/go-billy/v6 v6.0.0-alpha.1/go.mod h1:eaCUpHbedW7//EwcYmUDfJe2N6sJC9O12AT0OTqJR1E=
github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1 h1:gmqi2jvsreu0s8JMLylYDFq4sbjHwwlhktMw0DUg3mA=
github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1/go.mod h1:ECf1MqJlBdYpKggBrOXjo/0EnvRZx6D++I86UYjPgAQ=
github.com/go-git/go-git/v6 v6.0.0-alpha.4 h1:aDTc2UGanmaE7FkGLSlBEB9nohMnQ+RKXcfq/D+esDQ=
github.com/go-git/go-git/v6 v6.0.0-alpha.4/go.mod h1:4ODa/G7hPWrh4Y+7lmt59Ij3zW38IEfvRoAZxLYYBhc=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266 h1:wH21vHuv323v9x78JNFNJ6P7HEAsdwr9yq2k9/o4zEE=
github.com/go-git/go-billy/v6 v6.0.0-20260504142752-cb8e9d337266/go.mod h1:CdBVp7CXl9l3sOyNEog46cP1Pvx/hjCe9AD0mtaIUYU=
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsdvaghuVfIr7HpNTmFDLu2nz3I2iGqyn6Uk6MkJc=
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s=
github.com/go-git/go-git/v6 v6.0.0-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI=
github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak=
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4 h1:2WmHkJINIjgXXYDGik8d3oJvFA3DAwPy00csDJ3vo+o=
github.com/go-json-experiment/json v0.0.0-20260430182902-b6187a392ed4/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@@ -117,8 +117,8 @@ github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5G
github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM=
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -128,16 +128,16 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw=
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI=
github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA=
github.com/mdlayher/socket v0.6.1 h1:M7uj2NtuujUY4mYr1C57NmfNiRHbkKpnBxO856lsc3A=
github.com/mdlayher/socket v0.6.1/go.mod h1:+/SGtqc9V+5dAuRgQsU0fGBI+oRDiW7O2Obx10OIWfg=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mdlayher/netlink v1.11.1 h1:T136gDS6Gkt+hLncaBwKdW5GpEC8Z0ykqimOebVoal0=
github.com/mdlayher/netlink v1.11.1/go.mod h1:ao4LjamyK4Uq9L8+fQzqFYpAncbeCdwbvd9Edv/pYnc=
github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU=
github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
@@ -180,8 +180,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad h1:Ky26FR5yZ5IKEB0xtm5A8xSTb06ImY7kxBFrvgOmJSg=
github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
@@ -193,36 +193,36 @@ github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 h1:qLvzZeaANDgyVOA8pyHCOStGlXn0rseXma+GQjeuv2g=
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
@@ -235,5 +235,5 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
tailscale.com v1.100.0 h1:nm/M/dEaW9RaRsGUjW2HsSDpsZ60Jwd9k4gNW9tTFiE=
tailscale.com v1.100.0/go.mod h1:DQ9YBy85DpNlSyeU2XRIWzbAu3RsGp/frv+Khg57meE=
tailscale.com v1.96.5 h1:gNkfA/KSZAl6jCH9cj8urq00HRWItDDTtGsyATI89jA=
tailscale.com v1.96.5/go.mod h1:/3lnZBYb2UEwnN0MNu2SDXUtT06AGd5k0s+OWx3WmcY=
+316 -144
View File
@@ -9,6 +9,7 @@ import (
"syscall"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
const envServe = "_DMS_CLIPBOARD_SERVE"
@@ -16,37 +17,6 @@ const envMime = "_DMS_CLIPBOARD_MIME"
const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE"
const envCacheFile = "_DMS_CLIPBOARD_CACHE"
type Offer struct {
MimeType string
Data []byte
}
// textMimeAliases are offered alongside plain-text content so legacy X11
// clients bridged through XWayland find a target they can convert.
var textMimeAliases = []string{
"text/plain",
"text/plain;charset=utf-8",
"UTF8_STRING",
"STRING",
"TEXT",
}
// ExpandOffers turns raw clipboard data into the full offer list to serve,
// adding the standard alias set for text content.
func ExpandOffers(data []byte, mimeType string) []Offer {
offers := []Offer{{MimeType: mimeType, Data: data}}
if mimeType != "text/plain" && mimeType != "text/plain;charset=utf-8" {
return offers
}
for _, alias := range textMimeAliases {
if alias == mimeType {
continue
}
offers = append(offers, Offer{MimeType: alias, Data: data})
}
return offers
}
// MaybeServeAndExit intercepts before cobra when re-exec'd as a clipboard
// child. Reads source data into memory, deletes any cache file, then serves.
func MaybeServeAndExit() {
@@ -74,7 +44,7 @@ func MaybeServeAndExit() {
os.Exit(1)
}
if err := serveOffers(ExpandOffers(data, mimeType), pasteOnce); err != nil {
if err := serveClipboard(data, mimeType, pasteOnce); err != nil {
fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err)
os.Exit(1)
}
@@ -85,33 +55,22 @@ func Copy(data []byte, mimeType string) error {
return copyForkCached(data, mimeType, false)
}
func CopyText(text string) error {
return Copy([]byte(text), "text/plain;charset=utf-8")
}
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
if foreground {
return serveOffers(ExpandOffers(data, mimeType), pasteOnce)
return serveClipboard(data, mimeType, pasteOnce)
}
return copyForkCached(data, mimeType, pasteOnce)
}
func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error {
if !foreground {
return copyFork(data, mimeType, pasteOnce)
}
buf, err := io.ReadAll(data)
if err != nil {
return fmt.Errorf("read source: %w", err)
}
return serveOffers(ExpandOffers(buf, mimeType), pasteOnce)
}
func CopyMulti(offers []Offer, foreground, pasteOnce bool) error {
if foreground {
return serveOffers(offers, pasteOnce)
buf, err := io.ReadAll(data)
if err != nil {
return fmt.Errorf("read source: %w", err)
}
return serveClipboard(buf, mimeType, pasteOnce)
}
return copyMultiFork(offers, pasteOnce)
return copyFork(data, mimeType, pasteOnce)
}
func newForkCmd(mimeType string, pasteOnce bool, extra ...string) *exec.Cmd {
@@ -173,70 +132,39 @@ func copyForkCached(data []byte, mimeType string, pasteOnce bool) error {
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
cmd := newForkCmd(mimeType, pasteOnce)
if src, ok := data.(*os.File); ok {
switch src := data.(type) {
case *os.File:
cmd.Stdin = src
return waitReady(cmd)
}
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
if _, err := io.Copy(stdin, data); err != nil {
stdin.Close()
return fmt.Errorf("write stdin: %w", err)
}
if err := stdin.Close(); err != nil {
return fmt.Errorf("close stdin: %w", err)
}
var buf [1]byte
if _, err := stdout.Read(buf[:]); err != nil {
return fmt.Errorf("waiting for clipboard ready: %w", err)
}
return nil
}
func copyMultiFork(offers []Offer, pasteOnce bool) error {
args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"}
if pasteOnce {
args = append(args, "--paste-once")
}
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
for _, offer := range offers {
fmt.Fprintf(stdin, "%s\x00%d\x00", offer.MimeType, len(offer.Data))
if _, err := stdin.Write(offer.Data); err != nil {
stdin.Close()
return fmt.Errorf("write offer data: %w", err)
default:
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
}
stdin.Close()
return nil
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
if _, err := io.Copy(stdin, data); err != nil {
stdin.Close()
return fmt.Errorf("write stdin: %w", err)
}
if err := stdin.Close(); err != nil {
return fmt.Errorf("close stdin: %w", err)
}
var buf [1]byte
if _, err := stdout.Read(buf[:]); err != nil {
return fmt.Errorf("waiting for clipboard ready: %w", err)
}
return nil
}
}
func signalReady() {
@@ -266,25 +194,57 @@ func createClipboardCacheFile() (*os.File, error) {
return os.CreateTemp("", "dms-clipboard-*")
}
// serveOffers owns the Wayland selection until cancelled (or first paste when
// pasteOnce is set), answering every offered mime type with its data.
func serveOffers(offers []Offer, pasteOnce bool) error {
if len(offers) == 0 {
return fmt.Errorf("no offers to serve")
}
s, err := connectSession()
func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
display, err := wlclient.Connect("")
if err != nil {
return err
return fmt.Errorf("wayland connect: %w", err)
}
defer s.Close()
defer display.Destroy()
dataControlMgr, err := s.requireDataControl()
ctx := display.Context()
registry, err := display.GetRegistry()
if err != nil {
return err
return fmt.Errorf("get registry: %w", err)
}
defer registry.Destroy()
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
var seat *wlclient.Seat
var bindErr error
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
switch e.Interface {
case "ext_data_control_manager_v1":
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
bindErr = err
}
case "wl_seat":
if seat != nil {
return
}
seat = wlclient.NewSeat(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
bindErr = err
}
}
})
display.Roundtrip()
display.Roundtrip()
if bindErr != nil {
return fmt.Errorf("registry bind: %w", bindErr)
}
if dataControlMgr == nil {
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
}
defer dataControlMgr.Destroy()
if seat == nil {
return fmt.Errorf("no seat available")
}
device, err := dataControlMgr.GetDataDevice(s.seat)
device, err := dataControlMgr.GetDataDevice(seat)
if err != nil {
return fmt.Errorf("get data device: %w", err)
}
@@ -295,12 +255,25 @@ func serveOffers(offers []Offer, pasteOnce bool) error {
return fmt.Errorf("create data source: %w", err)
}
offerData := make(map[string][]byte, len(offers))
for _, offer := range offers {
if err := source.Offer(offer.MimeType); err != nil {
return fmt.Errorf("offer %s: %w", offer.MimeType, err)
if err := source.Offer(mimeType); err != nil {
return fmt.Errorf("offer mime type: %w", err)
}
if mimeType == "text/plain;charset=utf-8" || mimeType == "text/plain" {
if err := source.Offer("text/plain"); err != nil {
return fmt.Errorf("offer text/plain: %w", err)
}
if err := source.Offer("text/plain;charset=utf-8"); err != nil {
return fmt.Errorf("offer text/plain;charset=utf-8: %w", err)
}
if err := source.Offer("UTF8_STRING"); err != nil {
return fmt.Errorf("offer UTF8_STRING: %w", err)
}
if err := source.Offer("STRING"); err != nil {
return fmt.Errorf("offer STRING: %w", err)
}
if err := source.Offer("TEXT"); err != nil {
return fmt.Errorf("offer TEXT: %w", err)
}
offerData[offer.MimeType] = offer.Data
}
cancelled := make(chan struct{})
@@ -310,11 +283,7 @@ func serveOffers(offers []Offer, pasteOnce bool) error {
_ = syscall.SetNonblock(e.Fd, false)
file := os.NewFile(uintptr(e.Fd), "pipe")
defer file.Close()
if data, ok := offerData[e.MimeType]; ok {
_, _ = file.Write(data)
}
_, _ = file.Write(data)
select {
case pasted <- struct{}{}:
default:
@@ -329,7 +298,7 @@ func serveOffers(offers []Offer, pasteOnce bool) error {
return fmt.Errorf("set selection: %w", err)
}
s.display.Roundtrip()
display.Roundtrip()
signalReady()
for {
@@ -341,26 +310,70 @@ func serveOffers(offers []Offer, pasteOnce bool) error {
return nil
}
default:
if err := s.ctx.Dispatch(); err != nil {
if err := ctx.Dispatch(); err != nil {
return nil
}
}
}
}
func CopyText(text string) error {
return Copy([]byte(text), "text/plain;charset=utf-8")
}
func Paste() ([]byte, string, error) {
s, err := connectSession()
display, err := wlclient.Connect("")
if err != nil {
return nil, "", err
return nil, "", fmt.Errorf("wayland connect: %w", err)
}
defer s.Close()
defer display.Destroy()
dataControlMgr, err := s.requireDataControl()
ctx := display.Context()
registry, err := display.GetRegistry()
if err != nil {
return nil, "", err
return nil, "", fmt.Errorf("get registry: %w", err)
}
defer registry.Destroy()
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
var seat *wlclient.Seat
var bindErr error
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
switch e.Interface {
case "ext_data_control_manager_v1":
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
bindErr = err
}
case "wl_seat":
if seat != nil {
return
}
seat = wlclient.NewSeat(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
bindErr = err
}
}
})
display.Roundtrip()
display.Roundtrip()
if bindErr != nil {
return nil, "", fmt.Errorf("registry bind: %w", bindErr)
}
device, err := dataControlMgr.GetDataDevice(s.seat)
if dataControlMgr == nil {
return nil, "", fmt.Errorf("compositor does not support ext_data_control_manager_v1")
}
defer dataControlMgr.Destroy()
if seat == nil {
return nil, "", fmt.Errorf("no seat available")
}
device, err := dataControlMgr.GetDataDevice(seat)
if err != nil {
return nil, "", fmt.Errorf("get data device: %w", err)
}
@@ -386,14 +399,15 @@ func Paste() ([]byte, string, error) {
gotSelection = true
})
s.display.Roundtrip()
s.display.Roundtrip()
display.Roundtrip()
display.Roundtrip()
if !gotSelection || selectionOffer == nil {
return nil, "", fmt.Errorf("no clipboard data")
}
selectedMime := selectPreferredMimeType(offerMimeTypes[selectionOffer])
mimeTypes := offerMimeTypes[selectionOffer]
selectedMime := selectPreferredMimeType(mimeTypes)
if selectedMime == "" {
return nil, "", fmt.Errorf("no supported mime type")
}
@@ -410,7 +424,7 @@ func Paste() ([]byte, string, error) {
}
w.Close()
s.display.Roundtrip()
display.Roundtrip()
data, err := io.ReadAll(r)
if err != nil {
@@ -456,3 +470,161 @@ func selectPreferredMimeType(mimes []string) string {
func IsImageMimeType(mime string) bool {
return len(mime) > 6 && mime[:6] == "image/"
}
type Offer struct {
MimeType string
Data []byte
}
func CopyMulti(offers []Offer, foreground, pasteOnce bool) error {
if !foreground {
return copyMultiFork(offers, pasteOnce)
}
return copyMultiServe(offers, pasteOnce)
}
func copyMultiFork(offers []Offer, pasteOnce bool) error {
args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"}
if pasteOnce {
args = append(args, "--paste-once")
}
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
for _, offer := range offers {
fmt.Fprintf(stdin, "%s\x00%d\x00", offer.MimeType, len(offer.Data))
if _, err := stdin.Write(offer.Data); err != nil {
stdin.Close()
return fmt.Errorf("write offer data: %w", err)
}
}
stdin.Close()
return nil
}
func copyMultiServe(offers []Offer, pasteOnce bool) error {
display, err := wlclient.Connect("")
if err != nil {
return fmt.Errorf("wayland connect: %w", err)
}
defer display.Destroy()
ctx := display.Context()
registry, err := display.GetRegistry()
if err != nil {
return fmt.Errorf("get registry: %w", err)
}
defer registry.Destroy()
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
var seat *wlclient.Seat
var bindErr error
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
switch e.Interface {
case "ext_data_control_manager_v1":
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
bindErr = err
}
case "wl_seat":
if seat != nil {
return
}
seat = wlclient.NewSeat(ctx)
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
bindErr = err
}
}
})
display.Roundtrip()
display.Roundtrip()
if bindErr != nil {
return fmt.Errorf("registry bind: %w", bindErr)
}
if dataControlMgr == nil {
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
}
defer dataControlMgr.Destroy()
if seat == nil {
return fmt.Errorf("no seat available")
}
device, err := dataControlMgr.GetDataDevice(seat)
if err != nil {
return fmt.Errorf("get data device: %w", err)
}
defer device.Destroy()
source, err := dataControlMgr.CreateDataSource()
if err != nil {
return fmt.Errorf("create data source: %w", err)
}
offerMap := make(map[string][]byte)
for _, offer := range offers {
if err := source.Offer(offer.MimeType); err != nil {
return fmt.Errorf("offer %s: %w", offer.MimeType, err)
}
offerMap[offer.MimeType] = offer.Data
}
cancelled := make(chan struct{})
pasted := make(chan struct{}, 1)
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
_ = syscall.SetNonblock(e.Fd, false)
file := os.NewFile(uintptr(e.Fd), "pipe")
defer file.Close()
if data, ok := offerMap[e.MimeType]; ok {
_, _ = file.Write(data)
}
select {
case pasted <- struct{}{}:
default:
}
})
source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
close(cancelled)
})
if err := device.SetSelection(source); err != nil {
return fmt.Errorf("set selection: %w", err)
}
display.Roundtrip()
for {
select {
case <-cancelled:
return nil
case <-pasted:
if pasteOnce {
return nil
}
default:
if err := ctx.Dispatch(); err != nil {
return nil
}
}
}
}
-206
View File
@@ -1,206 +0,0 @@
package clipboard
import (
"fmt"
"regexp"
"strconv"
"strings"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
"golang.org/x/sys/unix"
)
const (
xkbKeymapFormatV1 = 1
keyStateReleased = 0
keyStatePressed = 1
// xkb real modifier bit positions are fixed: Shift=0, Lock=1, Control=2
shiftModMask = 1 << 0
ctrlModMask = 1 << 2
// evdev fallbacks for a standard pc105 map
fallbackCtrlKey = 29 // KEY_LEFTCTRL
fallbackShiftKey = 42 // KEY_LEFTSHIFT
fallbackVKey = 47 // KEY_V
)
// SendPasteKeystroke emulates a paste shortcut via zwp_virtual_keyboard_v1
// using the seat's own keymap, so keycodes stay valid for XWayland clients
// (a synthetic wtype-style keymap breaks X11 apps like Steam). withShift
// selects ctrl+shift+v for terminal targets.
func SendPasteKeystroke(withShift bool) error {
s, err := connectSession()
if err != nil {
return err
}
defer s.Close()
if s.virtualKeyboardMgr == nil {
return fmt.Errorf("compositor does not support zwp_virtual_keyboard_manager_v1")
}
if s.seat == nil {
return fmt.Errorf("no seat available")
}
keyboard, err := s.seat.GetKeyboard()
if err != nil {
return fmt.Errorf("get keyboard: %w", err)
}
defer keyboard.Release()
var keymap *wlclient.KeyboardKeymapEvent
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
if keymap == nil {
keymap = &e
}
})
s.display.Roundtrip()
if keymap == nil || keymap.Format != xkbKeymapFormatV1 {
return fmt.Errorf("no xkb keymap from seat")
}
defer unix.Close(keymap.Fd)
keymapText, err := readKeymap(keymap.Fd, keymap.Size)
if err != nil {
return fmt.Errorf("read keymap: %w", err)
}
keys := resolveKeycodes(keymapText)
vk, err := s.virtualKeyboardMgr.CreateVirtualKeyboard(s.seat)
if err != nil {
return fmt.Errorf("create virtual keyboard: %w", err)
}
defer vk.Destroy()
if err := vk.Keymap(xkbKeymapFormatV1, keymap.Fd, keymap.Size); err != nil {
return fmt.Errorf("set keymap: %w", err)
}
mods := uint32(ctrlModMask)
held := []uint32{keys.ctrl}
if withShift {
mods |= shiftModMask
held = append(held, keys.shift)
}
t := uint32(0)
press := func(key, state uint32) error {
t++
return vk.Key(t, key, state)
}
for _, key := range held {
if err := press(key, keyStatePressed); err != nil {
return fmt.Errorf("key press: %w", err)
}
}
if err := vk.Modifiers(mods, 0, 0, 0); err != nil {
return fmt.Errorf("set modifiers: %w", err)
}
if err := press(keys.v, keyStatePressed); err != nil {
return fmt.Errorf("key press: %w", err)
}
if err := press(keys.v, keyStateReleased); err != nil {
return fmt.Errorf("key release: %w", err)
}
for i := len(held) - 1; i >= 0; i-- {
if err := press(held[i], keyStateReleased); err != nil {
return fmt.Errorf("key release: %w", err)
}
}
if err := vk.Modifiers(0, 0, 0, 0); err != nil {
return fmt.Errorf("clear modifiers: %w", err)
}
s.display.Roundtrip()
return nil
}
func readKeymap(fd int, size uint32) (string, error) {
data, err := unix.Mmap(fd, 0, int(size), unix.PROT_READ, unix.MAP_PRIVATE)
if err != nil {
return "", err
}
text := strings.TrimRight(string(data), "\x00")
return text, unix.Munmap(data)
}
type pasteKeycodes struct {
ctrl uint32
shift uint32
v uint32
}
var (
keycodeDefRe = regexp.MustCompile(`<([A-Za-z0-9+_-]+)>\s*=\s*(\d+)`)
keySymbolsRe = regexp.MustCompile(`key\s*<([A-Za-z0-9+_-]+)>\s*\{([^}]*)\}`)
groupIndexRe = regexp.MustCompile(`\w+\[\d+\]\s*=`)
symbolListRe = regexp.MustCompile(`\[([^\]]*)\]`)
)
// xkbcommon may serialize keysyms as hex escapes instead of names
// (e.g. "0x76" for v, "0xffe3" for Control_L).
var keysymNames = map[uint32]string{
0x76: "v",
0xffe3: "Control_L",
0xffe1: "Shift_L",
}
func canonicalKeysym(sym string) string {
if !strings.HasPrefix(sym, "0x") && !strings.HasPrefix(sym, "0X") {
return sym
}
value, err := strconv.ParseUint(sym[2:], 16, 32)
if err != nil {
return sym
}
if name, ok := keysymNames[uint32(value)]; ok {
return name
}
return sym
}
// resolveKeycodes finds the evdev keycodes producing the keysyms we need in
// the seat keymap's first group, falling back to pc105 positions.
func resolveKeycodes(keymap string) pasteKeycodes {
codes := map[string]uint32{}
for _, m := range keycodeDefRe.FindAllStringSubmatch(keymap, -1) {
if code, err := strconv.Atoi(m[2]); err == nil {
codes[m[1]] = uint32(code)
}
}
keys := pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}
want := map[string]*uint32{
"Control_L": &keys.ctrl,
"Shift_L": &keys.shift,
"v": &keys.v,
}
for _, m := range keySymbolsRe.FindAllStringSubmatch(keymap, -1) {
group := symbolListRe.FindStringSubmatch(groupIndexRe.ReplaceAllString(m[2], ""))
if group == nil {
continue
}
xkbCode, ok := codes[m[1]]
if !ok || xkbCode < 8 {
continue
}
level1 := canonicalKeysym(strings.TrimSpace(strings.Split(group[1], ",")[0]))
target, wanted := want[level1]
if !wanted {
continue
}
*target = xkbCode - 8
delete(want, level1)
if len(want) == 0 {
break
}
}
return keys
}
@@ -1,65 +0,0 @@
package clipboard
import (
"os"
"testing"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
"golang.org/x/sys/unix"
)
func TestLiveSeatKeymapResolution(t *testing.T) {
if os.Getenv("DMS_LIVE_TEST") == "" {
t.Skip("set DMS_LIVE_TEST=1 to run against the live compositor")
}
s, err := connectSession()
if err != nil {
t.Fatalf("connect: %v", err)
}
defer s.Close()
if s.virtualKeyboardMgr == nil {
t.Fatal("compositor does not advertise zwp_virtual_keyboard_manager_v1")
}
if s.seat == nil {
t.Fatal("no seat")
}
keyboard, err := s.seat.GetKeyboard()
if err != nil {
t.Fatalf("get keyboard: %v", err)
}
defer keyboard.Release()
var keymap *wlclient.KeyboardKeymapEvent
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
if keymap == nil {
keymap = &e
}
})
s.display.Roundtrip()
if keymap == nil {
t.Fatal("no keymap event")
}
defer unix.Close(keymap.Fd)
text, err := readKeymap(keymap.Fd, keymap.Size)
if err != nil {
t.Fatalf("read keymap: %v", err)
}
if dump := os.Getenv("DMS_LIVE_DUMP"); dump != "" {
if err := os.WriteFile(dump, []byte(text), 0o644); err != nil {
t.Fatalf("dump keymap: %v", err)
}
}
keys := resolveKeycodes(text)
t.Logf("keymap size=%d resolved ctrl=%d shift=%d v=%d", keymap.Size, keys.ctrl, keys.shift, keys.v)
if keys.ctrl == fallbackCtrlKey && keys.shift == fallbackShiftKey && keys.v == fallbackVKey {
t.Log("all keycodes are fallbacks - parsing may not have matched the live keymap")
}
}
-105
View File
@@ -1,105 +0,0 @@
package clipboard
import "testing"
const qwertyKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
minimum = 8;
maximum = 708;
<ESC> = 9;
<AB04> = 55;
<LCTL> = 37;
<LFSH> = 50;
alias <AL01> = <AC01>;
indicator 1 = "Caps Lock";
};
xkb_types "(unnamed)" {
type "ALPHABETIC" {
modifiers = Shift+Lock;
map[Shift] = Level2;
level_name[Level1] = "Base";
};
};
xkb_symbols "(unnamed)" {
key <ESC> { [ Escape ] };
key <AB04> { type= "ALPHABETIC", [ v, V ] };
key <LCTL> { [ Control_L ] };
key <LFSH> { [ Shift_L ] };
};
};`
const hexKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
<AB04> = 56;
<LCTL> = 38;
<LFSH> = 51;
};
xkb_symbols "(unnamed)" {
key <LCTL> { [ 0xffe3 ] };
key <LFSH> {
type= "PC_ALT_LEVEL2",
symbols[1]= [ 0xffe1, 0xfe08 ]
};
key <AB04> { [ 0x76, 0x56 ] };
};
};`
const dvorakKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
<AB09> = 60;
<LCTL> = 37;
<LFSH> = 50;
};
xkb_symbols "(unnamed)" {
key <AB09> { [ v, V ] };
key <LCTL> { [ Control_L ] };
key <LFSH> { [ Shift_L ] };
};
};`
func TestResolveKeycodes(t *testing.T) {
tests := []struct {
name string
keymap string
want pasteKeycodes
}{
{"qwerty", qwertyKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 47}},
{"dvorak", dvorakKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 52}},
{"hex keysyms", hexKeymap, pasteKeycodes{ctrl: 30, shift: 43, v: 48}},
{"empty falls back", "", pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveKeycodes(tt.keymap)
if got != tt.want {
t.Errorf("resolveKeycodes() = %+v, want %+v", got, tt.want)
}
})
}
}
func TestExpandOffers(t *testing.T) {
text := ExpandOffers([]byte("hi"), "text/plain;charset=utf-8")
if len(text) != 5 {
t.Fatalf("expected 5 text offers, got %d", len(text))
}
seen := map[string]bool{}
for _, o := range text {
if string(o.Data) != "hi" {
t.Errorf("offer %s has wrong data", o.MimeType)
}
if seen[o.MimeType] {
t.Errorf("duplicate offer %s", o.MimeType)
}
seen[o.MimeType] = true
}
if !seen["UTF8_STRING"] || !seen["STRING"] || !seen["TEXT"] || !seen["text/plain"] {
t.Errorf("missing X11 alias offers: %v", seen)
}
img := ExpandOffers([]byte{1}, "image/png")
if len(img) != 1 || img[0].MimeType != "image/png" {
t.Errorf("non-text mime should not expand, got %+v", img)
}
}
-93
View File
@@ -1,93 +0,0 @@
package clipboard
import (
"fmt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/virtual_keyboard"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
type session struct {
display *wlclient.Display
ctx *wlclient.Context
registry *wlclient.Registry
seat *wlclient.Seat
dataControlMgr *ext_data_control.ExtDataControlManagerV1
virtualKeyboardMgr *virtual_keyboard.ZwpVirtualKeyboardManagerV1
}
// connectSession opens a short-lived Wayland connection and binds the seat
// plus whichever clipboard-related globals the compositor advertises.
func connectSession() (*session, error) {
display, err := wlclient.Connect("")
if err != nil {
return nil, fmt.Errorf("wayland connect: %w", err)
}
s := &session{display: display, ctx: display.Context()}
registry, err := display.GetRegistry()
if err != nil {
display.Destroy()
return nil, fmt.Errorf("get registry: %w", err)
}
s.registry = registry
var bindErr error
bind := func(name uint32, iface string, version uint32, proxy wlclient.Proxy) {
if err := registry.Bind(name, iface, version, proxy); err != nil {
bindErr = fmt.Errorf("bind %s: %w", iface, err)
}
}
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
switch e.Interface {
case ext_data_control.ExtDataControlManagerV1InterfaceName:
mgr := ext_data_control.NewExtDataControlManagerV1(s.ctx)
bind(e.Name, e.Interface, e.Version, mgr)
s.dataControlMgr = mgr
case virtual_keyboard.ZwpVirtualKeyboardManagerV1InterfaceName:
mgr := virtual_keyboard.NewZwpVirtualKeyboardManagerV1(s.ctx)
bind(e.Name, e.Interface, e.Version, mgr)
s.virtualKeyboardMgr = mgr
case "wl_seat":
if s.seat != nil {
return
}
seat := wlclient.NewSeat(s.ctx)
bind(e.Name, e.Interface, e.Version, seat)
s.seat = seat
}
})
display.Roundtrip()
display.Roundtrip()
if bindErr != nil {
s.Close()
return nil, bindErr
}
return s, nil
}
func (s *session) requireDataControl() (*ext_data_control.ExtDataControlManagerV1, error) {
switch {
case s.dataControlMgr == nil:
return nil, fmt.Errorf("compositor does not support ext_data_control_manager_v1")
case s.seat == nil:
return nil, fmt.Errorf("no seat available")
default:
return s.dataControlMgr, nil
}
}
func (s *session) Close() {
if s.dataControlMgr != nil {
s.dataControlMgr.Destroy()
}
if s.registry != nil {
s.registry.Destroy()
}
s.display.Destroy()
}
-110
View File
@@ -61,10 +61,6 @@ func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstalls(ctx contex
return cd.deployConfigurationsInternal(ctx, wm, terminal, installedDeps, replaceConfigs, reinstallItems, true)
}
func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstallsAndSystemd(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) {
return cd.deployConfigurationsInternal(ctx, wm, terminal, installedDeps, replaceConfigs, reinstallItems, useSystemd)
}
func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) {
var results []DeploymentResult
@@ -77,10 +73,6 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"),
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
},
"Mango": {
filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf"),
filepath.Join(os.Getenv("HOME"), ".config", "mango", "mango.conf"),
},
"Ghostty": {
filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"),
},
@@ -134,14 +126,6 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d
return results, fmt.Errorf("failed to deploy Hyprland config: %w", err)
}
}
case deps.WindowManagerMango:
if shouldReplaceConfig("Mango") {
result, err := cd.deployMangoConfig(terminal, useSystemd)
results = append(results, result)
if err != nil {
return results, fmt.Errorf("failed to deploy Mango config: %w", err)
}
}
}
switch terminal {
@@ -285,96 +269,6 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
return nil
}
func (cd *ConfigDeployer) deployMangoConfig(terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) {
result := DeploymentResult{
ConfigType: "Mango",
Path: filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf"),
}
configDir := filepath.Dir(result.Path)
if err := os.MkdirAll(configDir, 0o755); err != nil {
result.Error = fmt.Errorf("failed to create config directory: %w", err)
return result, result.Error
}
dmsDir := filepath.Join(configDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
result.Error = fmt.Errorf("failed to create dms directory: %w", err)
return result, result.Error
}
var terminalCommand string
switch terminal {
case deps.TerminalGhostty:
terminalCommand = "ghostty"
case deps.TerminalKitty:
terminalCommand = "kitty"
case deps.TerminalAlacritty:
terminalCommand = "alacritty"
default:
terminalCommand = "ghostty"
}
// DMS owns config.conf for mango (like niri/hyprland): back up and replace.
if existingData, err := os.ReadFile(result.Path); err == nil {
cd.log("Found existing Mango configuration")
timestamp := time.Now().Format("2006-01-02_15-04-05")
result.BackupPath = result.Path + ".backup." + timestamp
if err := os.WriteFile(result.BackupPath, existingData, 0o644); err != nil {
result.Error = fmt.Errorf("failed to create backup: %w", err)
return result, result.Error
}
cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath))
}
newConfig := strings.ReplaceAll(MangoConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil {
result.Error = fmt.Errorf("failed to write config: %w", err)
return result, result.Error
}
if err := cd.deployMangoDmsConfigs(dmsDir, terminalCommand); err != nil {
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
return result, result.Error
}
result.Deployed = true
cd.log("Successfully deployed Mango configuration")
return result, nil
}
func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string) error {
configs := []struct {
name string
content string
overwrite bool
}{
// binds.conf is DMS-owned (overwrite); the rest are runtime/user-managed.
{"binds.conf", strings.ReplaceAll(MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand), true},
{"colors.conf", MangoColorsConfig, false},
{"layout.conf", MangoLayoutConfig, false},
{"outputs.conf", "", false},
{"cursor.conf", "", false},
{"windowrules.conf", "", false},
}
for _, cfg := range configs {
path := filepath.Join(dmsDir, cfg.name)
if !cfg.overwrite {
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
continue
}
}
if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
}
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
}
return nil
}
func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
var results []DeploymentResult
@@ -706,10 +600,6 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, result.Error
}
CleanupStrayHyprlandConfFile(func(format string, v ...any) {
cd.log(fmt.Sprintf(format, v...))
})
result.Deployed = true
cd.log("Successfully deployed Hyprland configuration")
return result, nil
+3 -27
View File
@@ -20,17 +20,13 @@ func TestCleanupStrayHyprlandConfFile(t *testing.T) {
td := t.TempDir()
t.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr")
dmsDir := filepath.Join(configDir, "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
require.NoError(t, os.MkdirAll(configDir, 0o755))
confPath := filepath.Join(configDir, "hyprland.conf")
dmsConfPath := filepath.Join(dmsDir, "colors.conf")
require.NoError(t, os.WriteFile(confPath, []byte("# legacy user config\n"), 0o644))
require.NoError(t, os.WriteFile(dmsConfPath, []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
CleanupStrayHyprlandConfFile(nil)
assert.FileExists(t, confPath, "must not touch hyprland.conf when user has not migrated")
assert.FileExists(t, dmsConfPath, "must not touch dms/*.conf when user has not migrated")
assert.NoDirExists(t, filepath.Join(configDir, hyprlandBackupDirName))
})
@@ -38,25 +34,20 @@ func TestCleanupStrayHyprlandConfFile(t *testing.T) {
td := t.TempDir()
t.Setenv("HOME", td)
configDir := filepath.Join(td, ".config", "hypr")
dmsDir := filepath.Join(configDir, "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
require.NoError(t, os.MkdirAll(configDir, 0o755))
luaPath := filepath.Join(configDir, "hyprland.lua")
require.NoError(t, os.WriteFile(luaPath, []byte("-- dms managed\n"), 0o644))
confPath := filepath.Join(configDir, "hyprland.conf")
dmsConfPath := filepath.Join(dmsDir, "colors.conf")
require.NoError(t, os.WriteFile(confPath, []byte("# autogen\n"), 0o644))
require.NoError(t, os.WriteFile(dmsConfPath, []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
CleanupStrayHyprlandConfFile(nil)
assert.NoFileExists(t, confPath)
assert.NoFileExists(t, dmsConfPath)
assert.FileExists(t, luaPath)
entries, err := os.ReadDir(filepath.Join(configDir, hyprlandBackupDirName))
require.NoError(t, err)
require.Len(t, entries, 1)
assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "hyprland.conf"))
assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "dms", "colors.conf"))
})
}
@@ -413,7 +404,6 @@ general {
dmsDir := filepath.Join(td, ".config", "hypr", "dms")
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf"), []byte("bind = SUPER, T, exec, foot\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "colors.conf"), []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "cursor.conf"), []byte("env = XCURSOR_SIZE,24\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old"), []byte("old backup\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf.backup.old"), []byte("old dms backup\n"), 0o644))
@@ -433,12 +423,10 @@ general {
assert.Contains(t, result.BackupPath, hyprlandBackupDirName)
assert.NoFileExists(t, hyprPath)
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "colors.conf"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "cursor.conf"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf.backup.old"))
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf.backup.old"))
assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf"))
assert.NoFileExists(t, filepath.Join(dmsDir, "colors.conf"))
assert.NoFileExists(t, filepath.Join(dmsDir, "cursor.conf"))
assert.NoFileExists(t, filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old"))
assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf.backup.old"))
@@ -497,7 +485,7 @@ general {
managed, err := os.ReadFile(filepath.Join(dmsDir, "binds.lua"))
require.NoError(t, err)
assert.Contains(t, string(managed), `hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" }))`)
assert.Contains(t, string(managed), `hl.bind("SUPER + minus", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { repeating = true })`)
assert.Contains(t, string(managed), `hl.bind("SUPER + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive -10% 0]]), { repeating = true })`)
user, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua"))
require.NoError(t, err)
@@ -520,18 +508,6 @@ func TestHyprlandConfigStructure(t *testing.T) {
assert.Contains(t, HyprlandLuaConfig, "input =")
}
func TestMangoConfigStructure(t *testing.T) {
assert.Contains(t, MangoConfig, "exec-once=dms run")
assert.NotContains(t, MangoConfig, "exec_once=dms run")
assert.Contains(t, MangoConfig, "source=./dms/binds.conf")
assert.Contains(t, MangoBindsConfig, "bind=SUPER,H,focusdir,left")
assert.Contains(t, MangoBindsConfig, "bind=SUPER,J,focusdir,down")
assert.Contains(t, MangoBindsConfig, "bind=SUPER,K,focusdir,up")
assert.Contains(t, MangoBindsConfig, "bind=SUPER,L,focusdir,right")
assert.Contains(t, MangoBindsConfig, "gesturebind=none,right,3,viewtoleft_have_client")
assert.Contains(t, MangoBindsConfig, "gesturebind=none,left,3,viewtoright_have_client")
}
func TestGhosttyConfigStructure(t *testing.T) {
assert.Contains(t, GhosttyConfig, "window-decoration = false")
assert.Contains(t, GhosttyConfig, "background-opacity = 1.0")
+2 -2
View File
@@ -20,8 +20,8 @@ mouse-hide-while-typing = true
copy-on-select = false
confirm-close-surface = false
# Disable in-app Ghostty toast notifications
app-notifications = false
# Disable annoying copied to clipboard
app-notifications = no-clipboard-copy,no-config-reload
# Key bindings for common actions
#keybind = ctrl+c=copy_to_clipboard
+7 -11
View File
@@ -9,9 +9,8 @@ hl.bind("SUPER + M", hl.dsp.exec_cmd("dms ipc call processlist focusOrToggle"))
hl.bind("SUPER + comma", hl.dsp.exec_cmd("dms ipc call settings focusOrToggle"))
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notifications toggle"))
hl.bind("SUPER + SHIFT + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
hl.bind("SUPER + Y", hl.dsp.exec_cmd("dms ipc call dash toggle wallpaper"))
hl.bind("SUPER + Y", hl.dsp.exec_cmd("dms ipc call dankdash wallpaper"))
hl.bind("SUPER + TAB", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
hl.bind("SUPER + O", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
hl.bind("SUPER + X", hl.dsp.exec_cmd("dms ipc call powermenu toggle"))
-- === Cheat sheet
@@ -39,7 +38,7 @@ hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd([[dms ipc call brightness increme
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd([[dms ipc call brightness decrement 5 ""]]), { locked = true, repeating = true })
-- === Window Management ===
hl.bind("SUPER + Q", hl.dsp.window.close())
hl.bind("SUPER + Q", hl.dsp.window.kill())
hl.bind("SUPER + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" }))
hl.bind("SUPER + SHIFT + F", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" }))
hl.bind("SUPER + SHIFT + T", hl.dsp.window.float({ action = "toggle" }))
@@ -113,9 +112,6 @@ hl.bind("SUPER + mouse_up", hl.dsp.focus({ workspace = "e-1" }))
hl.bind("SUPER + CTRL + mouse_down", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + CTRL + mouse_up", hl.dsp.window.move({ workspace = "e-1" }))
-- === Touchpad Gestures ===
hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })
-- === Numbered Workspaces ===
hl.bind("SUPER + 1", hl.dsp.focus({ workspace = "1" }))
hl.bind("SUPER + 2", hl.dsp.focus({ workspace = "2" }))
@@ -144,7 +140,7 @@ hl.bind("SUPER + bracketright", hl.dsp.layout("preselect r"))
-- === Sizing & Layout ===
hl.bind("SUPER + R", hl.dsp.layout("togglesplit"))
hl.bind("SUPER + CTRL + F", hl.dsp.window.fullscreen({ mode = "maximized", action = "set" }))
hl.bind("SUPER + CTRL + F", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive exact 100% 100%]]))
-- === Move/resize windows with mainMod + LMB/RMB and dragging ===
hl.bind("SUPER + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window" })
@@ -154,10 +150,10 @@ hl.bind("SUPER + code:20", hl.dsp.window.resize({ x = -100, y = 0, relative = tr
hl.bind("SUPER + code:21", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { description = "Shrink window left" })
-- === Manual Sizing ===
hl.bind("SUPER + minus", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { repeating = true })
hl.bind("SUPER + equal", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { repeating = true })
hl.bind("SUPER + SHIFT + minus", hl.dsp.window.resize({ x = 0, y = -100, relative = true }), { repeating = true })
hl.bind("SUPER + SHIFT + equal", hl.dsp.window.resize({ x = 0, y = 100, relative = true }), { repeating = true })
hl.bind("SUPER + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive -10% 0]]), { repeating = true })
hl.bind("SUPER + equal", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 10% 0]]), { repeating = true })
hl.bind("SUPER + SHIFT + minus", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 0 -10%]]), { repeating = true })
hl.bind("SUPER + SHIFT + equal", hl.dsp.exec_cmd([[hyprctl dispatch resizeactive 0 10%]]), { repeating = true })
-- === Screenshots ===
hl.bind("Print", hl.dsp.exec_cmd("dms screenshot"))
+1 -7
View File
@@ -11,14 +11,8 @@ end)
hl.config({
input = {
-- empty inherits XKB_DEFAULT_LAYOUT (libxkbcommon), falls back to "us"
kb_layout = "",
kb_layout = "us",
numlock_by_default = true,
follow_mouse = 0,
touchpad = {
tap_to_click = true,
natural_scroll = true,
},
},
general = {
gaps_in = 5,
@@ -1,140 +0,0 @@
# DMS default keybinds (MangoWM) — managed by DMS, regenerated by `dms setup`.
# Format: bind=MODS,key,action[,args]
# Put bind descriptions above bind lines; inline # comments break Mango spawn args.
# === Application Launchers ===
# Open Terminal
bind=SUPER,t,spawn,{{TERMINAL_COMMAND}}
# Open Terminal
bind=SUPER,Return,spawn,{{TERMINAL_COMMAND}}
# Application Launcher
bind=SUPER,space,spawn,dms ipc call spotlight toggle
# Spotlight Bar
bind=ALT,space,spawn,dms ipc call spotlight-bar toggle
# Clipboard Manager
bind=SUPER,v,spawn,dms ipc call clipboard toggle
# Task Manager
bind=SUPER,m,spawn,dms ipc call processlist focusOrToggle
# Settings
bind=SUPER,comma,spawn,dms ipc call settings focusOrToggle
# Notification Center
bind=SUPER,n,spawn,dms ipc call notifications toggle
# Notepad
bind=SUPER+SHIFT,n,spawn,dms ipc call notepad toggle
# Browse Wallpapers
bind=SUPER,y,spawn,dms ipc call dash toggle wallpaper
# Power Menu
bind=SUPER,x,spawn,dms ipc call powermenu toggle
# Cycle Display Profile
bind=SUPER,p,spawn,dms ipc outputs cycleProfile
# === Cheat sheet ===
# Keyboard Shortcuts
bind=SUPER+SHIFT,slash,spawn,dms ipc call keybinds toggle mangowc
# === Security ===
# Lock Screen
bind=SUPER+ALT,l,spawn,dms ipc call lock lock
# Task Manager
bind=CTRL+ALT,Delete,spawn,dms ipc call processlist focusOrToggle
# === Window Rules ===
# Create Window Rule
bind=SUPER+SHIFT,w,spawn,dms ipc call window-rules toggle
# === Screenshots ===
# Screenshot: Interactive
bind=none,Print,spawn,dms screenshot
# Screenshot: Full Screen
bind=CTRL,Print,spawn,dms screenshot full
# Screenshot: Window
bind=ALT,Print,spawn,dms screenshot window
# === Audio Controls ===
bind=none,XF86AudioRaiseVolume,spawn,dms ipc call audio increment 3
bind=none,XF86AudioLowerVolume,spawn,dms ipc call audio decrement 3
bind=none,XF86AudioMute,spawn,dms ipc call audio mute
bind=none,XF86AudioMicMute,spawn,dms ipc call audio micmute
bind=none,XF86AudioPlay,spawn,dms ipc call mpris playPause
bind=none,XF86AudioPause,spawn,dms ipc call mpris playPause
bind=none,XF86AudioPrev,spawn,dms ipc call mpris previous
bind=none,XF86AudioNext,spawn,dms ipc call mpris next
# === Brightness Controls ===
bind=none,XF86MonBrightnessUp,spawn,dms ipc call brightness increment 5
bind=none,XF86MonBrightnessDown,spawn,dms ipc call brightness decrement 5
# === Window Management ===
# Close Window
bind=SUPER,q,killclient,
bind=SUPER,f,togglefullscreen,
bind=SUPER,a,togglemaximizescreen,
bind=SUPER+SHIFT,space,togglefloating,
bind=SUPER,o,toggleoverview
bind=ALT,Tab,toggleoverview
# Exit Compositor
bind=SUPER+SHIFT,e,quit,
# === Focus Navigation ===
bind=SUPER,Tab,focusstack,next
bind=SUPER+SHIFT,Tab,focusstack,prev
bind=SUPER,Left,focusdir,left
bind=SUPER,H,focusdir,left
bind=SUPER,Right,focusdir,right
bind=SUPER,L,focusdir,right
bind=SUPER,Up,focusdir,up
bind=SUPER,K,focusdir,up
bind=SUPER,Down,focusdir,down
bind=SUPER,J,focusdir,down
# === Window Movement ===
bind=SUPER+SHIFT,Left,exchange_client,left
bind=SUPER+SHIFT,Right,exchange_client,right
bind=SUPER+SHIFT,Up,exchange_client,up
bind=SUPER+SHIFT,Down,exchange_client,down
bind=SUPER+SHIFT,H,exchange_client,left
bind=SUPER+SHIFT,L,exchange_client,right
bind=SUPER+SHIFT,K,exchange_client,up
bind=SUPER+SHIFT,J,exchange_client,down
# === Monitor Navigation ===
bind=SUPER+ALT,Left,focusmon,left
bind=SUPER+ALT,Right,focusmon,right
bind=SUPER+ALT+SHIFT,Left,tagmon,left
bind=SUPER+ALT+SHIFT,Right,tagmon,right
# === Layout ===
# Cycle Layout - Gaps, Floating, Tiling
bind=SUPER+ALT,j,switch_layout
bind=SUPER+SHIFT,equal,incgaps,1
bind=SUPER+SHIFT,minus,incgaps,-1
# === Tags (1-9): view tag ===
bind=SUPER,1,view,1
bind=SUPER,2,view,2
bind=SUPER,3,view,3
bind=SUPER,4,view,4
bind=SUPER,5,view,5
bind=SUPER,6,view,6
bind=SUPER,7,view,7
bind=SUPER,8,view,8
bind=SUPER,9,view,9
# === Tags (1-9): move focused window to tag ===
bind=SUPER+SHIFT,1,tag,1
bind=SUPER+SHIFT,2,tag,2
bind=SUPER+SHIFT,3,tag,3
bind=SUPER+SHIFT,4,tag,4
bind=SUPER+SHIFT,5,tag,5
bind=SUPER+SHIFT,6,tag,6
bind=SUPER+SHIFT,7,tag,7
bind=SUPER+SHIFT,8,tag,8
bind=SUPER+SHIFT,9,tag,9
# === Touchpad Gestures ===
# 3-finger horizontal swipe: switch between occupied workspaces
gesturebind=none,right,3,viewtoleft_have_client
gesturebind=none,left,3,viewtoright_have_client
# 4-finger vertical swipe: toggle the overview
gesturebind=none,up,4,toggleoverview
gesturebind=none,down,4,toggleoverview
@@ -1,6 +0,0 @@
# Auto-generated by DMS. Overwritten by matugen (dms/colors.conf).
# Remove `source=./dms/colors.conf` from config.conf to override manually.
bordercolor = 0x595959ff
focuscolor = 0x8ab4f8ff
urgentcolor = 0xff5555ff
@@ -1,8 +0,0 @@
# Auto-generated by DMS. Regenerated from DMS settings (dms/layout.conf).
border_radius=12
gappih=5
gappiv=5
gappoh=5
gappov=5
borderpx=2
-18
View File
@@ -1,18 +0,0 @@
# DankMaterialShell — MangoWM configuration (managed by `dms setup`)
# Keybinds, colors, layout, outputs, cursor and window rules are pulled from the
# ./dms fragments below. Add your own binds/rules here; they sit alongside DMS's.
env=XDG_CURRENT_DESKTOP,mango
env=XDG_SESSION_TYPE,wayland
# exec-once runs only at startup. Do NOT use exec= for the shell: mango re-runs
# every exec= on each config reload, and DMS reloads the config, which would
# spawn a new shell on every reload.
exec-once=dms run
source=./dms/colors.conf
source=./dms/layout.conf
source=./dms/cursor.conf
source=./dms/outputs.conf
source=./dms/windowrules.conf
source=./dms/binds.conf
+20 -20
View File
@@ -1,6 +1,6 @@
binds {
// === System & Overview ===
Mod+O repeat=false { toggle-overview; }
Mod+D repeat=false { toggle-overview; }
Mod+Tab repeat=false { toggle-overview; }
Mod+Shift+Slash { show-hotkey-overlay; }
@@ -24,7 +24,7 @@ binds {
spawn "dms" "ipc" "call" "settings" "focusOrToggle";
}
Mod+Y hotkey-overlay-title="Browse Wallpapers" {
spawn "dms" "ipc" "call" "dash" "toggle" "wallpaper";
spawn "dms" "ipc" "call" "dankdash" "wallpaper";
}
Mod+N hotkey-overlay-title="Notification Center" { spawn "dms" "ipc" "call" "notifications" "toggle"; }
Mod+Shift+N hotkey-overlay-title="Notepad" { spawn "dms" "ipc" "call" "notepad" "toggle"; }
@@ -51,24 +51,24 @@ binds {
XF86AudioMicMute allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "micmute";
}
XF86AudioPause allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "playPause";
}
XF86AudioPlay allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "playPause";
}
XF86AudioPrev allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "previous";
}
XF86AudioNext allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "next";
}
Ctrl+XF86AudioRaiseVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "increment" "3";
}
Ctrl+XF86AudioLowerVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "decrement" "3";
}
XF86AudioPause allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "playPause";
}
XF86AudioPlay allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "playPause";
}
XF86AudioPrev allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "previous";
}
XF86AudioNext allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "next";
}
Ctrl+XF86AudioRaiseVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "increment" "3";
}
Ctrl+XF86AudioLowerVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "decrement" "3";
}
// === Brightness Controls ===
XF86MonBrightnessUp allow-when-locked=true {
@@ -0,0 +1,17 @@
hotkey-overlay {
skip-at-startup
}
environment {
DMS_RUN_GREETER "1"
}
gestures {
hot-corners {
off
}
}
layout {
background-color "#000000"
}
@@ -20,10 +20,3 @@ window-rule {
tiled-state true
draw-border-with-background false
}
layer-rule {
exclude namespace="^dms:bar$"
background-effect {
xray false
}
}
+6 -6
View File
@@ -265,9 +265,9 @@ recent-windows {
}
// Include dms files
include optional=true "dms/colors.kdl"
include optional=true "dms/layout.kdl"
include optional=true "dms/alttab.kdl"
include optional=true "dms/binds.kdl"
include optional=true "dms/outputs.kdl"
include optional=true "dms/cursor.kdl"
include "dms/colors.kdl"
include "dms/layout.kdl"
include "dms/alttab.kdl"
include "dms/binds.kdl"
include "dms/outputs.kdl"
include "dms/cursor.kdl"
+12 -35
View File
@@ -138,9 +138,11 @@ func readExistingHyprlandConfig(configDir string) (data string, sourcePath strin
return "", "", nil
}
// CleanupStrayHyprlandConfFile moves stray ~/.config/hypr/hyprland.conf and
// top-level ~/.config/hypr/dms/*.conf files into .dms-backups/<timestamp>/ only
// when hyprland.lua also exists as the live config.
// CleanupStrayHyprlandConfFile moves a stray ~/.config/hypr/hyprland.conf
// into .dms-backups/<timestamp>/ only when hyprland.lua also exists, which
// proves Lua is the live config and the .conf is an autogen Hyprland 0.55
// produced when launched without -c. If only hyprland.conf exists, the user
// has not migrated and we must leave their config alone.
func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
return
@@ -154,44 +156,19 @@ func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
if _, err := os.Stat(luaPath); err != nil {
return
}
var strayPaths []string
confPath := filepath.Join(configDir, "hyprland.conf")
if info, err := os.Lstat(confPath); err == nil && !info.IsDir() {
strayPaths = append(strayPaths, confPath)
}
dmsConfPaths, err := filepath.Glob(filepath.Join(configDir, "dms", "*.conf"))
if err == nil {
for _, p := range dmsConfPaths {
if info, err := os.Lstat(p); err == nil && !info.IsDir() {
strayPaths = append(strayPaths, p)
}
}
}
if len(strayPaths) == 0 {
if _, err := os.Stat(confPath); err != nil {
return
}
ts := time.Now().Format("2006-01-02_15-04-05")
moved := 0
for _, src := range strayPaths {
rel, err := filepath.Rel(configDir, src)
if err != nil {
rel = filepath.Base(src)
}
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, rel)
if err := moveHyprlandConfigFile(src, dst); err != nil {
if logFn != nil {
logFn("Could not move stray Hyprland conf file %s: %v", src, err)
}
continue
}
moved++
dst := filepath.Join(configDir, hyprlandBackupDirName, ts, "hyprland.conf")
if err := moveHyprlandConfigFile(confPath, dst); err != nil {
if logFn != nil {
logFn("Moved stray Hyprland conf file to %s", dst)
logFn("Could not move stray hyprland.conf: %v", err)
}
return
}
if moved > 0 && logFn != nil {
logFn("Moved %d stray Hyprland conf file(s) out of the active Lua config tree", moved)
if logFn != nil {
logFn("Moved stray hyprland.conf to %s", dst)
}
}
-15
View File
@@ -1,15 +0,0 @@
package config
import _ "embed"
//go:embed embedded/mango.conf
var MangoConfig string
//go:embed embedded/mango-colors.conf
var MangoColorsConfig string
//go:embed embedded/mango-layout.conf
var MangoLayoutConfig string
//go:embed embedded/mango-binds.conf
var MangoBindsConfig string
+3
View File
@@ -16,3 +16,6 @@ var NiriAlttabConfig string
//go:embed embedded/niri-binds.kdl
var NiriBindsConfig string
//go:embed embedded/niri-greeter.kdl
var NiriGreeterConfig string
-1
View File
@@ -35,7 +35,6 @@ type WindowManager int
const (
WindowManagerHyprland WindowManager = iota
WindowManagerNiri
WindowManagerMango
)
type Terminal int
+1 -18
View File
@@ -133,11 +133,6 @@ func mergedAssociations() *MimeAssociations {
return merged
}
// isSafeIniField rejects values that would corrupt a key=value line in mimeapps.list
func isSafeIniField(s string) bool {
return !strings.ContainsAny(s, "\n\r[]")
}
func writeUserMimeapps(update func(*MimeAssociations)) error {
mimeappsWriteMu.Lock()
defer mimeappsWriteMu.Unlock()
@@ -157,7 +152,6 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
var buf bytes.Buffer
w := bufio.NewWriter(&buf)
var writeErr error
writeSection := func(name string, entries map[string]string) {
fmt.Fprintf(w, "[%s]\n", name)
keys := make([]string, 0, len(entries))
@@ -166,14 +160,7 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
}
sort.Strings(keys)
for _, k := range keys {
v := entries[k]
if !isSafeIniField(k) || !isSafeIniField(v) {
if writeErr == nil {
writeErr = fmt.Errorf("invalid mimeapps.list field %q=%q", k, v)
}
continue
}
fmt.Fprintf(w, "%s=%s\n", k, v)
fmt.Fprintf(w, "%s=%s\n", k, entries[k])
}
fmt.Fprintln(w)
}
@@ -190,10 +177,6 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
writeSection(groupAdded, flatten(assoc.Added))
writeSection(groupRemoved, flatten(assoc.Removed))
if writeErr != nil {
return writeErr
}
if err := w.Flush(); err != nil {
return err
}
+3 -72
View File
@@ -112,37 +112,12 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
dependencies = append(dependencies, a.detectXwaylandSatellite())
}
// Mango-specific tools (dwl-based, uses xwayland-satellite like niri)
if wm == deps.WindowManagerMango {
dependencies = append(dependencies, a.detectXwaylandSatellite())
}
dependencies = append(dependencies, a.detectMatugen())
dependencies = append(dependencies, a.detectDgop())
dependencies = append(dependencies, a.detectDanksearch())
dependencies = append(dependencies, a.detectDankCalendar())
return dependencies, nil
}
func (a *ArchDistribution) detectDanksearch() deps.Dependency {
dep := a.BaseDistribution.detectDanksearch()
dep.CanToggle = true
if a.packageInstalled("dsearch-git") {
dep.Variant = deps.VariantGit
}
return dep
}
func (a *ArchDistribution) detectDankCalendar() deps.Dependency {
dep := a.BaseDistribution.detectDankCalendar()
dep.CanToggle = true
if a.packageInstalled("dankcalendar-git") {
dep.Variant = deps.VariantGit
}
return dep
}
func (a *ArchDistribution) detectXDGPortal() deps.Dependency {
return a.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", a.packageInstalled("xdg-desktop-portal-gtk"))
}
@@ -152,13 +127,7 @@ func (a *ArchDistribution) detectAccountsService() deps.Dependency {
}
func (a *ArchDistribution) detectDMSGreeter() deps.Dependency {
installed := a.packageInstalled("greetd-dms-greeter-git") || a.packageInstalled("greetd-dms-greeter-bin")
dep := a.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", installed)
dep.CanToggle = true
if a.packageInstalled("greetd-dms-greeter-git") {
dep.Variant = deps.VariantGit
}
return dep
return a.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", a.packageInstalled("greetd-dms-greeter-git"))
}
func (a *ArchDistribution) packageInstalled(pkg string) bool {
@@ -203,11 +172,6 @@ func (a *ArchDistribution) isInSystemRepo(pkg string) bool {
return exec.Command("pacman", "-Si", pkg).Run() == nil
}
// isSonameProvides reports whether dep is a shared-library soname
func isSonameProvides(dep string) bool {
return strings.HasSuffix(dep, ".so") || strings.Contains(dep, ".so.")
}
func (a *ArchDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return a.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
}
@@ -217,7 +181,7 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
"dms (DankMaterialShell)": a.getDMSMapping(variants["dms (DankMaterialShell)"]),
"git": {Name: "git", Repository: RepoTypeSystem},
"quickshell": a.getQuickshellMapping(variants["quickshell"]),
"dms-greeter": a.getDMSGreeterMapping(variants["dms-greeter"]),
"dms-greeter": {Name: "greetd-dms-greeter-git", Repository: RepoTypeAUR},
"matugen": a.getMatugenMapping(variants["matugen"]),
"dgop": {Name: "dgop", Repository: RepoTypeSystem},
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
@@ -225,8 +189,6 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"danksearch": a.getDanksearchMapping(variants["danksearch"]),
"dankcalendar": a.getDankCalendarMapping(variants["dankcalendar"]),
}
switch wm {
@@ -237,9 +199,6 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
case deps.WindowManagerNiri:
packages["niri"] = a.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
case deps.WindowManagerMango:
packages["mango"] = a.getMangoMapping(variants["mango"])
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
}
return packages
@@ -263,13 +222,6 @@ func (a *ArchDistribution) getNiriMapping(variant deps.PackageVariant) PackageMa
return PackageMapping{Name: "niri", Repository: RepoTypeSystem}
}
func (a *ArchDistribution) getMangoMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "mangowm-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "mangowm", Repository: RepoTypeAUR}
}
func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) PackageMapping {
if runtime.GOARCH == "arm64" {
return PackageMapping{Name: "matugen-git", Repository: RepoTypeAUR}
@@ -281,27 +233,6 @@ func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) Packag
return PackageMapping{Name: "matugen", Repository: RepoTypeSystem}
}
func (a *ArchDistribution) getDanksearchMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "dsearch-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "dsearch-bin", Repository: RepoTypeAUR}
}
func (a *ArchDistribution) getDankCalendarMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "dankcalendar-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "dankcalendar-bin", Repository: RepoTypeAUR}
}
func (a *ArchDistribution) getDMSGreeterMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "greetd-dms-greeter-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "greetd-dms-greeter-bin", Repository: RepoTypeAUR}
}
func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMapping {
if forceDMSGit || variant == deps.VariantGit {
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
@@ -793,7 +724,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
continue
}
seen[dep] = true
if isSonameProvides(dep) || a.isInSystemRepo(dep) {
if a.isInSystemRepo(dep) {
systemPkgs = append(systemPkgs, dep)
} else {
aurPkgs = append(aurPkgs, dep)
-38
View File
@@ -107,14 +107,6 @@ func (b *BaseDistribution) detectDgop() deps.Dependency {
return b.detectCommand("dgop", "Desktop portal management tool")
}
func (b *BaseDistribution) detectDanksearch() deps.Dependency {
return b.detectOptionalPackage("danksearch", "File indexing and search service", b.commandExists("dsearch") || b.commandExists("danksearch"))
}
func (b *BaseDistribution) detectDankCalendar() deps.Dependency {
return b.detectOptionalPackage("dankcalendar", "Calendar application", b.commandExists("dcal") || b.commandExists("dankcalendar"))
}
func (b *BaseDistribution) detectDMS() deps.Dependency {
dmsPath := filepath.Join(os.Getenv("HOME"), ".config/quickshell/dms")
@@ -345,36 +337,6 @@ func (b *BaseDistribution) detectWindowManager(wm deps.WindowManager) deps.Depen
Variant: variant,
CanToggle: true,
}
case deps.WindowManagerMango:
status := deps.StatusMissing
variant := deps.VariantStable
version := ""
if b.commandExists("mango") {
status = deps.StatusInstalled
cmd := exec.Command("mango", "-v")
if output, err := cmd.Output(); err == nil {
outStr := string(output)
if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") {
variant = deps.VariantGit
}
if versionRegex := regexp.MustCompile(`(\d+\.\d+\.\d+)`); versionRegex.MatchString(outStr) {
matches := versionRegex.FindStringSubmatch(outStr)
if len(matches) > 1 {
version = matches[1]
}
}
}
}
return deps.Dependency{
Name: "mango",
Status: status,
Version: version,
Description: "dwl-based dynamic tiling Wayland compositor",
Required: true,
Variant: variant,
CanToggle: true,
}
default:
return deps.Dependency{
Name: "unknown-wm",
+1 -62
View File
@@ -71,8 +71,6 @@ func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, d.detectMatugen())
dependencies = append(dependencies, d.detectDgop())
dependencies = append(dependencies, d.detectDanksearch())
dependencies = append(dependencies, d.detectDankCalendar())
return dependencies, nil
}
@@ -137,8 +135,6 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
}
if wm == deps.WindowManagerNiri {
@@ -161,60 +157,7 @@ func (d *DebianDistribution) getQuickshellMapping(variant deps.PackageVariant) P
if forceQuickshellGit || variant == deps.VariantGit {
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}
}
// Debian 13 ships stable quickshell in trixie-backports only.
if d.debianNeedsQuickshellBackports() {
return PackageMapping{Name: "quickshell/trixie-backports", Repository: RepoTypeSystem}
}
return PackageMapping{Name: "quickshell", Repository: RepoTypeSystem}
}
func (d *DebianDistribution) debianNeedsQuickshellBackports() bool {
osInfo, err := GetOSInfo()
if err != nil {
return false
}
return osInfo.VersionID == "13" || strings.EqualFold(osInfo.VersionCodename, "trixie")
}
func (d *DebianDistribution) ensureQuickshellBackports(ctx context.Context, systemPkgs []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
needsBackports := false
for _, pkg := range systemPkgs {
if strings.Contains(pkg, "trixie-backports") {
needsBackports = true
break
}
}
if !needsBackports {
return nil
}
policyOut, err := exec.CommandContext(ctx, "apt-cache", "policy").Output()
if err == nil && strings.Contains(string(policyOut), "trixie-backports") {
d.log("trixie-backports already configured")
return nil
}
listFile := "/etc/apt/sources.list.d/trixie-backports.list"
repoLine := "deb http://deb.debian.org/debian trixie-backports main contrib non-free non-free-firmware"
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.30,
Step: "Enabling trixie-backports for quickshell...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("echo '%s' | sudo tee %s", repoLine, listFile),
LogOutput: "Debian 13 ships quickshell in trixie-backports",
}
addCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c \"echo '%s' | tee %s\"", repoLine, listFile))
if err := d.runWithProgress(addCmd, progressChan, PhaseSystemPackages, 0.30, 0.31); err != nil {
return err
}
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
return d.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.31, 0.33)
return PackageMapping{Name: "quickshell", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}
}
func (d *DebianDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
@@ -319,10 +262,6 @@ func (d *DebianDistribution) InstallPackages(ctx context.Context, dependencies [
}
}
if err := d.ensureQuickshellBackports(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to enable trixie-backports for quickshell: %w", err)
}
// System Packages
if len(systemPkgs) > 0 {
progressChan <- InstallProgressMsg{
-34
View File
@@ -1,34 +0,0 @@
package distros
import (
"context"
"fmt"
"os/exec"
)
// SetupDsearchService enables the dsearch.service user unit. Enablement failures
// are returned for the caller to surface as a non-fatal warning.
func SetupDsearchService(ctx context.Context, logf func(string)) error {
if logf == nil {
logf = func(string) {}
}
if err := runSystemctlUser(ctx, "daemon-reload"); err != nil {
return err
}
if err := runSystemctlUser(ctx, "enable", "--now", "dsearch.service"); err != nil {
return err
}
logf("Enabled dsearch.service")
return nil
}
func runSystemctlUser(ctx context.Context, args ...string) error {
cmd := exec.CommandContext(ctx, "systemctl", append([]string{"--user"}, args...)...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("systemctl --user %v failed: %w: %s", args, err, string(output))
}
return nil
}
+2 -59
View File
@@ -77,11 +77,7 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
// Common detections using base methods
dependencies = append(dependencies, f.detectGit())
wmDep := f.detectWindowManager(wm)
if wm == deps.WindowManagerMango {
wmDep.Description = "MangoWM (Wayland compositor) — the Terra repo will be enabled automatically to install it"
}
dependencies = append(dependencies, wmDep)
dependencies = append(dependencies, f.detectWindowManager(wm))
dependencies = append(dependencies, f.detectQuickshell())
dependencies = append(dependencies, f.detectDMSGreeter())
dependencies = append(dependencies, f.detectXDGPortal())
@@ -97,15 +93,8 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, f.detectXwaylandSatellite())
}
// Mango-specific tools (dwl-based, uses xwayland-satellite like niri)
if wm == deps.WindowManagerMango {
dependencies = append(dependencies, f.detectXwaylandSatellite())
}
dependencies = append(dependencies, f.detectMatugen())
dependencies = append(dependencies, f.detectDgop())
dependencies = append(dependencies, f.detectDanksearch())
dependencies = append(dependencies, f.detectDankCalendar())
return dependencies, nil
}
@@ -140,8 +129,6 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]),
"dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"danksearch": {Name: "danksearch", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
}
switch wm {
@@ -152,10 +139,6 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
case deps.WindowManagerNiri:
packages["niri"] = f.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
case deps.WindowManagerMango:
// mangowm resolves via Terra, enabled automatically by enableTerraRepo.
packages["mango"] = PackageMapping{Name: "mangowm", Repository: RepoTypeSystem}
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
}
return packages
@@ -176,7 +159,7 @@ func (f *FedoraDistribution) getDmsMapping(variant deps.PackageVariant) PackageM
}
func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "lionheartp/Hyprland"}
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "sdegler/hyprland"}
}
func (f *FedoraDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
@@ -314,22 +297,6 @@ func (f *FedoraDistribution) InstallPackages(ctx context.Context, dependencies [
}
}
// Phase 2b: Enable Terra repo for MangoWM (not in Fedora's repos). Must run
// before the DNF phase so `mangowm` resolves.
if wm == deps.WindowManagerMango {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.25,
Step: "Enabling Terra repository for MangoWM...",
IsComplete: false,
NeedsSudo: true,
LogOutput: "Setting up the Terra repo (fyralabs) to provide mango",
}
if err := f.enableTerraRepo(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to enable Terra repository: %w", err)
}
}
// Phase 3: System Packages (DNF)
if len(dnfPkgs) > 0 {
progressChan <- InstallProgressMsg{
@@ -456,30 +423,6 @@ func (f *FedoraDistribution) extractPackageNames(packages []PackageMapping) []st
return names
}
// enableTerraRepo registers the persistent Terra repo (via terra-release) so
// `mangowm` resolves in the DNF phase. $releasever is single-quoted so dnf, not
// the shell, expands it.
func (f *FedoraDistribution) enableTerraRepo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
// Skip if Terra is already configured
if exec.CommandContext(ctx, "sh", "-c",
"rpm -q terra-release >/dev/null 2>&1 || test -f /etc/yum.repos.d/terra.repo").Run() == nil {
f.log("Terra repository already configured, skipping enable")
return nil
}
f.log("Enabling Terra repository (fyralabs) for mango...")
cmd := privesc.ExecCommand(ctx, sudoPassword,
`dnf install -y --nogpgcheck --repofrompath 'terra,https://repos.fyralabs.com/terra$releasever' terra-release 2>&1`)
output, err := cmd.CombinedOutput()
if err != nil {
f.logError("failed to enable Terra repository", err)
f.log(fmt.Sprintf("Terra enable output: %s", string(output)))
return fmt.Errorf("failed to enable Terra repository: %w", err)
}
f.log(fmt.Sprintf("Terra repository enabled: %s", string(output)))
return nil
}
func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
enabledRepos := make(map[string]bool)
-15
View File
@@ -106,14 +106,8 @@ func (g *GentooDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, g.detectXwaylandSatellite())
}
// Mango-specific tools (dwl-based, uses xwayland-satellite like niri)
if wm == deps.WindowManagerMango {
dependencies = append(dependencies, g.detectXwaylandSatellite())
}
dependencies = append(dependencies, g.detectMatugen())
dependencies = append(dependencies, g.detectDgop())
dependencies = append(dependencies, g.detectDanksearch())
return dependencies, nil
}
@@ -172,7 +166,6 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
"dms (DankMaterialShell)": g.getDmsMapping(),
"dgop": {Name: "gui-apps/dgop", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
"danksearch": {Name: "gui-apps/danksearch", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
}
switch wm {
@@ -183,10 +176,6 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
case deps.WindowManagerNiri:
packages["niri"] = g.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "gui-apps/xwayland-satellite", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
case deps.WindowManagerMango:
packages["mango"] = g.getMangoMapping(variants["mango"])
packages["scenefx"] = PackageMapping{Name: "gui-libs/scenefx", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
packages["xwayland-satellite"] = PackageMapping{Name: "gui-apps/xwayland-satellite", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
}
return packages
@@ -208,10 +197,6 @@ func (g *GentooDistribution) getNiriMapping(_ deps.PackageVariant) PackageMappin
return PackageMapping{Name: "gui-wm/niri", Repository: RepoTypeGURU, UseFlags: "dbus screencast", AcceptKeywords: g.getArchKeyword()}
}
func (g *GentooDistribution) getMangoMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "gui-wm/mangowm", Repository: RepoTypeGURU, AcceptKeywords: g.getArchKeyword()}
}
func (g *GentooDistribution) getPrerequisites() []string {
return []string{
"app-eselect/eselect-repository",
-3
View File
@@ -17,7 +17,6 @@ const (
FamilyDebian DistroFamily = "debian"
FamilyNix DistroFamily = "nix"
FamilyGentoo DistroFamily = "gentoo"
FamilyVoid DistroFamily = "void"
)
// PackageManagerType defines the package manager a distro uses
@@ -30,7 +29,6 @@ const (
PackageManagerZypper PackageManagerType = "zypper"
PackageManagerNix PackageManagerType = "nix"
PackageManagerPortage PackageManagerType = "portage"
PackageManagerXBPS PackageManagerType = "xbps"
)
// RepositoryType defines the type of repository for a package
@@ -44,7 +42,6 @@ const (
RepoTypeOBS RepositoryType = "obs" // OpenBuild Service (Debian/OpenSUSE)
RepoTypeFlake RepositoryType = "flake" // Nix flake
RepoTypeGURU RepositoryType = "guru" // Gentoo GURU
RepoTypeXBPS RepositoryType = "xbps" // Custom XBPS repository
RepoTypeManual RepositoryType = "manual" // Manual build from source
)
+2 -15
View File
@@ -520,7 +520,7 @@ func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, v
Progress: 0.90,
Step: "Cloning DankMaterialShell...",
IsComplete: false,
CommandInfo: "git clone --recurse-submodules https://github.com/AvengeMedia/DankMaterialShell.git",
CommandInfo: "git clone https://github.com/AvengeMedia/DankMaterialShell.git",
}
configDir := filepath.Dir(dmsPath)
@@ -528,7 +528,7 @@ func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, v
return fmt.Errorf("failed to create quickshell config directory: %w", err)
}
cloneCmd := exec.CommandContext(ctx, "git", "clone", "--recurse-submodules",
cloneCmd := exec.CommandContext(ctx, "git", "clone",
"https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath)
if err := cloneCmd.Run(); err != nil {
return fmt.Errorf("failed to clone DankMaterialShell: %w", err)
@@ -553,8 +553,6 @@ func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, v
return nil
}
m.syncDMSSubmodules(ctx, dmsPath)
m.log(fmt.Sprintf("Checked out latest tag: %s", latestTag))
m.log("DankMaterialShell cloned successfully")
return nil
@@ -593,8 +591,6 @@ func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, v
return nil
}
m.syncDMSSubmodules(ctx, dmsPath)
m.log("DankMaterialShell updated successfully (git variant)")
return nil
}
@@ -613,19 +609,10 @@ func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, v
return nil
}
m.syncDMSSubmodules(ctx, dmsPath)
m.log(fmt.Sprintf("Updated to tag: %s", latestTag))
return nil
}
func (m *ManualPackageInstaller) syncDMSSubmodules(ctx context.Context, dmsPath string) {
submoduleCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "submodule", "update", "--init", "--recursive")
if err := submoduleCmd.Run(); err != nil {
m.logError("Failed to update submodules", err)
}
}
func (m *ManualPackageInstaller) installXwaylandSatellite(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing xwayland-satellite from source...")
+1 -1
View File
@@ -7,7 +7,7 @@ type minimalInstallGroup struct {
func shouldPreferMinimalInstall(pkg string) bool {
switch pkg {
case "niri", "niri-git", "hyprland", "hyprland-git":
case "niri", "niri-git":
return true
default:
return false
-4
View File
@@ -91,8 +91,6 @@ func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Contex
dependencies = append(dependencies, o.detectMatugen())
dependencies = append(dependencies, o.detectDgop())
dependencies = append(dependencies, o.detectDanksearch())
dependencies = append(dependencies, o.detectDankCalendar())
return dependencies, nil
}
@@ -131,8 +129,6 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
}
switch wm {
-4
View File
@@ -80,8 +80,6 @@ func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, u.detectMatugen())
dependencies = append(dependencies, u.detectDgop())
dependencies = append(dependencies, u.detectDanksearch())
dependencies = append(dependencies, u.detectDankCalendar())
return dependencies, nil
}
@@ -126,8 +124,6 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"dgop": {Name: "dgop", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"danksearch": {Name: "danksearch", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
}
switch wm {
-541
View File
@@ -1,541 +0,0 @@
package distros
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
)
const (
VoidDMSRepo = "https://void.danklinux.com/dms/current"
VoidDankLinuxRepo = "https://void.danklinux.com/danklinux/current"
VoidHyprlandRepo = "https://mirror.black-hole.dev/x86_64"
voidRunitSvDir = "/etc/sv"
voidRunitServiceDir = "/var/service"
)
func init() {
Register("void", "#478061", FamilyVoid, func(config DistroConfig, logChan chan<- string) Distribution {
return NewVoidDistribution(config, logChan)
})
}
type VoidDistribution struct {
*BaseDistribution
config DistroConfig
}
func NewVoidDistribution(config DistroConfig, logChan chan<- string) *VoidDistribution {
return &VoidDistribution{
BaseDistribution: NewBaseDistribution(logChan),
config: config,
}
}
func (v *VoidDistribution) GetID() string {
return v.config.ID
}
func (v *VoidDistribution) GetColorHex() string {
return v.config.ColorHex
}
func (v *VoidDistribution) GetFamily() DistroFamily {
return v.config.Family
}
func (v *VoidDistribution) GetPackageManager() PackageManagerType {
return PackageManagerXBPS
}
func (v *VoidDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
return v.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
}
func (v *VoidDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
var dependencies []deps.Dependency
dependencies = append(dependencies, v.detectDMS())
dependencies = append(dependencies, v.detectSpecificTerminal(terminal))
dependencies = append(dependencies, v.detectGit())
dependencies = append(dependencies, v.detectWindowManager(wm))
dependencies = append(dependencies, v.detectQuickshell())
dependencies = append(dependencies, v.detectDMSGreeter())
dependencies = append(dependencies, v.detectXDGPortal())
dependencies = append(dependencies, v.detectAccountsService())
dependencies = append(dependencies, v.detectDBus())
dependencies = append(dependencies, v.detectElogind())
dependencies = append(dependencies, v.detectMesaDri())
if wm == deps.WindowManagerHyprland {
dependencies = append(dependencies, v.detectHyprlandTools()...)
}
if wm == deps.WindowManagerNiri || wm == deps.WindowManagerMango {
dependencies = append(dependencies, v.detectXwaylandSatellite())
}
dependencies = append(dependencies, v.detectMatugen())
dependencies = append(dependencies, v.detectDgop())
dependencies = append(dependencies, v.detectDanksearch())
dependencies = append(dependencies, v.detectDankCalendar())
return dependencies, nil
}
func (v *VoidDistribution) detectDMS() deps.Dependency {
status := deps.StatusMissing
version := ""
variant := deps.VariantStable
if v.packageInstalled("dms-git") {
status = deps.StatusInstalled
version = v.packageVersion("dms-git")
variant = deps.VariantGit
} else if v.packageInstalled("dms") {
status = deps.StatusInstalled
version = v.packageVersion("dms")
} else if v.commandExists("dms") {
status = deps.StatusInstalled
}
return deps.Dependency{
Name: "dms (DankMaterialShell)",
Status: status,
Version: version,
Description: "Desktop Management System package",
Required: true,
Variant: variant,
CanToggle: true,
}
}
func (v *VoidDistribution) detectQuickshell() deps.Dependency {
dep := v.BaseDistribution.detectQuickshell()
dep.CanToggle = false
return dep
}
func (v *VoidDistribution) detectXDGPortal() deps.Dependency {
return v.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", v.packageInstalled("xdg-desktop-portal-gtk"))
}
func (v *VoidDistribution) detectDMSGreeter() deps.Dependency {
return v.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", v.packageInstalled("dms-greeter"))
}
func (v *VoidDistribution) detectAccountsService() deps.Dependency {
return v.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", v.packageInstalled("accountsservice"))
}
func (v *VoidDistribution) detectDBus() deps.Dependency {
return v.detectPackage("dbus", "D-Bus system and session message bus", v.packageInstalled("dbus"))
}
func (v *VoidDistribution) detectElogind() deps.Dependency {
return v.detectPackage("elogind", "loginctl/logind provider for power management and session tracking", v.packageInstalled("elogind") || v.commandExists("loginctl"))
}
func (v *VoidDistribution) detectMesaDri() deps.Dependency {
return v.detectPackage("mesa-dri", "Mesa DRI/EGL drivers (GPU rendering; compositors find no outputs without it)", v.packageInstalled("mesa-dri"))
}
func (v *VoidDistribution) detectXwaylandSatellite() deps.Dependency {
return v.detectPackage("xwayland-satellite", "Xwayland support", v.packageInstalled("xwayland-satellite"))
}
func (v *VoidDistribution) packageInstalled(pkg string) bool {
return exec.Command("xbps-query", pkg).Run() == nil
}
func (v *VoidDistribution) packageVersion(pkg string) string {
output, err := exec.Command("xbps-query", "-p", "pkgver", pkg).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(output))
}
func (v *VoidDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return v.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
}
func (v *VoidDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping {
packages := map[string]PackageMapping{
"git": {Name: "git", Repository: RepoTypeSystem},
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"dbus": {Name: "dbus", Repository: RepoTypeSystem},
"elogind": {Name: "elogind", Repository: RepoTypeSystem},
"mesa-dri": {Name: "mesa-dri", Repository: RepoTypeSystem},
"quickshell": {Name: "quickshell", Repository: RepoTypeSystem},
"matugen": {Name: "matugen", Repository: RepoTypeSystem},
"dms (DankMaterialShell)": v.getDmsMapping(variants["dms (DankMaterialShell)"]),
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeXBPS, RepoURL: VoidDMSRepo},
"dgop": {Name: "dgop", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
"danksearch": {Name: "danksearch", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
"dankcalendar": {Name: "dankcalendar", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
}
switch wm {
case deps.WindowManagerHyprland:
packages["hyprland"] = PackageMapping{Name: "hyprland", Repository: RepoTypeXBPS, RepoURL: VoidHyprlandRepo}
packages["hyprctl"] = PackageMapping{Name: "hyprland", Repository: RepoTypeXBPS, RepoURL: VoidHyprlandRepo}
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
case deps.WindowManagerNiri:
packages["niri"] = PackageMapping{Name: "niri", Repository: RepoTypeSystem}
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
case deps.WindowManagerMango:
packages["mango"] = PackageMapping{Name: "mangowc", Repository: RepoTypeSystem}
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
}
return packages
}
func (v *VoidDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantStable {
return PackageMapping{Name: "dms", Repository: RepoTypeXBPS, RepoURL: VoidDMSRepo}
}
return PackageMapping{Name: "dms-git", Repository: RepoTypeXBPS, RepoURL: VoidDMSRepo}
}
func (v *VoidDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.06,
Step: "Checking XBPS...",
IsComplete: false,
LogOutput: "Checking for xbps-install",
}
if _, err := exec.LookPath("xbps-install"); err != nil {
return fmt.Errorf("xbps-install not found; Void Linux package tools are required: %w", err)
}
return nil
}
func (v *VoidDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error {
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
Progress: 0.05,
Step: "Checking system prerequisites...",
IsComplete: false,
LogOutput: "Starting prerequisite check...",
}
if wm == deps.WindowManagerHyprland {
arch, err := v.xbpsArch(ctx)
if err != nil {
return fmt.Errorf("failed to detect XBPS architecture for Hyprland repository selection: %w", err)
}
if arch != "x86_64" {
return fmt.Errorf("hyprland on Void Linux is installed from %s, which currently provides x86_64 packages only (detected %s)", VoidHyprlandRepo, arch)
}
}
if err := v.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install prerequisites: %w", err)
}
systemPkgs, xbpsPkgs := v.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
if len(xbpsPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.15,
Step: "Enabling DMS XBPS repositories...",
IsComplete: false,
NeedsSudo: true,
LogOutput: "Setting up custom XBPS repositories for DMS packages",
}
if err := v.enableXBPSRepos(ctx, xbpsPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to enable XBPS repositories: %w", err)
}
}
allPkgs := v.uniquePackageNames(systemPkgs, v.extractPackageNames(xbpsPkgs))
if len(allPkgs) > 0 {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.35,
Step: fmt.Sprintf("Installing %d XBPS packages...", len(allPkgs)),
IsComplete: false,
NeedsSudo: true,
LogOutput: fmt.Sprintf("Installing XBPS packages: %s", strings.Join(allPkgs, ", ")),
}
if err := v.installXBPSPackages(ctx, allPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install XBPS packages: %w", err)
}
}
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.90,
Step: "Configuring system...",
IsComplete: false,
LogOutput: "Starting post-installation configuration...",
}
v.log("Void Linux detected; DMS environment and autostart will be configured in the compositor config instead of systemd")
if err := v.ensureSessionServices(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to enable Void session services: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseComplete,
Progress: 1.0,
Step: "Installation complete!",
IsComplete: true,
LogOutput: "All packages installed and configured successfully",
}
return nil
}
func (v *VoidDistribution) ensureSessionServices(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if !v.isRunitSystem() {
v.log("Void runit service directory not detected; skipping dbus/elogind service enablement")
return nil
}
// D-Bus activation alone starts elogind without its wrapper mounts; the runit service is required.
for _, service := range []string{"dbus", "elogind"} {
if !v.runitServiceInstalled(service) {
v.log(fmt.Sprintf("Warning: %s runit service not found in %s; power/session actions may not work until %s is installed", service, voidRunitSvDir, service))
continue
}
if v.runitServiceEnabled(service) {
v.log(fmt.Sprintf("Void runit service %s already enabled", service))
continue
}
progressChan <- InstallProgressMsg{
Phase: PhaseConfiguration,
Progress: 0.92,
Step: fmt.Sprintf("Enabling %s runit service...", service),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo ln -sf %s %s", filepath.Join(voidRunitSvDir, service), filepath.Join(voidRunitServiceDir, service)),
LogOutput: fmt.Sprintf("Enabling Void runit service: %s", service),
}
cmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("ln -sf %s %s", filepath.Join(voidRunitSvDir, service), filepath.Join(voidRunitServiceDir, service)))
if err := v.runWithProgress(cmd, progressChan, PhaseConfiguration, 0.92, 0.95); err != nil {
return fmt.Errorf("failed to enable %s runit service: %w", service, err)
}
v.log(fmt.Sprintf("✓ Enabled %s runit service", service))
}
return nil
}
func (v *VoidDistribution) isRunitSystem() bool {
if fi, err := os.Stat("/run/runit"); err == nil && fi.IsDir() {
return true
}
if _, err := os.Stat("/run/systemd/system"); err == nil {
return false
}
if fi, err := os.Stat(voidRunitServiceDir); err == nil && fi.IsDir() {
return true
}
return false
}
func (v *VoidDistribution) runitServiceInstalled(name string) bool {
fi, err := os.Stat(filepath.Join(voidRunitSvDir, name))
return err == nil && fi.IsDir()
}
func (v *VoidDistribution) runitServiceEnabled(name string) bool {
_, err := os.Lstat(filepath.Join(voidRunitServiceDir, name))
return err == nil
}
func (v *VoidDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]string, []PackageMapping) {
systemPkgs := []string{}
xbpsPkgs := []PackageMapping{}
variantMap := make(map[string]deps.PackageVariant)
for _, dep := range dependencies {
variantMap[dep.Name] = dep.Variant
}
packageMap := v.GetPackageMappingWithVariants(wm, variantMap)
for _, dep := range dependencies {
if disabledFlags[dep.Name] {
continue
}
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
continue
}
pkgInfo, exists := packageMap[dep.Name]
if !exists {
v.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name))
continue
}
switch pkgInfo.Repository {
case RepoTypeXBPS:
xbpsPkgs = append(xbpsPkgs, pkgInfo)
case RepoTypeSystem:
systemPkgs = append(systemPkgs, pkgInfo.Name)
}
}
return systemPkgs, xbpsPkgs
}
func (v *VoidDistribution) enableXBPSRepos(ctx context.Context, xbpsPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
enabledRepos := make(map[string]bool)
enabledRepoURLs := []string{}
for _, pkg := range xbpsPkgs {
if pkg.RepoURL == "" || enabledRepos[pkg.RepoURL] {
continue
}
repoName := v.xbpsRepoName(pkg.RepoURL)
confPath := filepath.Join("/etc/xbps.d", repoName+".conf")
repoLine := fmt.Sprintf("repository=%s", pkg.RepoURL)
repoFileContent := repoLine + "\n"
if content, err := os.ReadFile(confPath); err == nil && string(content) == repoFileContent {
v.log(fmt.Sprintf("XBPS repo %s already configured, skipping", pkg.RepoURL))
enabledRepos[pkg.RepoURL] = true
enabledRepoURLs = append(enabledRepoURLs, pkg.RepoURL)
continue
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.18,
Step: fmt.Sprintf("Adding XBPS repo %s...", repoName),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("echo 'repository=%s' | sudo tee %s", pkg.RepoURL, confPath),
LogOutput: fmt.Sprintf("Adding XBPS repository: %s", pkg.RepoURL),
}
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, "mkdir -p /etc/xbps.d")
if err := v.runWithProgress(mkdirCmd, progressChan, PhaseSystemPackages, 0.18, 0.19); err != nil {
return fmt.Errorf("failed to create /etc/xbps.d: %w", err)
}
writeCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c 'printf \"%%s\\n\" %q > %s'", repoLine, confPath))
if err := v.runWithProgress(writeCmd, progressChan, PhaseSystemPackages, 0.19, 0.22); err != nil {
return fmt.Errorf("failed to add XBPS repo %s: %w", pkg.RepoURL, err)
}
enabledRepos[pkg.RepoURL] = true
enabledRepoURLs = append(enabledRepoURLs, pkg.RepoURL)
}
if len(enabledRepos) > 0 {
syncArgs := []string{"xbps-install", "-Sy", "-i"}
for _, repoURL := range enabledRepoURLs {
syncArgs = append(syncArgs, "--repository", repoURL)
}
syncCommand := strings.Join(syncArgs, " ")
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.25,
Step: "Synchronizing XBPS repositories...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo sh -c 'yes y | " + syncCommand + "'",
LogOutput: "Synchronizing XBPS repository indexes",
}
syncCmd := privesc.ExecCommand(ctx, sudoPassword, "sh -c 'yes y | "+syncCommand+"'")
if err := v.runWithProgress(syncCmd, progressChan, PhaseSystemPackages, 0.25, 0.30); err != nil {
return fmt.Errorf("failed to synchronize XBPS repositories: %w", err)
}
}
return nil
}
func (v *VoidDistribution) xbpsRepoName(repoURL string) string {
switch repoURL {
case VoidDMSRepo:
return "dms"
case VoidDankLinuxRepo:
return "danklinux"
case VoidHyprlandRepo:
return "hyprland"
default:
name := strings.TrimPrefix(repoURL, "https://")
name = strings.TrimPrefix(name, "http://")
name = strings.NewReplacer("/", "-", ".", "-").Replace(name)
return name
}
}
func (v *VoidDistribution) xbpsArch(ctx context.Context) (string, error) {
output, err := exec.CommandContext(ctx, "xbps-uhelper", "arch").Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(output)), nil
}
func (v *VoidDistribution) installXBPSPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
}
args := append([]string{"xbps-install", "-Sy"}, packages...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.40,
Step: "Installing XBPS packages...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
}
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
return v.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.85)
}
func (v *VoidDistribution) extractPackageNames(packages []PackageMapping) []string {
names := make([]string, len(packages))
for i, pkg := range packages {
names[i] = pkg.Name
}
return names
}
func (v *VoidDistribution) uniquePackageNames(groups ...[]string) []string {
seen := make(map[string]bool)
var unique []string
for _, group := range groups {
for _, pkg := range group {
if pkg == "" || seen[pkg] {
continue
}
seen[pkg] = true
unique = append(unique, pkg)
}
}
return unique
}
+15 -13
View File
@@ -1,15 +1,9 @@
package errdefs
import (
dankerrdefs "github.com/AvengeMedia/dankgo/errdefs"
)
type ErrorType = dankerrdefs.ErrorType
type CustomError = dankerrdefs.CustomError
type ErrorType int
const (
ErrTypeNotLinux ErrorType = dankerrdefs.AppErrorBase + iota
ErrTypeNotLinux ErrorType = iota
ErrTypeInvalidArchitecture
ErrTypeUnsupportedDistribution
ErrTypeUnsupportedVersion
@@ -28,8 +22,20 @@ const (
ErrTypeGeneric
)
type CustomError struct {
Type ErrorType
Message string
}
func (e *CustomError) Error() string {
return e.Message
}
func NewCustomError(errType ErrorType, message string) error {
return dankerrdefs.NewCustomError(errType, message)
return &CustomError{
Type: errType,
Message: message,
}
}
const (
@@ -41,10 +47,6 @@ const (
ErrWifiDisabled = "wifi-disabled"
ErrAlreadyConnected = "already-connected"
ErrConnectionFailed = "connection-failed"
ErrHotspotIPConfigFailed = "hotspot-ip-config-failed"
ErrHotspotSupplicantFailed = "hotspot-supplicant-failed"
ErrHotspotFailed = "hotspot-failed"
)
var (
+3 -3
View File
@@ -5,8 +5,8 @@ import (
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/dankgo/dbusutil"
"github.com/AvengeMedia/dankgo/syncmap"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
"github.com/godbus/dbus/v5"
)
@@ -132,7 +132,7 @@ func (c *GeoClueClient) startSignalPump() error {
if err := c.dbusConn.AddMatchSignal(
dbus.WithMatchObjectPath(c.clientPath),
dbus.WithMatchInterface(dbusGeoClueClientInterface),
dbus.WithMatchMember("LocationUpdated"),
dbus.WithMatchSender(dbusGeoClueClientLocationUpdated),
); err != nil {
return err
}
@@ -0,0 +1,91 @@
# AppArmor profile for dms-greeter
#
# Managed by DMS — regenerated on every `dms greeter install` / `dms greeter sync`.
# Manual edits will be overwritten on next sync.
#
# Mode: complain (denials are logged, nothing is blocked)
# To switch to enforce after validating with `aa-logprof`:
# sudo aa-enforce /etc/apparmor.d/usr.bin.dms-greeter
#
#include <tunables/global>
profile dms-greeter /usr/bin/dms-greeter flags=(complain) {
#include <abstractions/base>
#include <abstractions/bash>
# The launcher script itself
/usr/bin/dms-greeter r,
# Cache directory — created by dms greeter sync/enable with greeter:greeter ownership
/var/cache/dms-greeter/ rw,
/var/cache/dms-greeter/** rwlk,
# DMS config — packaged path
/usr/share/quickshell/dms-greeter/ r,
/usr/share/quickshell/dms-greeter/** r,
/usr/share/quickshell/ r,
/usr/share/quickshell/** r,
# DMS config — system and user overrides
/etc/dms/ r,
/etc/dms/** r,
/usr/share/dms/ r,
/usr/share/dms/** r,
/home/*/.config/quickshell/ r,
/home/*/.config/quickshell/** r,
/root/.config/quickshell/ r,
/root/.config/quickshell/** r,
# greetd / PAM — read-only for session setup
/etc/greetd/ r,
/etc/greetd/** r,
/etc/pam.d/ r,
/etc/pam.d/** r,
/usr/lib/pam.d/ r,
/usr/lib/pam.d/** r,
# Compositor binaries — run unconfined so each compositor uses its own profile
/usr/bin/niri Ux,
/usr/bin/hyprland Ux,
/usr/bin/Hyprland Ux,
/usr/bin/sway Ux,
/usr/bin/labwc Ux,
/usr/bin/scroll Ux,
/usr/bin/miracle-wm Ux,
/usr/bin/mango Ux,
# Quickshell — run unconfined (has its own compositor profile on some distros)
/usr/bin/qs Ux,
/usr/bin/quickshell Ux,
# Wayland / XDG runtime (pipewire, wireplumber, wayland socket)
/run/user/[0-9]*/ rw,
/run/user/[0-9]*/** rw,
# DRM / GPU devices (required for Wayland compositor startup)
/dev/dri/ r,
/dev/dri/* rw,
/dev/udmabuf rw,
# Input devices
/dev/input/ r,
/dev/input/* r,
# Systemd journal / logging
/run/systemd/journal/socket rw,
/dev/log rw,
# Shell helper binaries invoked by the launcher script
/usr/bin/env ix,
/usr/bin/mkdir ix,
/usr/bin/cat ix,
/usr/bin/grep ix,
/usr/bin/dirname ix,
/usr/bin/basename ix,
/usr/bin/command ix,
/bin/env ix,
/bin/mkdir ix,
# Signal management (compositor lifecycle)
signal (send, receive) set=("term", "int", "hup", "kill"),
}
File diff suppressed because it is too large Load Diff

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