mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-02 03:28:28 -04:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db4de55338 | |||
| 37ecbbbbde | |||
| d6a6d2a438 | |||
| bf1c6eec74 | |||
| 0ddae80584 | |||
| 5c96c03bfa | |||
| dfe36e47d8 | |||
| 63e1b75e57 | |||
| 29efdd8598 | |||
| 34d03cf11b | |||
| c339389d44 | |||
| af5f6eb656 | |||
| a6d28e2553 | |||
| 6213267908 | |||
| d084114149 | |||
| f6d99eca0d | |||
| 722eb3289e | |||
| b7f2bdcb2d | |||
| 11c20db6e6 | |||
| 8a4e3f8bb1 | |||
| bc8fe97c13 | |||
| 47262155aa |
@@ -1,104 +0,0 @@
|
||||
# Agent Skills
|
||||
|
||||
This directory contains agent skills following the [Agent Skills](https://agentskills.io) open standard - a portable, version-controlled format for giving AI agents specialized capabilities.
|
||||
|
||||
Each skill is a directory with a `SKILL.md` entrypoint, optional reference docs, scripts, and templates. Agents load skills progressively: metadata at startup, full instructions on activation, and supporting files on demand.
|
||||
|
||||
## Available Skills
|
||||
|
||||
| Skill | Description |
|
||||
|-------|-------------|
|
||||
| [dms-plugin-dev](dms-plugin-dev/) | Develop plugins for DankMaterialShell - covers all 4 plugin types (widget, daemon, launcher, desktop), manifest creation, QML components, settings UI, data persistence, theme integration, and PopoutService usage. |
|
||||
|
||||
## Installation
|
||||
|
||||
The `.agents/skills/` directory at the project root is the standard location defined by the agentskills.io spec. Many agents discover skills from this path automatically. Some agents use their own directory conventions and need a symlink or copy.
|
||||
|
||||
### Claude Code
|
||||
|
||||
Claude Code discovers skills from `.claude/skills/` (project-level) or `~/.claude/skills/` (personal). To make skills from `.agents/skills/` available, symlink them into the Claude Code skills directory:
|
||||
|
||||
**Project-level** (this repo only):
|
||||
|
||||
```bash
|
||||
mkdir -p .claude/skills
|
||||
ln -s ../../.agents/skills/dms-plugin-dev .claude/skills/dms-plugin-dev
|
||||
```
|
||||
|
||||
**Personal** (all your projects):
|
||||
|
||||
```bash
|
||||
ln -s /path/to/DankMaterialShell/.agents/skills/dms-plugin-dev ~/.claude/skills/dms-plugin-dev
|
||||
```
|
||||
|
||||
After linking, the skill appears in Claude Code's `/` menu as `/dms-plugin-dev`, and Claude loads it automatically when you ask about DMS plugin development.
|
||||
|
||||
See the [Claude Code skills docs](https://code.claude.com/docs/en/skills) for more on skill configuration, invocation control, and frontmatter options.
|
||||
|
||||
### Cursor
|
||||
|
||||
Cursor discovers skills from `.cursor/skills/` in the project root:
|
||||
|
||||
```bash
|
||||
mkdir -p .cursor/skills
|
||||
ln -s ../../.agents/skills/dms-plugin-dev .cursor/skills/dms-plugin-dev
|
||||
```
|
||||
|
||||
See [Cursor skills docs](https://cursor.com/docs/context/skills) for details.
|
||||
|
||||
### VS Code (Copilot)
|
||||
|
||||
VS Code Copilot discovers skills from `.github/skills/` or `.vscode/skills/`:
|
||||
|
||||
```bash
|
||||
mkdir -p .github/skills
|
||||
ln -s ../../.agents/skills/dms-plugin-dev .github/skills/dms-plugin-dev
|
||||
```
|
||||
|
||||
See [VS Code skills docs](https://code.visualstudio.com/docs/copilot/customization/agent-skills) for details.
|
||||
|
||||
### Gemini CLI
|
||||
|
||||
Gemini CLI discovers skills from `.gemini/skills/` in the project root:
|
||||
|
||||
```bash
|
||||
mkdir -p .gemini/skills
|
||||
ln -s ../../.agents/skills/dms-plugin-dev .gemini/skills/dms-plugin-dev
|
||||
```
|
||||
|
||||
See [Gemini CLI skills docs](https://geminicli.com/docs/cli/skills/) for details.
|
||||
|
||||
### OpenAI Codex
|
||||
|
||||
Codex discovers skills from `.codex/skills/` in the project root:
|
||||
|
||||
```bash
|
||||
mkdir -p .codex/skills
|
||||
ln -s ../../.agents/skills/dms-plugin-dev .codex/skills/dms-plugin-dev
|
||||
```
|
||||
|
||||
See [Codex skills docs](https://developers.openai.com/codex/skills/) for details.
|
||||
|
||||
### Other Agents
|
||||
|
||||
The Agent Skills standard is supported by 30+ tools including Goose, Roo Code, JetBrains Junie, Amp, OpenCode, OpenHands, Kiro, and more. Most discover skills from a dot-directory at the project root (e.g., `.goose/skills/`, `.roo/skills/`). Some read `.agents/skills/` directly.
|
||||
|
||||
Check the [Agent Skills client showcase](https://agentskills.io/clients) for setup instructions specific to your agent.
|
||||
|
||||
The general pattern is:
|
||||
|
||||
```bash
|
||||
mkdir -p .<agent>/skills
|
||||
ln -s ../../.agents/skills/dms-plugin-dev .<agent>/skills/dms-plugin-dev
|
||||
```
|
||||
|
||||
## Adding New Skills
|
||||
|
||||
To add a new skill to this directory:
|
||||
|
||||
1. Create a subdirectory named with lowercase letters, numbers, and hyphens (e.g., `my-new-skill/`)
|
||||
2. Add a `SKILL.md` file with YAML frontmatter (`name`, `description`) and markdown instructions
|
||||
3. Optionally add `references/`, `scripts/`, and `assets/` subdirectories
|
||||
4. Keep `SKILL.md` under 500 lines - move detailed content to reference files
|
||||
|
||||
See the [Agent Skills specification](https://agentskills.io/specification) for the full format.
|
||||
@@ -1,609 +0,0 @@
|
||||
---
|
||||
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.
|
||||
compatibility: Designed for Claude Code (or similar products)
|
||||
metadata:
|
||||
author: DankMaterialShell
|
||||
version: "1.1"
|
||||
domain: qml-desktop-development
|
||||
framework: DankMaterialShell
|
||||
languages: qml, javascript
|
||||
allowed-tools: Bash Read Write Edit
|
||||
---
|
||||
|
||||
# DankMaterialShell Plugin Development
|
||||
|
||||
## Overview
|
||||
|
||||
DMS plugins extend the desktop shell with custom widgets, background services, launcher
|
||||
integrations, and desktop widgets. Plugins are QML components discovered from
|
||||
`~/.config/DankMaterialShell/plugins/`.
|
||||
|
||||
**Minimum plugin structure:**
|
||||
|
||||
```
|
||||
~/.config/DankMaterialShell/plugins/YourPlugin/
|
||||
plugin.json # Required: manifest with metadata
|
||||
YourComponent.qml # Required: main QML component
|
||||
YourSettings.qml # Optional: settings UI
|
||||
*.js # Optional: JavaScript utilities
|
||||
```
|
||||
|
||||
**Plugin registry:** Community plugins are available at https://plugins.danklinux.com/
|
||||
|
||||
**Five 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 |
|
||||
|
||||
## Step 1: Determine Plugin Type
|
||||
|
||||
Choose the type based on what the plugin does:
|
||||
|
||||
- **Shows in the bar?** - Use `widget`. Displays a pill in DankBar, optionally opens a popout,
|
||||
optionally integrates with Control Center.
|
||||
- **Runs in background only?** - Use `daemon`. No visible UI, reacts to events (wallpaper
|
||||
changes, notifications, battery level, etc.).
|
||||
- **Provides searchable/actionable items?** - Use `launcher`. Items appear in the DMS launcher
|
||||
with trigger-based filtering (e.g., type `=` for calculator, `:` for emoji).
|
||||
- **Shows on the desktop background?** - Use `desktop`. Draggable, resizable widget on the
|
||||
desktop layer.
|
||||
- **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
|
||||
|
||||
Create `plugin.json` in your plugin directory. See [plugin-manifest-reference.md](references/plugin-manifest-reference.md) for the full schema.
|
||||
|
||||
**Minimal manifest:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "yourPlugin",
|
||||
"name": "Your Plugin Name",
|
||||
"description": "Brief description of what your plugin does",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"type": "widget",
|
||||
"capabilities": ["your-capability"],
|
||||
"component": "./YourWidget.qml"
|
||||
}
|
||||
```
|
||||
|
||||
**With settings, startup check, and permissions:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "yourPlugin",
|
||||
"name": "Your Plugin Name",
|
||||
"description": "Brief description",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"type": "widget",
|
||||
"capabilities": ["your-capability"],
|
||||
"component": "./YourWidget.qml",
|
||||
"icon": "extension",
|
||||
"settings": "./Settings.qml",
|
||||
"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"]
|
||||
}
|
||||
```
|
||||
|
||||
**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
|
||||
- `settings_write` permission is **required** if the plugin has a settings component
|
||||
- `dependencies` replaces the deprecated `requires` field
|
||||
|
||||
## Step 3: Create the Main Component
|
||||
|
||||
### Widget
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginComponent {
|
||||
property var popoutService: null
|
||||
|
||||
horizontalBarPill: Component {
|
||||
StyledRect {
|
||||
width: label.implicitWidth + Theme.spacingM * 2
|
||||
height: parent.widgetThickness
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
StyledText {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: "Hello"
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
verticalBarPill: Component {
|
||||
StyledRect {
|
||||
width: parent.widgetThickness
|
||||
height: label.implicitHeight + Theme.spacingM * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
StyledText {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: "Hi"
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
rotation: 90
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [widget-plugin-guide.md](references/widget-plugin-guide.md) for popouts, CC integration, and advanced features.
|
||||
|
||||
### Launcher
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var pluginService: null
|
||||
property string trigger: "#"
|
||||
|
||||
signal itemsChanged()
|
||||
|
||||
function getItems(query) {
|
||||
const items = [
|
||||
{ name: "Item One", icon: "material:star", comment: "Description",
|
||||
action: "toast:Hello!", categories: ["MyPlugin"] }
|
||||
]
|
||||
if (!query) return items
|
||||
const q = query.toLowerCase()
|
||||
return items.filter(i => i.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
function executeItem(item) {
|
||||
const [type, ...rest] = item.action.split(":")
|
||||
const data = rest.join(":")
|
||||
if (type === "toast") ToastService?.showInfo(data)
|
||||
else if (type === "copy") Quickshell.execDetached(["dms", "cl", "copy", data])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [launcher-plugin-guide.md](references/launcher-plugin-guide.md) for triggers, icon types, context menus, and image tiles.
|
||||
|
||||
### Desktop
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var pluginService: null
|
||||
property string pluginId: ""
|
||||
property bool editMode: false
|
||||
property real widgetWidth: 200
|
||||
property real widgetHeight: 200
|
||||
property real minWidth: 150
|
||||
property real minHeight: 150
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainer
|
||||
opacity: 0.85
|
||||
border.color: root.editMode ? Theme.primary : "transparent"
|
||||
border.width: root.editMode ? 2 : 0
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "Desktop Widget"
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [desktop-plugin-guide.md](references/desktop-plugin-guide.md) for sizing, persistence, and edit mode.
|
||||
|
||||
### Daemon
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginComponent {
|
||||
property var popoutService: null
|
||||
|
||||
Connections {
|
||||
target: SessionData
|
||||
function onSomeSignal() {
|
||||
console.log("Event received")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [daemon-plugin-guide.md](references/daemon-plugin-guide.md) for event-driven patterns and process execution.
|
||||
|
||||
### 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)
|
||||
|
||||
Wrap settings in `PluginSettings` with your `pluginId`. All settings auto-save and auto-load.
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginSettings {
|
||||
pluginId: "yourPlugin"
|
||||
|
||||
StringSetting {
|
||||
settingKey: "apiKey"
|
||||
label: "API Key"
|
||||
description: "Your API key"
|
||||
placeholder: "sk-..."
|
||||
}
|
||||
|
||||
ToggleSetting {
|
||||
settingKey: "enabled"
|
||||
label: "Enable Feature"
|
||||
defaultValue: true
|
||||
}
|
||||
|
||||
SelectionSetting {
|
||||
settingKey: "interval"
|
||||
label: "Refresh Interval"
|
||||
options: [
|
||||
{ label: "1 min", value: "60" },
|
||||
{ label: "5 min", value: "300" }
|
||||
]
|
||||
defaultValue: "300"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Available setting components:** StringSetting, ToggleSetting, SelectionSetting, SliderSetting, ColorSetting, ListSetting, ListSettingWithInput.
|
||||
|
||||
See [settings-components-reference.md](references/settings-components-reference.md) for full property lists.
|
||||
|
||||
**Important:** Your plugin must declare `"permissions": ["settings_write"]` in plugin.json, or the settings UI will show an error.
|
||||
|
||||
## Step 6: Use Data Persistence
|
||||
|
||||
Three tiers of persistence:
|
||||
|
||||
| API | Persisted | Use case |
|
||||
|-----|-----------|----------|
|
||||
| `pluginService.savePluginData(id, key, val)` / `loadPluginData(id, key, default)` | Yes (settings.json) | User preferences, config |
|
||||
| `pluginService.savePluginState(id, key, val)` / `loadPluginState(id, key, default)` | Yes (separate state file) | Runtime state, history, cache |
|
||||
| `PluginGlobalVar { varName; defaultValue; value; set() }` | No (runtime only) | Cross-instance shared state |
|
||||
| `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) { ... } }`
|
||||
- Global vars sync across all instances (multi-monitor, multiple bar sections)
|
||||
|
||||
See [data-persistence-guide.md](references/data-persistence-guide.md) for details and examples.
|
||||
|
||||
## Step 7: Theme Integration
|
||||
|
||||
Always use `Theme.*` properties from `qs.Common` - never hardcode colors or sizes.
|
||||
|
||||
**Essential properties:**
|
||||
- Colors: `Theme.surfaceContainerHigh`, `Theme.surfaceText`, `Theme.primary`, `Theme.onPrimary`
|
||||
- Fonts: `Theme.fontSizeSmall` (12), `Theme.fontSizeMedium` (14), `Theme.fontSizeLarge` (16), `Theme.fontSizeXLarge` (20)
|
||||
- Spacing: `Theme.spacingXS`, `Theme.spacingS`, `Theme.spacingM`, `Theme.spacingL`, `Theme.spacingXL`
|
||||
- Radius: `Theme.cornerRadius`, `Theme.cornerRadiusSmall`, `Theme.cornerRadiusLarge`
|
||||
- Icons: `Theme.iconSizeSmall` (16), `Theme.iconSize` (24), `Theme.iconSizeLarge` (32)
|
||||
|
||||
**Common widgets from `qs.Widgets`:** `StyledText`, `StyledRect`, `DankIcon`, `DankButton`, `DankToggle`, `DankTextField`, `DankSlider`, `DankGridView`, `CachingImage`.
|
||||
|
||||
See [theme-reference.md](references/theme-reference.md) for the complete property list.
|
||||
|
||||
## Step 8: Add Popout Content (Widgets Only)
|
||||
|
||||
Add a popout that opens when the bar pill is clicked:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
popoutWidth: 400
|
||||
popoutHeight: 300
|
||||
|
||||
popoutContent: Component {
|
||||
PopoutComponent {
|
||||
headerText: "My Plugin"
|
||||
detailsText: "Optional subtitle"
|
||||
showCloseButton: true
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
StyledText {
|
||||
text: "Content here"
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
horizontalBarPill: Component { /* ... */ }
|
||||
verticalBarPill: Component { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
**PopoutComponent properties:** `headerText`, `detailsText`, `showCloseButton`, `closePopout()` (auto-injected), `headerHeight` (readonly), `detailsHeight` (readonly).
|
||||
|
||||
Calculate available content height: `popoutHeight - headerHeight - detailsHeight - spacing`
|
||||
|
||||
## Step 9: Control Center Integration (Widgets Only)
|
||||
|
||||
Add your widget to the Control Center grid:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
ccWidgetIcon: "toggle_on"
|
||||
ccWidgetPrimaryText: "My Feature"
|
||||
ccWidgetSecondaryText: isActive ? "On" : "Off"
|
||||
ccWidgetIsActive: isActive
|
||||
|
||||
onCcWidgetToggled: {
|
||||
isActive = !isActive
|
||||
pluginService?.savePluginData(pluginId, "active", isActive)
|
||||
}
|
||||
|
||||
// Optional: expandable detail panel (for CompoundPill)
|
||||
ccDetailContent: Component {
|
||||
Rectangle {
|
||||
implicitHeight: 200
|
||||
color: Theme.surfaceContainerHigh
|
||||
radius: Theme.cornerRadius
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**CC sizing:** 25% width = SmallToggleButton (icon only), 50% width = ToggleButton or CompoundPill (if ccDetailContent is defined).
|
||||
|
||||
## Step 10: External Commands and Clipboard
|
||||
|
||||
**Run commands and capture output:**
|
||||
|
||||
```qml
|
||||
import qs.Common
|
||||
|
||||
Proc.runCommand(
|
||||
"myPlugin.fetch",
|
||||
["curl", "-s", "https://api.example.com/data"],
|
||||
(stdout, exitCode) => {
|
||||
if (exitCode === 0) processData(stdout)
|
||||
},
|
||||
500 // debounce ms
|
||||
)
|
||||
```
|
||||
|
||||
**Fire-and-forget (clipboard, notifications):**
|
||||
|
||||
```qml
|
||||
import Quickshell
|
||||
|
||||
Quickshell.execDetached(["dms", "cl", "copy", textToCopy])
|
||||
```
|
||||
|
||||
**Long-running processes:** Use the `Process` QML component from `Quickshell.Io` with `StdioCollector`.
|
||||
|
||||
**Shell commands with pipes:** `["sh", "-c", "ps aux | grep foo"]`
|
||||
|
||||
**Do NOT use** `globalThis.clipboard` or browser JavaScript APIs - they don't exist in the QML runtime.
|
||||
|
||||
## Step 11: 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
|
||||
|
||||
1. **Missing `settings_write` permission** - Settings UI shows error without it
|
||||
2. **Missing `property var popoutService: null`** - Must declare for injection to work
|
||||
3. **Missing vertical bar pill** - Widget disappears when bar is on left/right edge
|
||||
4. **Hardcoded colors** - Use `Theme.*` properties, not hex values
|
||||
5. **Using `globalThis.clipboard`** - Does not exist; use `Quickshell.execDetached(["dms", "cl", "copy", text])`
|
||||
6. **Wrong Theme property names** - `Theme.fontSizeS` does not exist, use `Theme.fontSizeSmall`
|
||||
7. **Wrong import for Quickshell** - Use `import Quickshell` (not `import QtQuick` for execDetached)
|
||||
8. **Forgetting `categories` in launcher items** - Items won't display without it
|
||||
9. **Not handling null pluginService** - Always use optional chaining or null checks
|
||||
10. **Using `PluginComponent` for launchers** - Launchers use plain `Item`, not `PluginComponent`
|
||||
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
|
||||
|
||||
**Widget / Daemon:**
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
```
|
||||
|
||||
**Launcher:**
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Services
|
||||
```
|
||||
|
||||
**Desktop:**
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
```
|
||||
|
||||
**For clipboard/exec:** `import Quickshell`
|
||||
**For processes:** `import Quickshell.Io`
|
||||
**For networking:** `import Quickshell.Networking`
|
||||
**For toast notifications:** access `ToastService` from `qs.Services`
|
||||
|
||||
## Quick Reference: File Naming
|
||||
|
||||
- **Directory name:** PascalCase (e.g., `MyAwesomePlugin/`)
|
||||
- **Plugin ID:** camelCase (e.g., `myAwesomePlugin`)
|
||||
- **QML files:** PascalCase (e.g., `MyWidget.qml`, `Settings.qml`)
|
||||
- **Component paths in manifest:** relative with `./` prefix (e.g., `"./MyWidget.qml"`)
|
||||
- **JS utility files:** camelCase (e.g., `utils.js`, `apiAdapter.js`)
|
||||
|
||||
## Reference Files
|
||||
|
||||
Load these on demand for detailed API documentation:
|
||||
|
||||
- [plugin-manifest-reference.md](references/plugin-manifest-reference.md) - Complete plugin.json field reference and JSON schema
|
||||
- [widget-plugin-guide.md](references/widget-plugin-guide.md) - PluginComponent, bar pills, popouts, click actions, CC integration
|
||||
- [launcher-plugin-guide.md](references/launcher-plugin-guide.md) - getItems/executeItem, triggers, icon types, context menus, tile view
|
||||
- [desktop-plugin-guide.md](references/desktop-plugin-guide.md) - DesktopPluginComponent, sizing, edit mode, position persistence
|
||||
- [daemon-plugin-guide.md](references/daemon-plugin-guide.md) - Event-driven background services, process execution
|
||||
- [settings-components-reference.md](references/settings-components-reference.md) - All 7 setting components with complete property lists
|
||||
- [theme-reference.md](references/theme-reference.md) - Theme colors, spacing, fonts, radii, common patterns
|
||||
- [data-persistence-guide.md](references/data-persistence-guide.md) - pluginData, state API, global variables
|
||||
- [popout-service-reference.md](references/popout-service-reference.md) - PopoutService API for controlling shell popouts and modals
|
||||
- [advanced-patterns.md](references/advanced-patterns.md) - Variants, JS utilities, qmldir, IPC, multi-file plugins
|
||||
@@ -1,177 +0,0 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://danklinux.com/schemas/plugin.json",
|
||||
"title": "DankMaterialShell Plugin Manifest",
|
||||
"description": "Schema for DankMaterialShell plugin.json manifest files",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"version",
|
||||
"author",
|
||||
"type",
|
||||
"capabilities"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique plugin identifier (camelCase, no spaces)",
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9]*$"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Human-readable plugin name",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Short description of plugin functionality",
|
||||
"minLength": 1
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Semantic version string (e.g., '1.0.0')",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.-]+)?(\\+[a-zA-Z0-9.-]+)?$"
|
||||
},
|
||||
"author": {
|
||||
"type": "string",
|
||||
"description": "Plugin creator name or email",
|
||||
"minLength": 1
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Plugin type. Use 'composite' (or any value) together with 'components' to provide multiple surfaces from one plugin.",
|
||||
"enum": ["widget", "daemon", "launcher", "desktop", "composite"]
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
"description": "Array of plugin capabilities",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"component": {
|
||||
"type": "string",
|
||||
"description": "Relative path to main QML component file. Required unless 'components' is provided.",
|
||||
"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)"
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"description": "Material Design icon name"
|
||||
},
|
||||
"settings": {
|
||||
"type": "string",
|
||||
"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'.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"description": "Required capabilities",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"settings_read",
|
||||
"settings_write",
|
||||
"process",
|
||||
"network"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "launcher"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"required": ["trigger"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"required": ["components"],
|
||||
"properties": {
|
||||
"components": {
|
||||
"required": ["launcher"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"required": ["trigger"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"required": ["component"]
|
||||
},
|
||||
{
|
||||
"required": ["components"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"additionalProperties": true
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginComponent {
|
||||
id: root
|
||||
property var popoutService: null
|
||||
|
||||
// TODO: Read configuration from settings
|
||||
property string configValue: pluginData?.configValue || ""
|
||||
|
||||
Connections {
|
||||
target: pluginService
|
||||
function onPluginDataChanged(changedId) {
|
||||
if (changedId !== pluginId) return
|
||||
configValue = pluginService.loadPluginData(pluginId, "configValue", "")
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Connect to the service events you need
|
||||
// Connections {
|
||||
// target: SessionData
|
||||
// function onWallpaperPathChanged() {
|
||||
// console.log("[MyDaemon] Wallpaper changed:", SessionData.wallpaperPath)
|
||||
// handleEvent(SessionData.wallpaperPath)
|
||||
// }
|
||||
// }
|
||||
|
||||
function handleEvent(data) {
|
||||
Proc.runCommand(
|
||||
"myDaemon.handle",
|
||||
["echo", "Event received:", data],
|
||||
(stdout, exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
console.log("[MyDaemon] Output:", stdout)
|
||||
} else {
|
||||
console.error("[MyDaemon] Failed:", exitCode)
|
||||
ToastService?.showInfo("Daemon action failed")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
console.log("[MyDaemon] Started")
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginSettings {
|
||||
pluginId: "myDaemon"
|
||||
|
||||
StringSetting {
|
||||
settingKey: "configValue"
|
||||
label: "Configuration"
|
||||
description: "Value used by the daemon"
|
||||
placeholder: "Enter value..."
|
||||
defaultValue: ""
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"id": "myDaemon",
|
||||
"name": "My Daemon",
|
||||
"description": "A background service that reacts to events",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"type": "daemon",
|
||||
"capabilities": ["background-service"],
|
||||
"component": "./Daemon.qml",
|
||||
"icon": "settings",
|
||||
"settings": "./Settings.qml",
|
||||
"permissions": ["settings_read", "settings_write", "process"]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginSettings {
|
||||
pluginId: "myDesktopWidget"
|
||||
|
||||
SliderSetting {
|
||||
settingKey: "opacity"
|
||||
label: "Opacity"
|
||||
description: "Widget background opacity"
|
||||
defaultValue: 85
|
||||
minimum: 10
|
||||
maximum: 100
|
||||
unit: "%"
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var pluginService: null
|
||||
property string pluginId: ""
|
||||
property bool editMode: false
|
||||
property real widgetWidth: 200
|
||||
property real widgetHeight: 200
|
||||
property real minWidth: 150
|
||||
property real minHeight: 150
|
||||
|
||||
// TODO: Load settings reactively
|
||||
property real bgOpacity: {
|
||||
if (!pluginService) return 0.85
|
||||
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
|
||||
return val / 100
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: pluginService
|
||||
function onPluginDataChanged(changedId) {
|
||||
if (changedId !== pluginId) return
|
||||
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
|
||||
bgOpacity = val / 100
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainer
|
||||
opacity: root.bgOpacity
|
||||
border.color: root.editMode ? Theme.primary : "transparent"
|
||||
border.width: root.editMode ? 2 : 0
|
||||
|
||||
// TODO: Add your widget content here
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "Desktop Widget"
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"id": "myDesktopWidget",
|
||||
"name": "My Desktop Widget",
|
||||
"description": "A custom desktop widget",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"type": "desktop",
|
||||
"capabilities": ["desktop-widget"],
|
||||
"component": "./Widget.qml",
|
||||
"icon": "widgets",
|
||||
"settings": "./Settings.qml",
|
||||
"permissions": ["settings_read", "settings_write"]
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var pluginService: null
|
||||
property string trigger: "#"
|
||||
|
||||
signal itemsChanged()
|
||||
|
||||
// TODO: Define your items
|
||||
property var allItems: [
|
||||
{
|
||||
name: "Example Item",
|
||||
icon: "material:star",
|
||||
comment: "An example launcher item",
|
||||
action: "toast:Hello from my launcher!",
|
||||
categories: ["MyLauncher"]
|
||||
}
|
||||
]
|
||||
|
||||
function getItems(query) {
|
||||
if (!query || query.length === 0) return allItems
|
||||
|
||||
var q = query.toLowerCase()
|
||||
return allItems.filter(function(item) {
|
||||
return item.name.toLowerCase().includes(q) ||
|
||||
item.comment.toLowerCase().includes(q)
|
||||
})
|
||||
}
|
||||
|
||||
function executeItem(item) {
|
||||
var actionParts = item.action.split(":")
|
||||
var actionType = actionParts[0]
|
||||
var actionData = actionParts.slice(1).join(":")
|
||||
|
||||
switch (actionType) {
|
||||
case "toast":
|
||||
if (typeof ToastService !== "undefined")
|
||||
ToastService.showInfo(actionData)
|
||||
break
|
||||
case "copy":
|
||||
Quickshell.execDetached(["dms", "cl", "copy", actionData])
|
||||
if (typeof ToastService !== "undefined")
|
||||
ToastService.showInfo("Copied to clipboard")
|
||||
break
|
||||
default:
|
||||
console.warn("Unknown action type:", actionType)
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (pluginService) {
|
||||
trigger = pluginService.loadPluginData("myLauncher", "trigger", "#")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginSettings {
|
||||
pluginId: "myLauncher"
|
||||
|
||||
StringSetting {
|
||||
settingKey: "trigger"
|
||||
label: "Trigger"
|
||||
description: "Type this prefix in the launcher to activate the plugin"
|
||||
placeholder: "#"
|
||||
defaultValue: "#"
|
||||
}
|
||||
|
||||
ToggleSetting {
|
||||
settingKey: "noTrigger"
|
||||
label: "Always Visible"
|
||||
description: "Show items alongside regular apps without needing a trigger"
|
||||
defaultValue: false
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"id": "myLauncher",
|
||||
"name": "My Launcher",
|
||||
"description": "Custom launcher plugin with searchable items",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"type": "launcher",
|
||||
"capabilities": ["launcher"],
|
||||
"component": "./Launcher.qml",
|
||||
"trigger": "#",
|
||||
"icon": "search",
|
||||
"settings": "./Settings.qml",
|
||||
"permissions": ["settings_read", "settings_write"]
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginSettings {
|
||||
pluginId: "myWidget"
|
||||
|
||||
StringSetting {
|
||||
settingKey: "text"
|
||||
label: "Display Text"
|
||||
description: "Text shown in the bar widget"
|
||||
placeholder: "Hello"
|
||||
defaultValue: "Hello"
|
||||
}
|
||||
|
||||
ToggleSetting {
|
||||
settingKey: "showIcon"
|
||||
label: "Show Icon"
|
||||
description: "Display an icon next to the text"
|
||||
defaultValue: true
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginComponent {
|
||||
id: root
|
||||
property var popoutService: null
|
||||
|
||||
// TODO: Read settings reactively
|
||||
property string displayText: pluginData?.text || "Hello"
|
||||
|
||||
Connections {
|
||||
target: pluginService
|
||||
function onPluginDataChanged(changedId) {
|
||||
if (changedId !== pluginId) return
|
||||
displayText = pluginService.loadPluginData(pluginId, "text", "Hello")
|
||||
}
|
||||
}
|
||||
|
||||
horizontalBarPill: Component {
|
||||
StyledRect {
|
||||
width: label.implicitWidth + Theme.spacingM * 2
|
||||
height: parent.widgetThickness
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
StyledText {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: root.displayText
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
verticalBarPill: Component {
|
||||
StyledRect {
|
||||
width: parent.widgetThickness
|
||||
height: label.implicitHeight + Theme.spacingM * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
StyledText {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: root.displayText
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
rotation: 90
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Uncomment and customize popout content
|
||||
// popoutWidth: 350
|
||||
// popoutHeight: 300
|
||||
// popoutContent: Component {
|
||||
// PopoutComponent {
|
||||
// headerText: "My Widget"
|
||||
// showCloseButton: true
|
||||
//
|
||||
// Column {
|
||||
// width: parent.width
|
||||
// spacing: Theme.spacingM
|
||||
//
|
||||
// StyledText {
|
||||
// text: "Popout content here"
|
||||
// color: Theme.surfaceText
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"id": "myWidget",
|
||||
"name": "My Widget",
|
||||
"description": "A custom bar widget",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"type": "widget",
|
||||
"capabilities": ["dankbar-widget"],
|
||||
"component": "./Widget.qml",
|
||||
"icon": "extension",
|
||||
"settings": "./Settings.qml",
|
||||
"permissions": ["settings_read", "settings_write"]
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
# Advanced Patterns
|
||||
|
||||
Patterns observed in production DMS plugins that go beyond the basics.
|
||||
|
||||
## Plugin Variants
|
||||
|
||||
Create multiple widget instances from a single plugin definition. Each variant has its own configuration.
|
||||
|
||||
### Manifest
|
||||
|
||||
No special manifest changes needed - the variant system is built into PluginComponent.
|
||||
|
||||
### Widget with Variant Support
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
property string variantId: ""
|
||||
property var variantData: ({})
|
||||
|
||||
property string displayText: variantData?.text || "Default"
|
||||
|
||||
horizontalBarPill: Component {
|
||||
StyledRect {
|
||||
width: label.implicitWidth + Theme.spacingM * 2
|
||||
height: parent.widgetThickness
|
||||
|
||||
StyledText {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: root.displayText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Widget format in bar config: `pluginId:variantId` (e.g., `exampleVariants:variant_1234567890`)
|
||||
|
||||
### Settings with Variant Management
|
||||
|
||||
```qml
|
||||
PluginSettings {
|
||||
pluginId: "exampleVariants"
|
||||
|
||||
// Variant creation UI
|
||||
DankButton {
|
||||
text: "Add New Instance"
|
||||
onClicked: {
|
||||
var id = "variant_" + Date.now()
|
||||
root.createVariant(id, { name: "New Instance", text: "Hello" })
|
||||
}
|
||||
}
|
||||
|
||||
// Per-variant configuration
|
||||
Repeater {
|
||||
model: root.variants
|
||||
delegate: Column {
|
||||
StringSetting {
|
||||
settingKey: modelData.id + "_text"
|
||||
label: modelData.name || modelData.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## JavaScript Utility Files
|
||||
|
||||
For complex logic, split into `.js` files:
|
||||
|
||||
### utils.js
|
||||
|
||||
```javascript
|
||||
.pragma library
|
||||
|
||||
function formatDuration(ms) {
|
||||
if (ms < 60000) return "just now"
|
||||
if (ms < 3600000) return Math.floor(ms / 60000) + "m ago"
|
||||
return Math.floor(ms / 3600000) + "h ago"
|
||||
}
|
||||
|
||||
function parseResponse(json) {
|
||||
try {
|
||||
return JSON.parse(json)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using in QML
|
||||
|
||||
```qml
|
||||
import "utils.js" as Utils
|
||||
|
||||
Item {
|
||||
StyledText {
|
||||
text: Utils.formatDuration(Date.now() - timestamp)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `.pragma library` directive makes the JS file a shared singleton - it is loaded once and shared across all QML instances that import it.
|
||||
|
||||
## qmldir for Singleton Services
|
||||
|
||||
For plugins with internal singleton services:
|
||||
|
||||
### qmldir
|
||||
|
||||
```
|
||||
singleton MyService 1.0 MyService.qml
|
||||
```
|
||||
|
||||
### MyService.qml
|
||||
|
||||
```qml
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
|
||||
QtObject {
|
||||
property var cache: ({})
|
||||
|
||||
function getData(key) {
|
||||
return cache[key] || null
|
||||
}
|
||||
|
||||
function setData(key, value) {
|
||||
cache[key] = value
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using the singleton
|
||||
|
||||
```qml
|
||||
import "." as Local
|
||||
|
||||
Item {
|
||||
Component.onCompleted: {
|
||||
Local.MyService.setData("key", "value")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Inline Component Declarations
|
||||
|
||||
Reusable sub-components defined inline:
|
||||
|
||||
```qml
|
||||
Item {
|
||||
component StatusBadge: Rectangle {
|
||||
property string label: ""
|
||||
property color badgeColor: Theme.primary
|
||||
|
||||
width: badgeText.implicitWidth + Theme.spacingM * 2
|
||||
height: 24
|
||||
radius: 12
|
||||
color: badgeColor
|
||||
|
||||
StyledText {
|
||||
id: badgeText
|
||||
anchors.centerIn: parent
|
||||
text: label
|
||||
color: Theme.onPrimary
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: Theme.spacingS
|
||||
StatusBadge { label: "Running"; badgeColor: Theme.success }
|
||||
StatusBadge { label: "Stopped"; badgeColor: Theme.error }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-Provider Adapter Pattern
|
||||
|
||||
For plugins supporting multiple backends (AI providers, API services):
|
||||
|
||||
### apiAdapters.js
|
||||
|
||||
```javascript
|
||||
.pragma library
|
||||
|
||||
function createAdapter(provider) {
|
||||
switch (provider) {
|
||||
case "openai": return {
|
||||
url: "https://api.openai.com/v1/chat/completions",
|
||||
headers: (key) => ({ "Authorization": "Bearer " + key }),
|
||||
formatRequest: (messages) => JSON.stringify({ model: "gpt-4", messages: messages }),
|
||||
parseResponse: (text) => JSON.parse(text).choices[0].message.content
|
||||
}
|
||||
case "anthropic": return {
|
||||
url: "https://api.anthropic.com/v1/messages",
|
||||
headers: (key) => ({ "x-api-key": key, "anthropic-version": "2023-06-01" }),
|
||||
formatRequest: (messages) => JSON.stringify({ model: "claude-sonnet-4-20250514", messages: messages }),
|
||||
parseResponse: (text) => JSON.parse(text).content[0].text
|
||||
}
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## IPC Integration
|
||||
|
||||
For plugins that respond to keyboard shortcuts or external commands:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
Connections {
|
||||
target: DMSIpc
|
||||
function onCommandReceived(command, args) {
|
||||
if (command === "myPlugin.toggle") {
|
||||
doToggle()
|
||||
} else if (command === "myPlugin.next") {
|
||||
goNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
External trigger: `dms ipc call myPlugin.toggle`
|
||||
|
||||
## Networking with Quickshell.Networking
|
||||
|
||||
For API calls using the built-in networking module:
|
||||
|
||||
```qml
|
||||
import Quickshell.Networking
|
||||
|
||||
Item {
|
||||
NetworkRequest {
|
||||
id: request
|
||||
url: "https://api.example.com/data"
|
||||
method: "GET"
|
||||
|
||||
onResponseReceived: (response) => {
|
||||
const data = JSON.parse(response.body)
|
||||
processData(data)
|
||||
}
|
||||
|
||||
onErrorOccurred: (error) => {
|
||||
console.error("Network error:", error)
|
||||
}
|
||||
}
|
||||
|
||||
function fetchData() {
|
||||
request.send()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Toast Notifications
|
||||
|
||||
Show user feedback:
|
||||
|
||||
```qml
|
||||
import qs.Services
|
||||
|
||||
// Info toast
|
||||
ToastService?.showInfo("Operation completed")
|
||||
|
||||
// With title
|
||||
ToastService?.showInfo("Plugin Name", "Data refreshed successfully")
|
||||
```
|
||||
|
||||
Always use optional chaining since ToastService may not be available in all contexts.
|
||||
|
||||
## Clipboard Operations
|
||||
|
||||
```qml
|
||||
import Quickshell
|
||||
|
||||
function copyToClipboard(text) {
|
||||
Quickshell.execDetached(["dms", "cl", "copy", text])
|
||||
ToastService?.showInfo("Copied to clipboard")
|
||||
}
|
||||
```
|
||||
|
||||
Do NOT use `globalThis.clipboard`, `navigator.clipboard`, or any browser API - they do not exist in the QML runtime.
|
||||
|
||||
## Multi-File Plugin Architecture
|
||||
|
||||
Large plugins can be split across multiple files:
|
||||
|
||||
```
|
||||
MyPlugin/
|
||||
plugin.json
|
||||
Main.qml # Main widget component
|
||||
Settings.qml # Settings UI
|
||||
DetailView.qml # Popout detail view
|
||||
utils.js # Utility functions
|
||||
apiAdapter.js # API adapter layer
|
||||
qmldir # Optional: singleton registrations
|
||||
```
|
||||
|
||||
Import sibling files:
|
||||
|
||||
```qml
|
||||
// In Main.qml
|
||||
import "." as Local
|
||||
|
||||
Item {
|
||||
Loader {
|
||||
source: "DetailView.qml"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. Use `Proc.runCommand` with appropriate debounce for external commands
|
||||
2. Pre-cache images and thumbnails for image-heavy plugins
|
||||
3. Limit concurrent network requests
|
||||
4. Use `Timer` with reasonable intervals (don't poll faster than needed)
|
||||
5. Lazy-load heavy content (use `Loader` for complex popout content)
|
||||
6. Avoid blocking the UI thread with synchronous operations
|
||||
@@ -1,272 +0,0 @@
|
||||
# Daemon Plugin Guide
|
||||
|
||||
Daemon plugins are invisible background services that react to events and execute actions. They have no bar pills or desktop presence.
|
||||
|
||||
## Base Component
|
||||
|
||||
Daemons use `PluginComponent` with no bar pills:
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginComponent {
|
||||
id: root
|
||||
property var popoutService: null
|
||||
|
||||
// Event-driven logic goes here
|
||||
}
|
||||
```
|
||||
|
||||
## When to Use Daemons
|
||||
|
||||
- Monitor system events (wallpaper changes, battery level, notifications)
|
||||
- Run periodic background tasks (polling APIs, checking system state)
|
||||
- Execute scripts in response to events
|
||||
- Control shell UI via PopoutService based on conditions
|
||||
|
||||
## Event-Driven Pattern
|
||||
|
||||
Use `Connections` to react to service signals:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
property var popoutService: null
|
||||
|
||||
Connections {
|
||||
target: SessionData
|
||||
function onWallpaperPathChanged() {
|
||||
console.log("Wallpaper changed to:", SessionData.wallpaperPath)
|
||||
runScript(SessionData.wallpaperPath)
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: BatteryService
|
||||
function onPercentageChanged() {
|
||||
if (BatteryService.percentage < 10 && !BatteryService.isCharging) {
|
||||
popoutService?.openBattery()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Available Services
|
||||
|
||||
Common services daemons can connect to:
|
||||
|
||||
| Service | Signals/Properties | Description |
|
||||
|---------|-------------------|-------------|
|
||||
| `SessionData` | `wallpaperPath`, `onWallpaperPathChanged` | Desktop session state |
|
||||
| `BatteryService` | `percentage`, `isCharging`, `batteryAvailable` | Battery status |
|
||||
| `NotificationService` | `onNotificationReceived(notification)` | Desktop notifications |
|
||||
| `PluginService` | `onPluginLoaded`, `onGlobalVarChanged` | Plugin lifecycle |
|
||||
|
||||
Import services from `qs.Services`.
|
||||
|
||||
## Process Execution
|
||||
|
||||
### Simple command with Proc
|
||||
|
||||
```qml
|
||||
import qs.Common
|
||||
|
||||
PluginComponent {
|
||||
function runScript(arg) {
|
||||
Proc.runCommand(
|
||||
"myDaemon.script",
|
||||
["bash", "-c", "echo 'Processing: " + arg + "'"],
|
||||
(stdout, exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
console.log("Script output:", stdout)
|
||||
} else {
|
||||
ToastService?.showInfo("Script failed: exit " + exitCode)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Long-running process with Process component
|
||||
|
||||
```qml
|
||||
import Quickshell.Io
|
||||
|
||||
PluginComponent {
|
||||
property string scriptPath: ""
|
||||
|
||||
Process {
|
||||
id: proc
|
||||
command: ["bash", scriptPath]
|
||||
running: false
|
||||
|
||||
stdout: StdioCollector {
|
||||
onTextReceived: (text) => {
|
||||
console.log("stdout:", text)
|
||||
}
|
||||
}
|
||||
|
||||
stderr: StdioCollector {
|
||||
onTextReceived: (text) => {
|
||||
console.error("stderr:", text)
|
||||
}
|
||||
}
|
||||
|
||||
onExited: (exitCode) => {
|
||||
if (exitCode !== 0) {
|
||||
ToastService?.showInfo("Process failed: exit " + exitCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startProcess() {
|
||||
if (scriptPath && !proc.running) {
|
||||
proc.running = true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Timer-Based Polling
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
Timer {
|
||||
interval: 60000 // every minute
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: checkStatus()
|
||||
}
|
||||
|
||||
function checkStatus() {
|
||||
Proc.runCommand(
|
||||
"myDaemon.check",
|
||||
["sh", "-c", "systemctl is-active myservice"],
|
||||
(stdout, exitCode) => {
|
||||
const active = stdout.trim() === "active"
|
||||
PluginService.setGlobalVar("myDaemon", "serviceActive", active)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Data Persistence
|
||||
|
||||
Daemons access PluginService directly (it's injected via PluginComponent):
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
property string configuredScript: pluginData?.scriptPath || ""
|
||||
|
||||
Connections {
|
||||
target: pluginService
|
||||
function onPluginDataChanged(changedId) {
|
||||
if (changedId === pluginId) {
|
||||
configuredScript = pluginService.loadPluginData(pluginId, "scriptPath", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## PopoutService Usage
|
||||
|
||||
Daemons can control shell UI via the injected popoutService:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
property var popoutService: null
|
||||
|
||||
function showAlert() {
|
||||
popoutService?.openNotificationCenter()
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
popoutService?.openSettings()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [popout-service-reference.md](popout-service-reference.md) for the full API.
|
||||
|
||||
## Complete Example
|
||||
|
||||
Based on the WallpaperWatcherDaemon:
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginComponent {
|
||||
id: root
|
||||
property var popoutService: null
|
||||
|
||||
property string scriptPath: pluginData?.scriptPath || ""
|
||||
|
||||
Connections {
|
||||
target: pluginService
|
||||
function onPluginDataChanged(changedId) {
|
||||
if (changedId === pluginId) {
|
||||
scriptPath = pluginService.loadPluginData(pluginId, "scriptPath", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SessionData
|
||||
function onWallpaperPathChanged() {
|
||||
if (scriptPath) {
|
||||
runWallpaperScript(SessionData.wallpaperPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runWallpaperScript(wallpaperPath) {
|
||||
console.log("[WallpaperWatcher] Running script:", scriptPath, wallpaperPath)
|
||||
|
||||
Proc.runCommand(
|
||||
"wallpaperWatcher.run",
|
||||
["bash", scriptPath, wallpaperPath],
|
||||
(stdout, exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
console.log("[WallpaperWatcher] Script output:", stdout)
|
||||
} else {
|
||||
console.error("[WallpaperWatcher] Script failed:", exitCode)
|
||||
ToastService?.showInfo("Wallpaper script failed")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
console.log("[WallpaperWatcher] Daemon started")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Manifest Example
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "wallpaperWatcher",
|
||||
"name": "Wallpaper Watcher",
|
||||
"description": "Runs a script when the wallpaper changes",
|
||||
"version": "1.0.0",
|
||||
"author": "Developer",
|
||||
"type": "daemon",
|
||||
"capabilities": ["wallpaper-automation"],
|
||||
"component": "./WallpaperWatcher.qml",
|
||||
"icon": "wallpaper",
|
||||
"settings": "./Settings.qml",
|
||||
"permissions": ["settings_read", "settings_write", "process"]
|
||||
}
|
||||
```
|
||||
@@ -1,186 +0,0 @@
|
||||
# Data Persistence Guide
|
||||
|
||||
DMS plugins have three tiers of data persistence, each suited for different use cases.
|
||||
|
||||
## Tier 1: Plugin Data (Settings)
|
||||
|
||||
Persisted to `settings.json`. Use for user preferences and configuration.
|
||||
|
||||
### Saving
|
||||
|
||||
```qml
|
||||
pluginService.savePluginData(pluginId, "key", value)
|
||||
```
|
||||
|
||||
### Loading
|
||||
|
||||
```qml
|
||||
var value = pluginService.loadPluginData(pluginId, "key", defaultValue)
|
||||
```
|
||||
|
||||
### Reactive Access via pluginData
|
||||
|
||||
`PluginComponent` has a reactive `pluginData` property that auto-loads from settings:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
property string displayText: pluginData?.text || "Default"
|
||||
property bool showIcon: pluginData?.showIcon !== undefined ? pluginData.showIcon : true
|
||||
}
|
||||
```
|
||||
|
||||
### Reacting to Settings Changes
|
||||
|
||||
When settings are changed (e.g., from the settings UI), react with `Connections`:
|
||||
|
||||
```qml
|
||||
Connections {
|
||||
target: pluginService
|
||||
function onPluginDataChanged(changedId) {
|
||||
if (changedId !== pluginId) return
|
||||
displayText = pluginService.loadPluginData(pluginId, "text", "Default")
|
||||
showIcon = pluginService.loadPluginData(pluginId, "showIcon", true)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tier 2: Plugin State
|
||||
|
||||
Persisted to a separate state file. Use for runtime state that should survive restarts but is not user-configurable (history, cache, counters).
|
||||
|
||||
### Saving
|
||||
|
||||
```qml
|
||||
pluginService.savePluginState(pluginId, "key", value)
|
||||
```
|
||||
|
||||
### Loading
|
||||
|
||||
```qml
|
||||
var state = pluginService.loadPluginState(pluginId, "key", defaultValue)
|
||||
```
|
||||
|
||||
### Additional Methods
|
||||
|
||||
```qml
|
||||
pluginService.clearPluginState(pluginId)
|
||||
pluginService.removePluginStateKey(pluginId, "key")
|
||||
```
|
||||
|
||||
### Example: Persistent History
|
||||
|
||||
```qml
|
||||
Item {
|
||||
property var history: []
|
||||
|
||||
Component.onCompleted: {
|
||||
history = pluginService?.loadPluginState(pluginId, "history", []) || []
|
||||
}
|
||||
|
||||
function addToHistory(entry) {
|
||||
history.unshift({
|
||||
text: entry,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
if (history.length > 100) history = history.slice(0, 100)
|
||||
pluginService?.savePluginState(pluginId, "history", history)
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
history = []
|
||||
pluginService?.removePluginStateKey(pluginId, "history")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tier 3: Global Variables (Runtime Only)
|
||||
|
||||
NOT persisted. Shared across all instances of a plugin. Use for cross-instance state synchronization (multi-monitor consistency, multi-instance widgets).
|
||||
|
||||
### Using PluginGlobalVar Component
|
||||
|
||||
```qml
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginComponent {
|
||||
PluginGlobalVar {
|
||||
id: globalCounter
|
||||
varName: "counter"
|
||||
defaultValue: 0
|
||||
}
|
||||
|
||||
horizontalBarPill: Component {
|
||||
StyledRect {
|
||||
// ...
|
||||
StyledText {
|
||||
text: "Count: " + globalCounter.value
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
onClicked: globalCounter.set(globalCounter.value + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**PluginGlobalVar properties:**
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `varName` | string | Required: name of the global variable |
|
||||
| `defaultValue` | any | Optional: default if not set |
|
||||
| `value` | any | Readonly: current value |
|
||||
|
||||
**Methods:**
|
||||
- `set(newValue)` - update the value (triggers reactivity across all instances)
|
||||
|
||||
### Using PluginService API Directly
|
||||
|
||||
```qml
|
||||
import qs.Services
|
||||
|
||||
property int counter: PluginService.getGlobalVar("myPlugin", "counter", 0)
|
||||
|
||||
Connections {
|
||||
target: PluginService
|
||||
function onGlobalVarChanged(pluginId, varName) {
|
||||
if (pluginId === "myPlugin" && varName === "counter") {
|
||||
counter = PluginService.getGlobalVar("myPlugin", "counter", 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function increment() {
|
||||
var current = PluginService.getGlobalVar("myPlugin", "counter", 0)
|
||||
PluginService.setGlobalVar("myPlugin", "counter", current + 1)
|
||||
}
|
||||
```
|
||||
|
||||
## Decision Matrix
|
||||
|
||||
| Need | API | Persisted | Scope |
|
||||
|------|-----|-----------|-------|
|
||||
| User preferences (API keys, themes, intervals) | `savePluginData` / `loadPluginData` | Yes (settings.json) | Per plugin |
|
||||
| Runtime state (history, cache, counters) | `savePluginState` / `loadPluginState` | Yes (state file) | Per plugin |
|
||||
| Cross-instance sync (multi-monitor data) | `PluginGlobalVar` or `getGlobalVar`/`setGlobalVar` | No (runtime only) | All instances |
|
||||
| Quick reactive reads from settings | `pluginData` property | N/A (read-only) | Per instance |
|
||||
|
||||
## 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
|
||||
2. **Global vars are NOT persistent** - they reset when the shell restarts
|
||||
3. **State vs Data** - data is for user-facing settings, state is for internal runtime data
|
||||
4. **Null safety** - always check `pluginService` is not null before calling methods
|
||||
5. **Signal namespacing** - global var signals include `pluginId` to filter for your plugin
|
||||
6. **Performance** - global vars are efficient for frequent updates; settings writes are batched
|
||||
@@ -1,240 +0,0 @@
|
||||
# Desktop Plugin Guide
|
||||
|
||||
Desktop plugins are widgets that appear on the desktop background layer. They support drag-and-drop positioning and resize via corner handles.
|
||||
|
||||
## Base Component
|
||||
|
||||
Desktop widgets use a plain `Item` with injected properties:
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var pluginService: null
|
||||
property string pluginId: ""
|
||||
property bool editMode: false
|
||||
property real widgetWidth: 200
|
||||
property real widgetHeight: 200
|
||||
property real minWidth: 150
|
||||
property real minHeight: 150
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainer
|
||||
opacity: 0.85
|
||||
|
||||
// Your content here
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Injected Properties
|
||||
|
||||
These are set automatically by the DesktopPluginWrapper:
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `pluginService` | object | PluginService reference for data persistence |
|
||||
| `pluginId` | string | Plugin's unique identifier |
|
||||
| `editMode` | bool | `true` when user is dragging/resizing |
|
||||
| `widgetWidth` | real | Current widget container width |
|
||||
| `widgetHeight` | real | Current widget container height |
|
||||
|
||||
## Optional Properties
|
||||
|
||||
Define these on your root item to customize behavior:
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `minWidth` | real | 100 | Minimum allowed width during resize |
|
||||
| `minHeight` | real | 100 | Minimum allowed height during resize |
|
||||
|
||||
## Position and Size Persistence
|
||||
|
||||
Position (`desktopX`, `desktopY`) and size (`desktopWidth`, `desktopHeight`) are automatically managed by the DesktopPluginWrapper. You do not need to handle persistence for positioning.
|
||||
|
||||
## Edit Mode
|
||||
|
||||
When `editMode` is true, the user is repositioning or resizing. Use this to:
|
||||
- Show visual indicators (borders, handles)
|
||||
- Disable interactive elements to prevent accidental actions
|
||||
- Display additional controls
|
||||
|
||||
```qml
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
border.color: root.editMode ? Theme.primary : "transparent"
|
||||
border.width: root.editMode ? 2 : 0
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: !root.editMode
|
||||
onClicked: doSomething()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Loading and Saving Data
|
||||
|
||||
Use the injected `pluginService` for data persistence:
|
||||
|
||||
```qml
|
||||
property string displayMode: {
|
||||
if (!pluginService) return "default"
|
||||
return pluginService.loadPluginData(pluginId, "displayMode", "default")
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: pluginService
|
||||
function onPluginDataChanged(changedId) {
|
||||
if (changedId !== pluginId) return
|
||||
root.displayMode = pluginService.loadPluginData(pluginId, "displayMode", "default")
|
||||
}
|
||||
}
|
||||
|
||||
function saveMode(mode) {
|
||||
pluginService?.savePluginData(pluginId, "displayMode", mode)
|
||||
}
|
||||
```
|
||||
|
||||
## Settings Component
|
||||
|
||||
Desktop plugin settings use the same `PluginSettings` wrapper as other types:
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginSettings {
|
||||
pluginId: "myDesktopWidget"
|
||||
|
||||
SliderSetting {
|
||||
settingKey: "opacity"
|
||||
label: "Opacity"
|
||||
description: "Widget background opacity"
|
||||
defaultValue: 85
|
||||
minimum: 10
|
||||
maximum: 100
|
||||
unit: "%"
|
||||
}
|
||||
|
||||
SelectionSetting {
|
||||
settingKey: "style"
|
||||
label: "Display Style"
|
||||
options: [
|
||||
{ label: "Compact", value: "compact" },
|
||||
{ label: "Expanded", value: "expanded" }
|
||||
]
|
||||
defaultValue: "compact"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## User Interaction
|
||||
|
||||
Desktop widgets support:
|
||||
1. **Drag** - click and drag anywhere (in edit mode)
|
||||
2. **Resize** - drag bottom-right corner handle (in edit mode)
|
||||
3. **Edit mode toggle** - via the desktop edit button
|
||||
|
||||
## Complete Example
|
||||
|
||||
Based on the ExampleDesktopClock pattern:
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var pluginService: null
|
||||
property string pluginId: ""
|
||||
property bool editMode: false
|
||||
property real widgetWidth: 250
|
||||
property real widgetHeight: 250
|
||||
property real minWidth: 150
|
||||
property real minHeight: 150
|
||||
|
||||
property string clockStyle: {
|
||||
if (!pluginService) return "digital"
|
||||
return pluginService.loadPluginData(pluginId, "clockStyle", "digital")
|
||||
}
|
||||
|
||||
property real bgOpacity: {
|
||||
if (!pluginService) return 0.85
|
||||
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
|
||||
return val / 100
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: pluginService
|
||||
function onPluginDataChanged(changedId) {
|
||||
if (changedId !== pluginId) return
|
||||
clockStyle = pluginService.loadPluginData(pluginId, "clockStyle", "digital")
|
||||
var val = pluginService.loadPluginData(pluginId, "opacity", 85)
|
||||
bgOpacity = val / 100
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainer
|
||||
opacity: root.bgOpacity
|
||||
border.color: root.editMode ? Theme.primary : "transparent"
|
||||
border.width: root.editMode ? 2 : 0
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingS
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: Qt.formatTime(new Date(), "hh:mm:ss")
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: root.widgetWidth * 0.15
|
||||
font.weight: Font.Bold
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: Qt.formatDate(new Date(), "ddd, MMM d")
|
||||
color: Theme.onSurfaceVariant
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: root.widgetWidth = root.widgetWidth // force update
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Manifest Example
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "myDesktopClock",
|
||||
"name": "Desktop Clock",
|
||||
"description": "Analog and digital clock for the desktop",
|
||||
"version": "1.0.0",
|
||||
"author": "Developer",
|
||||
"type": "desktop",
|
||||
"capabilities": ["desktop-widget"],
|
||||
"component": "./ClockWidget.qml",
|
||||
"icon": "schedule",
|
||||
"settings": "./Settings.qml",
|
||||
"permissions": ["settings_read", "settings_write"]
|
||||
}
|
||||
```
|
||||
@@ -1,308 +0,0 @@
|
||||
# Launcher Plugin Guide
|
||||
|
||||
Launcher plugins extend the DMS launcher with custom searchable items and actions. They use trigger-based filtering and integrate directly into the app drawer.
|
||||
|
||||
## Base Component
|
||||
|
||||
Launchers use a plain `Item` (not PluginComponent):
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var pluginService: null
|
||||
property string trigger: "#"
|
||||
|
||||
signal itemsChanged()
|
||||
|
||||
function getItems(query) {
|
||||
// Return array of items
|
||||
return []
|
||||
}
|
||||
|
||||
function executeItem(item) {
|
||||
// Handle item selection
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Required Interface
|
||||
|
||||
| Member | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `pluginService` | property | Injected PluginService reference (declare as `null`) |
|
||||
| `trigger` | property | Trigger string for activation |
|
||||
| `itemsChanged` | signal | Emit when item list changes (triggers UI refresh) |
|
||||
| `getItems(query)` | function | Return array of items matching query |
|
||||
| `executeItem(item)` | function | Handle item selection |
|
||||
|
||||
## Item Structure
|
||||
|
||||
Each item returned by `getItems()`:
|
||||
|
||||
```javascript
|
||||
{
|
||||
name: "Item Display Name", // Required: shown in launcher
|
||||
icon: "material:star", // Optional: icon specification
|
||||
comment: "Description text", // Required: subtitle text
|
||||
action: "type:data", // Required: action identifier
|
||||
categories: ["MyPlugin"], // Required: array with plugin category
|
||||
imageUrl: "https://..." // Optional: image for tile view
|
||||
}
|
||||
```
|
||||
|
||||
## Icon Types
|
||||
|
||||
### 1. Material Design Icons
|
||||
|
||||
```javascript
|
||||
{ icon: "material:lightbulb" }
|
||||
{ icon: "material:terminal" }
|
||||
{ icon: "material:translate" }
|
||||
```
|
||||
|
||||
Uses the Material Symbols Rounded font.
|
||||
|
||||
### 2. Unicode / Emoji Icons
|
||||
|
||||
```javascript
|
||||
{ icon: "unicode:smile_face" }
|
||||
```
|
||||
|
||||
Rendered at 70-80% of icon size with theming.
|
||||
|
||||
### 3. Desktop Theme Icons
|
||||
|
||||
```javascript
|
||||
{ icon: "firefox" }
|
||||
{ icon: "folder" }
|
||||
```
|
||||
|
||||
Uses the user's installed icon theme.
|
||||
|
||||
### 4. No Icon
|
||||
|
||||
Omit the `icon` field entirely. The launcher hides the icon area and gives full width to the item name.
|
||||
|
||||
## Trigger System
|
||||
|
||||
**Custom trigger** (items only appear when trigger is typed):
|
||||
|
||||
```json
|
||||
{ "trigger": "#" }
|
||||
```
|
||||
|
||||
- Type `#` alone: shows all plugin items
|
||||
- Type `# query`: filters plugin items by query
|
||||
- The query string (without trigger) is passed to `getItems(query)`
|
||||
|
||||
**No trigger** (items always visible alongside regular apps):
|
||||
|
||||
```json
|
||||
{ "trigger": "" }
|
||||
```
|
||||
|
||||
Save empty trigger at runtime:
|
||||
```qml
|
||||
Component.onCompleted: {
|
||||
trigger = pluginService?.loadPluginData(pluginId, "trigger", "#") ?? "#"
|
||||
}
|
||||
```
|
||||
|
||||
## Action Execution
|
||||
|
||||
Parse action strings in `executeItem()`:
|
||||
|
||||
```qml
|
||||
function executeItem(item) {
|
||||
const actionParts = item.action.split(":")
|
||||
const actionType = actionParts[0]
|
||||
const actionData = actionParts.slice(1).join(":")
|
||||
|
||||
switch (actionType) {
|
||||
case "toast":
|
||||
ToastService?.showInfo(actionData)
|
||||
break
|
||||
case "copy":
|
||||
Quickshell.execDetached(["dms", "cl", "copy", actionData])
|
||||
ToastService?.showInfo("Copied to clipboard")
|
||||
break
|
||||
case "exec":
|
||||
Quickshell.execDetached(actionData.split(" "))
|
||||
break
|
||||
case "url":
|
||||
Quickshell.execDetached(["xdg-open", actionData])
|
||||
break
|
||||
default:
|
||||
console.warn("Unknown action type:", actionType)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Search / Filtering
|
||||
|
||||
The `query` parameter in `getItems()` contains the user's search text (without the trigger prefix).
|
||||
|
||||
```qml
|
||||
function getItems(query) {
|
||||
const allItems = [
|
||||
{ name: "Calculator", icon: "material:calculate",
|
||||
comment: "Open calculator", action: "exec:gnome-calculator",
|
||||
categories: ["Tools"] },
|
||||
{ name: "Terminal", icon: "material:terminal",
|
||||
comment: "Open terminal", action: "exec:alacritty",
|
||||
categories: ["Tools"] }
|
||||
]
|
||||
|
||||
if (!query || query.length === 0) return allItems
|
||||
|
||||
const q = query.toLowerCase()
|
||||
return allItems.filter(item =>
|
||||
item.name.toLowerCase().includes(q) ||
|
||||
item.comment.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Context Menu Actions
|
||||
|
||||
Add right-click actions to launcher items:
|
||||
|
||||
```qml
|
||||
function getContextMenuActions(item) {
|
||||
return [
|
||||
{ name: "Copy", icon: "material:content_copy",
|
||||
action: "copy:" + item.name },
|
||||
{ name: "Open in Browser", icon: "material:open_in_new",
|
||||
action: "url:" + item.url }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Context menu actions use the same `executeItem()` handler.
|
||||
|
||||
## Image Tile View
|
||||
|
||||
For image-heavy launchers (GIF search, sticker pickers), use tile view:
|
||||
|
||||
In `plugin.json`:
|
||||
```json
|
||||
{
|
||||
"viewMode": "tile",
|
||||
"viewModeEnforced": true
|
||||
}
|
||||
```
|
||||
|
||||
In items:
|
||||
```javascript
|
||||
{
|
||||
name: "Image Title",
|
||||
imageUrl: "https://example.com/image.png",
|
||||
comment: "Description",
|
||||
action: "copy:https://example.com/image.png",
|
||||
categories: ["MyPlugin"]
|
||||
}
|
||||
```
|
||||
|
||||
## State Persistence
|
||||
|
||||
For plugins with persistent state (notes, history, favorites):
|
||||
|
||||
```qml
|
||||
property var notes: []
|
||||
|
||||
Component.onCompleted: {
|
||||
const saved = pluginService?.loadPluginState(pluginId, "notes", [])
|
||||
if (saved) notes = saved
|
||||
}
|
||||
|
||||
function addNote(text) {
|
||||
notes.push({ text: text, timestamp: Date.now() })
|
||||
pluginService?.savePluginState(pluginId, "notes", notes)
|
||||
itemsChanged()
|
||||
}
|
||||
```
|
||||
|
||||
Use `savePluginState/loadPluginState` for runtime data and `savePluginData/loadPluginData` for user preferences.
|
||||
|
||||
## Settings for Trigger Configuration
|
||||
|
||||
Provide a PluginSettings component for trigger customization:
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginSettings {
|
||||
pluginId: "myLauncher"
|
||||
|
||||
StringSetting {
|
||||
settingKey: "trigger"
|
||||
label: "Trigger"
|
||||
description: "Type this prefix to activate the launcher plugin"
|
||||
placeholder: "#"
|
||||
defaultValue: "#"
|
||||
}
|
||||
|
||||
ToggleSetting {
|
||||
settingKey: "noTrigger"
|
||||
label: "Always Visible"
|
||||
description: "Show items alongside regular apps (no trigger needed)"
|
||||
defaultValue: false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var pluginService: null
|
||||
property string trigger: "!"
|
||||
|
||||
signal itemsChanged()
|
||||
|
||||
property var commands: [
|
||||
{ name: "Lock Screen", icon: "material:lock",
|
||||
comment: "Lock the session", action: "exec:loginctl lock-session" },
|
||||
{ name: "Screenshot", icon: "material:screenshot_monitor",
|
||||
comment: "Take a screenshot", action: "exec:grim" },
|
||||
{ name: "File Manager", icon: "material:folder",
|
||||
comment: "Open file manager", action: "exec:nautilus" }
|
||||
]
|
||||
|
||||
function getItems(query) {
|
||||
if (!query) return commands
|
||||
const q = query.toLowerCase()
|
||||
return commands.filter(c =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.comment.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
function executeItem(item) {
|
||||
const [type, ...rest] = item.action.split(":")
|
||||
const data = rest.join(":")
|
||||
if (type === "exec") {
|
||||
Quickshell.execDetached(data.split(" "))
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (pluginService) {
|
||||
trigger = pluginService.loadPluginData("quickCommands", "trigger", "!")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,188 +0,0 @@
|
||||
# Plugin Manifest Reference (plugin.json)
|
||||
|
||||
## Required Fields
|
||||
|
||||
| Field | Type | Description | Validation |
|
||||
|-------|------|-------------|------------|
|
||||
| `id` | string | Unique plugin identifier | camelCase, pattern `^[a-zA-Z][a-zA-Z0-9]*$` |
|
||||
| `name` | string | Human-readable name | Non-empty |
|
||||
| `description` | string | Short description (shown in UI) | Non-empty |
|
||||
| `version` | string | Semantic version | Pattern `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$` |
|
||||
| `author` | string | Creator name or email | Non-empty |
|
||||
| `type` | string | Plugin type | One of: `widget`, `daemon`, `launcher`, `desktop`, `composite` |
|
||||
| `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` |
|
||||
|
||||
## 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
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `icon` | string | Material Design icon name (displayed in plugin list UI) |
|
||||
| `settings` | string | Path to settings QML file (must start with `./`, end with `.qml`) |
|
||||
| `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` |
|
||||
| `permissions` | array | Required permissions |
|
||||
| `trigger` | string | Launcher trigger string (required for launcher type) |
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | Description | Enforced |
|
||||
|------------|-------------|----------|
|
||||
| `settings_read` | Read plugin configuration | No (not currently enforced) |
|
||||
| `settings_write` | Write plugin configuration / use PluginSettings | **Yes** |
|
||||
| `process` | Execute system commands | No (not currently enforced) |
|
||||
| `network` | Network access | No (not currently enforced) |
|
||||
|
||||
If your plugin has a `settings` component but does not declare `settings_write`, users will see an error instead of the settings UI.
|
||||
|
||||
## Capabilities
|
||||
|
||||
Capabilities are free-form strings that describe what the plugin does. Common values:
|
||||
|
||||
- `dankbar-widget` - general bar widget
|
||||
- `control-center` - integrates with Control Center
|
||||
- `monitoring` - system/service monitoring
|
||||
- `launcher` - launcher search provider
|
||||
- `desktop-widget` - desktop background widget
|
||||
- `ai` - AI/LLM integration
|
||||
- `slideout` - uses slideout panel
|
||||
|
||||
## 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
|
||||
{
|
||||
"id": "myPlugin",
|
||||
"name": "My Plugin",
|
||||
"description": "A sample plugin demonstrating all fields",
|
||||
"version": "1.0.0",
|
||||
"author": "Developer Name",
|
||||
"type": "widget",
|
||||
"capabilities": ["dankbar-widget", "control-center"],
|
||||
"component": "./MyWidget.qml",
|
||||
"icon": "extension",
|
||||
"settings": "./Settings.qml",
|
||||
"startupCheck": "./StartupCheck.qml",
|
||||
"requires_dms": ">=0.1.18",
|
||||
"dependencies": ["curl", "jq"],
|
||||
"permissions": ["settings_read", "settings_write", "process", "network"]
|
||||
}
|
||||
```
|
||||
|
||||
## Launcher Example
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "myLauncher",
|
||||
"name": "My Launcher",
|
||||
"description": "Search and execute custom actions",
|
||||
"version": "1.0.0",
|
||||
"author": "Developer Name",
|
||||
"type": "launcher",
|
||||
"capabilities": ["launcher"],
|
||||
"component": "./MyLauncher.qml",
|
||||
"trigger": "#",
|
||||
"icon": "search",
|
||||
"settings": "./Settings.qml",
|
||||
"requires_dms": ">=0.1.18",
|
||||
"permissions": ["settings_read", "settings_write"]
|
||||
}
|
||||
```
|
||||
|
||||
## JSON Schema
|
||||
|
||||
The complete JSON schema is available at `assets/plugin-schema.json` in this skill. Validate with:
|
||||
|
||||
```bash
|
||||
# Using python
|
||||
python3 -c "
|
||||
import json, jsonschema
|
||||
schema = json.load(open('path/to/plugin-schema.json'))
|
||||
manifest = json.load(open('plugin.json'))
|
||||
jsonschema.validate(manifest, schema)
|
||||
print('Valid!')
|
||||
"
|
||||
|
||||
# Using jq (syntax check only)
|
||||
jq . plugin.json
|
||||
```
|
||||
|
||||
## Additional Properties
|
||||
|
||||
The schema allows additional properties (`"additionalProperties": true`), so plugins can include custom fields. Common custom fields seen in production plugins:
|
||||
|
||||
- `viewMode` - launcher display mode (`"tile"` for image grids)
|
||||
- `viewModeEnforced` - lock launcher to specific view mode (`true`/`false`)
|
||||
@@ -1,120 +0,0 @@
|
||||
# PopoutService Reference
|
||||
|
||||
The `PopoutService` singleton lets plugins control all DMS popouts and modals. It is automatically injected into widget, daemon, and settings components.
|
||||
|
||||
## Setup
|
||||
|
||||
Declare the property in your component for injection to work:
|
||||
|
||||
```qml
|
||||
property var popoutService: null
|
||||
```
|
||||
|
||||
Without this declaration, injection fails with: `Cannot assign to non-existent property "popoutService"`
|
||||
|
||||
## Popouts (DankPopout-based)
|
||||
|
||||
| Component | Open | Close | Toggle |
|
||||
|-----------|------|-------|--------|
|
||||
| Control Center | `openControlCenter()` | `closeControlCenter()` | `toggleControlCenter()` |
|
||||
| Notification Center | `openNotificationCenter()` | `closeNotificationCenter()` | `toggleNotificationCenter()` |
|
||||
| App Drawer | `openAppDrawer()` | `closeAppDrawer()` | `toggleAppDrawer()` |
|
||||
| Process List | `openProcessList()` | `closeProcessList()` | `toggleProcessList()` |
|
||||
| DankDash | `openDankDash(tab)` | `closeDankDash()` | `toggleDankDash(tab)` |
|
||||
| Battery | `openBattery()` | `closeBattery()` | `toggleBattery()` |
|
||||
| VPN | `openVpn()` | `closeVpn()` | `toggleVpn()` |
|
||||
| System Update | `openSystemUpdate()` | `closeSystemUpdate()` | `toggleSystemUpdate()` |
|
||||
|
||||
## Modals (DankModal-based)
|
||||
|
||||
| Modal | Show | Hide | Notes |
|
||||
|-------|------|------|-------|
|
||||
| Settings | `openSettings()` | `closeSettings()` | Full settings interface |
|
||||
| Clipboard History | `openClipboardHistory()` | `closeClipboardHistory()` | Clipboard integration |
|
||||
| Launcher | `openDankLauncherV2()` | `closeDankLauncherV2()` | Also has `toggleDankLauncherV2()` |
|
||||
| Power Menu | `openPowerMenu()` | `closePowerMenu()` | Also has `togglePowerMenu()` |
|
||||
| Process List Modal | `showProcessListModal()` | `hideProcessListModal()` | Has `toggleProcessListModal()` |
|
||||
| Color Picker | `showColorPicker()` | `hideColorPicker()` | Theme color selection |
|
||||
| Notification | `showNotificationModal()` | `hideNotificationModal()` | Notification details |
|
||||
| WiFi Password | `showWifiPasswordModal()` | `hideWifiPasswordModal()` | Network auth |
|
||||
| Network Info | `showNetworkInfoModal()` | `hideNetworkInfoModal()` | Network details |
|
||||
|
||||
## Slideouts
|
||||
|
||||
| Component | Open | Close | Toggle |
|
||||
|-----------|------|-------|--------|
|
||||
| Notepad | `openNotepad()` | `closeNotepad()` | `toggleNotepad()` |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Simple toggle
|
||||
|
||||
```qml
|
||||
MouseArea {
|
||||
onClicked: popoutService?.toggleControlCenter()
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional popout
|
||||
|
||||
```qml
|
||||
Connections {
|
||||
target: BatteryService
|
||||
function onPercentageChanged() {
|
||||
if (BatteryService.percentage < 10 && !BatteryService.isCharging) {
|
||||
popoutService?.openBattery()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Context menu with multiple actions
|
||||
|
||||
```qml
|
||||
MouseArea {
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onClicked: (mouse) => {
|
||||
if (mouse.button === Qt.RightButton) contextMenu.popup()
|
||||
else popoutService?.toggleControlCenter()
|
||||
}
|
||||
}
|
||||
|
||||
Menu {
|
||||
id: contextMenu
|
||||
MenuItem { text: "Settings"; onClicked: popoutService?.openSettings() }
|
||||
MenuItem { text: "Notifications"; onClicked: popoutService?.toggleNotificationCenter() }
|
||||
MenuItem { text: "Power"; onClicked: popoutService?.openPowerMenu() }
|
||||
}
|
||||
```
|
||||
|
||||
### Position-aware toggle (from bar pill)
|
||||
|
||||
Some toggle functions accept position parameters for proper popout placement:
|
||||
|
||||
```qml
|
||||
pillClickAction: (x, y, width, section, screen) => {
|
||||
popoutService?.toggleControlCenter(x, y, width, section, screen)
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use optional chaining** (`?.`) - the service may not be injected yet
|
||||
2. **Check feature availability** before opening feature-specific popouts:
|
||||
```qml
|
||||
if (BatteryService.batteryAvailable) {
|
||||
popoutService?.openBattery()
|
||||
}
|
||||
```
|
||||
3. **Lazy loading** - first access may activate lazy loaders; this is normal
|
||||
4. **Popouts are shared** - avoid opening conflicting popouts simultaneously
|
||||
5. **User intent** - only trigger popouts from user actions or critical system events
|
||||
6. **Multi-monitor** - positioned popouts are screen-aware when using position parameters
|
||||
|
||||
## Injection Locations
|
||||
|
||||
The service is injected at these points:
|
||||
- `DMSShell.qml` - daemon plugins
|
||||
- `WidgetHost.qml` - widget plugins in left/right bar sections
|
||||
- `CenterSection.qml` - center bar widgets
|
||||
- `PluginsTab.qml` - settings components
|
||||
@@ -1,273 +0,0 @@
|
||||
# Settings Components Reference
|
||||
|
||||
All plugin settings use the `PluginSettings` wrapper. Setting components auto-save on change and auto-load on creation.
|
||||
|
||||
## PluginSettings Wrapper
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginSettings {
|
||||
pluginId: "yourPlugin" // Required: must match plugin.json id
|
||||
|
||||
// Setting components go here
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** The plugin must declare `"permissions": ["settings_write"]` in plugin.json for the settings UI to render. Without it, users see an error.
|
||||
|
||||
**PluginSettings provides to children:**
|
||||
- `saveValue(key, value)` - save a setting value
|
||||
- `loadValue(key, defaultValue)` - load a setting value
|
||||
- `saveState(key, value)` - save plugin state (separate file)
|
||||
- `loadState(key, defaultValue)` - load plugin state
|
||||
- `clearState()` - clear all plugin state
|
||||
- Variant management functions (for variant plugins)
|
||||
|
||||
## StringSetting
|
||||
|
||||
Text input field.
|
||||
|
||||
```qml
|
||||
StringSetting {
|
||||
settingKey: "apiKey" // Required: storage key
|
||||
label: "API Key" // Required: display label
|
||||
description: "Your API key" // Optional: help text
|
||||
placeholder: "sk-..." // Optional: input placeholder
|
||||
defaultValue: "" // Optional: default (default: "")
|
||||
}
|
||||
```
|
||||
|
||||
**Layout:** Vertical stack - label, description, input field.
|
||||
|
||||
## ToggleSetting
|
||||
|
||||
Boolean toggle switch.
|
||||
|
||||
```qml
|
||||
ToggleSetting {
|
||||
settingKey: "notifications" // Required: storage key
|
||||
label: "Enable Notifications" // Required: display label
|
||||
description: "Show alerts" // Optional: help text
|
||||
defaultValue: true // Optional: default (default: false)
|
||||
}
|
||||
```
|
||||
|
||||
**Layout:** Horizontal - label/description on left, toggle on right.
|
||||
|
||||
## SelectionSetting
|
||||
|
||||
Dropdown menu.
|
||||
|
||||
```qml
|
||||
SelectionSetting {
|
||||
settingKey: "theme" // Required: storage key
|
||||
label: "Theme" // Required: display label
|
||||
description: "Color scheme" // Optional: help text
|
||||
options: [ // Required: array of options
|
||||
{ label: "Dark", value: "dark" },
|
||||
{ label: "Light", value: "light" },
|
||||
{ label: "Auto", value: "auto" }
|
||||
]
|
||||
defaultValue: "dark" // Optional: default value
|
||||
}
|
||||
```
|
||||
|
||||
Options can be `{ label, value }` objects or simple strings. Stores the `value` field, displays the `label` field.
|
||||
|
||||
**Layout:** Horizontal - label/description on left, dropdown on right.
|
||||
|
||||
**Reacting to changes:**
|
||||
```qml
|
||||
SelectionSetting {
|
||||
settingKey: "updateInterval"
|
||||
label: "Update Interval"
|
||||
options: [
|
||||
{ label: "1 minute", value: "60" },
|
||||
{ label: "5 minutes", value: "300" }
|
||||
]
|
||||
defaultValue: "300"
|
||||
onValueChanged: (newValue) => {
|
||||
console.log("Interval changed to:", newValue)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## SliderSetting
|
||||
|
||||
Numeric slider with min/max.
|
||||
|
||||
```qml
|
||||
SliderSetting {
|
||||
settingKey: "opacity" // Required: storage key
|
||||
label: "Opacity" // Required: display label
|
||||
description: "Background" // Optional: help text
|
||||
defaultValue: 85 // Optional: default value
|
||||
minimum: 0 // Required: min value
|
||||
maximum: 100 // Required: max value
|
||||
unit: "%" // Optional: unit label shown after value
|
||||
leftIcon: "dark_mode" // Optional: Material icon on left
|
||||
rightIcon: "light_mode" // Optional: Material icon on right
|
||||
}
|
||||
```
|
||||
|
||||
## ColorSetting
|
||||
|
||||
Color picker.
|
||||
|
||||
```qml
|
||||
ColorSetting {
|
||||
settingKey: "accentColor" // Required: storage key
|
||||
label: "Accent Color" // Required: display label
|
||||
description: "Custom accent" // Optional: help text
|
||||
defaultValue: "#ff5722" // Optional: default hex color
|
||||
}
|
||||
```
|
||||
|
||||
Displays a color swatch that opens a color picker dialog.
|
||||
|
||||
## ListSetting
|
||||
|
||||
Manage a list of items with manual add/remove. Use when you need custom UI for adding items.
|
||||
|
||||
```qml
|
||||
ListSetting {
|
||||
id: itemList
|
||||
settingKey: "items" // Required: storage key
|
||||
label: "Saved Items" // Required: display label
|
||||
description: "Your items" // Optional: help text
|
||||
defaultValue: [] // Optional: default array
|
||||
delegate: Component { // Optional: custom item display
|
||||
StyledRect {
|
||||
width: parent.width
|
||||
height: 40
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
StyledText {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: modelData.name
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 60
|
||||
height: 28
|
||||
color: removeArea.containsMouse ? Theme.errorHover : Theme.error
|
||||
radius: Theme.cornerRadius
|
||||
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
text: "Remove"
|
||||
color: Theme.errorText
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: removeArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: itemList.removeItem(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Methods:**
|
||||
- `addItem(item)` - add an item to the list
|
||||
- `removeItem(index)` - remove item at index
|
||||
|
||||
## ListSettingWithInput
|
||||
|
||||
Complete list management with built-in form. Best for collecting structured data.
|
||||
|
||||
```qml
|
||||
ListSettingWithInput {
|
||||
settingKey: "locations" // Required: storage key
|
||||
label: "Locations" // Required: display label
|
||||
description: "Track zones" // Optional: help text
|
||||
defaultValue: [] // Optional: default array
|
||||
fields: [ // Required: field definitions
|
||||
{
|
||||
id: "name", // Required: key in saved object
|
||||
label: "Name", // Required: column header
|
||||
placeholder: "Home", // Optional: input placeholder
|
||||
width: 150, // Optional: column width (default: 200)
|
||||
required: true // Optional: must have value to add
|
||||
},
|
||||
{
|
||||
id: "timezone",
|
||||
label: "Timezone",
|
||||
placeholder: "America/New_York",
|
||||
width: 200,
|
||||
required: true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Automatically generates: column headers, input fields, add button with validation, list display, remove buttons.
|
||||
|
||||
## Mixing Custom UI with Settings
|
||||
|
||||
You can interleave regular QML elements with setting components:
|
||||
|
||||
```qml
|
||||
PluginSettings {
|
||||
pluginId: "myPlugin"
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: "General Settings"
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.Bold
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
StringSetting {
|
||||
settingKey: "name"
|
||||
label: "Display Name"
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: "Advanced Settings"
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.Bold
|
||||
color: Theme.surfaceText
|
||||
topPadding: Theme.spacingL
|
||||
}
|
||||
|
||||
ToggleSetting {
|
||||
settingKey: "debug"
|
||||
label: "Debug Mode"
|
||||
defaultValue: false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Default Values
|
||||
|
||||
Define sensible defaults in every setting component. The default is used when no saved value exists:
|
||||
|
||||
```qml
|
||||
StringSetting { settingKey: "text"; defaultValue: "Hello" }
|
||||
ToggleSetting { settingKey: "enabled"; defaultValue: true }
|
||||
SelectionSetting { settingKey: "mode"; defaultValue: "auto" }
|
||||
SliderSetting { settingKey: "opacity"; defaultValue: 85 }
|
||||
ColorSetting { settingKey: "color"; defaultValue: "#ff5722" }
|
||||
ListSetting { settingKey: "items"; defaultValue: [] }
|
||||
ListSettingWithInput { settingKey: "data"; defaultValue: [] }
|
||||
```
|
||||
@@ -1,216 +0,0 @@
|
||||
# Theme Property Reference
|
||||
|
||||
All theme properties are accessed via the `Theme` singleton from `qs.Common`. Always use these instead of hardcoded values.
|
||||
|
||||
## Font Sizes
|
||||
|
||||
```qml
|
||||
Theme.fontSizeSmall // 12px (scaled by SettingsData.fontScale)
|
||||
Theme.fontSizeMedium // 14px (scaled)
|
||||
Theme.fontSizeLarge // 16px (scaled)
|
||||
Theme.fontSizeXLarge // 20px (scaled)
|
||||
```
|
||||
|
||||
## Icon Sizes
|
||||
|
||||
```qml
|
||||
Theme.iconSizeSmall // 16px
|
||||
Theme.iconSize // 24px (default)
|
||||
Theme.iconSizeLarge // 32px
|
||||
```
|
||||
|
||||
## Spacing
|
||||
|
||||
```qml
|
||||
Theme.spacingXS // Extra small
|
||||
Theme.spacingS // Small
|
||||
Theme.spacingM // Medium
|
||||
Theme.spacingL // Large
|
||||
Theme.spacingXL // Extra large
|
||||
```
|
||||
|
||||
## Border Radius
|
||||
|
||||
```qml
|
||||
Theme.cornerRadius // Standard
|
||||
Theme.cornerRadiusSmall // Smaller
|
||||
Theme.cornerRadiusLarge // Larger
|
||||
```
|
||||
|
||||
## Surface Colors
|
||||
|
||||
```qml
|
||||
Theme.surface
|
||||
Theme.surfaceContainerLow
|
||||
Theme.surfaceContainer
|
||||
Theme.surfaceContainerHigh
|
||||
Theme.surfaceContainerHighest
|
||||
```
|
||||
|
||||
## Text Colors
|
||||
|
||||
```qml
|
||||
Theme.onSurface // Primary text on surface
|
||||
Theme.onSurfaceVariant // Secondary text on surface
|
||||
Theme.surfaceText // Alias for primary surface text
|
||||
Theme.surfaceVariantText // Alias for secondary surface text
|
||||
Theme.outline // Border/divider color
|
||||
```
|
||||
|
||||
## Semantic Colors
|
||||
|
||||
```qml
|
||||
Theme.primary
|
||||
Theme.onPrimary
|
||||
Theme.secondary
|
||||
Theme.onSecondary
|
||||
Theme.error
|
||||
Theme.errorHover
|
||||
Theme.errorText
|
||||
Theme.warning
|
||||
Theme.success
|
||||
```
|
||||
|
||||
## Special Functions
|
||||
|
||||
```qml
|
||||
Theme.popupBackground() // Popup background with proper opacity
|
||||
```
|
||||
|
||||
## Common Widget Patterns
|
||||
|
||||
### Icon with Text
|
||||
|
||||
```qml
|
||||
import qs.Widgets
|
||||
|
||||
Row {
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: "icon_name"
|
||||
color: Theme.onSurface
|
||||
font.pixelSize: Theme.iconSize
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: "Label"
|
||||
color: Theme.onSurface
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Container with Border
|
||||
|
||||
```qml
|
||||
Rectangle {
|
||||
color: Theme.surfaceContainerHigh
|
||||
radius: Theme.cornerRadius
|
||||
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
|
||||
border.width: 1
|
||||
}
|
||||
```
|
||||
|
||||
### Hover Effect
|
||||
|
||||
```qml
|
||||
Rectangle {
|
||||
id: container
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onEntered: container.color = Qt.lighter(Theme.surfaceContainerHigh, 1.1)
|
||||
onExited: container.color = Theme.surfaceContainerHigh
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Clickable Pill
|
||||
|
||||
```qml
|
||||
StyledRect {
|
||||
width: content.implicitWidth + Theme.spacingM * 2
|
||||
height: parent.widgetThickness
|
||||
radius: Theme.cornerRadius
|
||||
color: mouseArea.containsMouse
|
||||
? Qt.lighter(Theme.surfaceContainerHigh, 1.1)
|
||||
: Theme.surfaceContainerHigh
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
Row {
|
||||
id: content
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: "star"
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.iconSize
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: "Label"
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
**Wrong property names** (these do NOT exist):
|
||||
```qml
|
||||
Theme.fontSizeS // Use Theme.fontSizeSmall
|
||||
Theme.iconSizeS // Use Theme.iconSizeSmall
|
||||
Theme.spacingSmall // Use Theme.spacingS
|
||||
Theme.borderRadius // Use Theme.cornerRadius
|
||||
```
|
||||
|
||||
**Hardcoded values** (do NOT do this):
|
||||
```qml
|
||||
color: "#1e1e1e" // Use Theme.surfaceContainerHigh
|
||||
color: "white" // Use Theme.surfaceText
|
||||
font.pixelSize: 14 // Use Theme.fontSizeMedium
|
||||
```
|
||||
|
||||
## Available Widgets from qs.Widgets
|
||||
|
||||
| Widget | Description |
|
||||
|--------|-------------|
|
||||
| `StyledText` | Themed text with proper color defaults |
|
||||
| `StyledRect` | Themed rectangle |
|
||||
| `DankIcon` | Material Symbols icon renderer |
|
||||
| `DankNFIcon` | Nerd Font icon renderer |
|
||||
| `DankButton` | Themed button |
|
||||
| `DankToggle` | Toggle switch |
|
||||
| `DankTextField` | Text input field |
|
||||
| `DankSlider` | Slider control |
|
||||
| `DankDropdown` | Dropdown menu |
|
||||
| `DankGridView` | Grid layout view |
|
||||
| `DankListView` | List layout view |
|
||||
| `DankFlickable` | Scrollable container |
|
||||
| `DankTabBar` | Tab bar navigation |
|
||||
| `DankCollapsibleSection` | Collapsible content section |
|
||||
| `DankTooltip` | Hover tooltip |
|
||||
| `DankNumberStepper` | Number +/- control |
|
||||
| `DankFilterChips` | Filter chip row |
|
||||
| `CachingImage` | Image with disk cache |
|
||||
| `NumericText` | Fixed-width numeric display |
|
||||
|
||||
## Checking All Properties
|
||||
|
||||
```bash
|
||||
grep "property" Common/Theme.qml
|
||||
```
|
||||
@@ -1,371 +0,0 @@
|
||||
# Widget Plugin Guide
|
||||
|
||||
Widgets are bar plugins that display pills in DankBar, optionally open popouts, and can integrate with the Control Center.
|
||||
|
||||
## Base Component
|
||||
|
||||
Widgets use `PluginComponent` from `qs.Modules.Plugins`.
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginComponent {
|
||||
property var popoutService: null
|
||||
|
||||
horizontalBarPill: Component { /* ... */ }
|
||||
verticalBarPill: Component { /* ... */ }
|
||||
popoutContent: Component { /* ... */ }
|
||||
popoutWidth: 400
|
||||
popoutHeight: 300
|
||||
}
|
||||
```
|
||||
|
||||
## Injected Properties
|
||||
|
||||
These are automatically set by the plugin host:
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `axis` | object | Bar axis info (horizontal/vertical) |
|
||||
| `section` | string | Bar section: `"left"`, `"center"`, or `"right"` |
|
||||
| `parentScreen` | object | Screen reference for multi-monitor |
|
||||
| `widgetThickness` | real | Widget size perpendicular to bar edge |
|
||||
| `barThickness` | real | Bar thickness parallel to edge |
|
||||
| `pluginId` | string | This plugin's ID |
|
||||
| `pluginService` | object | PluginService reference |
|
||||
| `pluginData` | object | Reactive plugin settings data |
|
||||
|
||||
## Bar Pills
|
||||
|
||||
Define `horizontalBarPill` (for top/bottom bars) and `verticalBarPill` (for left/right bars).
|
||||
|
||||
### Horizontal Bar Pill
|
||||
|
||||
```qml
|
||||
horizontalBarPill: Component {
|
||||
StyledRect {
|
||||
width: content.implicitWidth + Theme.spacingM * 2
|
||||
height: parent.widgetThickness
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
Row {
|
||||
id: content
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: "star"
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.iconSize
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: "Label"
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Vertical Bar Pill
|
||||
|
||||
```qml
|
||||
verticalBarPill: Component {
|
||||
StyledRect {
|
||||
width: parent.widgetThickness
|
||||
height: content.implicitHeight + Theme.spacingM * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
Column {
|
||||
id: content
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: "star"
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.iconSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** Always define both pills. If a pill is missing, the widget disappears when the bar is on that orientation's edge.
|
||||
|
||||
## Popout Content
|
||||
|
||||
Open a popout window when the bar pill is clicked:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
popoutWidth: 400
|
||||
popoutHeight: 300
|
||||
|
||||
popoutContent: Component {
|
||||
PopoutComponent {
|
||||
headerText: "My Plugin"
|
||||
detailsText: "Optional subtitle"
|
||||
showCloseButton: true
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
StyledText {
|
||||
text: "Content here"
|
||||
color: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**PopoutComponent properties:**
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `headerText` | string | `""` | Main header (bold, large). Hidden if empty. |
|
||||
| `detailsText` | string | `""` | Subtitle below header. Hidden if empty. |
|
||||
| `showCloseButton` | bool | `false` | Show X button in top-right corner. |
|
||||
| `closePopout` | function | (injected) | Call to close the popout programmatically. |
|
||||
| `headerHeight` | int | (readonly) | Height of header area (0 if hidden). |
|
||||
| `detailsHeight` | int | (readonly) | Height of details area (0 if hidden). |
|
||||
|
||||
**Content sizing:** Content children render below the header/details. Calculate available height: `popoutHeight - headerHeight - detailsHeight - spacing`
|
||||
|
||||
## Custom Click Actions
|
||||
|
||||
Override the default popout behavior:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
// Simple no-args handler
|
||||
pillClickAction: () => {
|
||||
popoutService?.toggleControlCenter()
|
||||
}
|
||||
|
||||
// With position params (x, y, width, section, screen)
|
||||
pillClickAction: (x, y, width, section, screen) => {
|
||||
popoutService?.toggleControlCenter(x, y, width, section, screen)
|
||||
}
|
||||
|
||||
pillRightClickAction: () => {
|
||||
popoutService?.openSettings()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Control Center Integration
|
||||
|
||||
Add CC properties to show your widget in the Control Center grid:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
ccWidgetIcon: "toggle_on"
|
||||
ccWidgetPrimaryText: "Feature Name"
|
||||
ccWidgetSecondaryText: isActive ? "Active" : "Off"
|
||||
ccWidgetIsActive: isActive
|
||||
|
||||
onCcWidgetToggled: {
|
||||
isActive = !isActive
|
||||
pluginService?.savePluginData(pluginId, "active", isActive)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**CC properties:**
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `ccWidgetIcon` | string | Material icon name |
|
||||
| `ccWidgetPrimaryText` | string | Main label |
|
||||
| `ccWidgetSecondaryText` | string | Subtitle / status text |
|
||||
| `ccWidgetIsActive` | bool | Active state (changes styling) |
|
||||
|
||||
**CC signals:**
|
||||
|
||||
| Signal | When fired |
|
||||
|--------|-----------|
|
||||
| `ccWidgetToggled()` | Icon area clicked |
|
||||
| `ccWidgetExpanded()` | Expand area clicked (CompoundPill only) |
|
||||
|
||||
**CC sizing rules:**
|
||||
- 25% width - SmallToggleButton (icon only)
|
||||
- 50% width - ToggleButton (no detail) or CompoundPill (with ccDetailContent)
|
||||
- Users can resize in CC edit mode
|
||||
|
||||
### Detail Content (CompoundPill)
|
||||
|
||||
Add an expandable panel below the CC widget:
|
||||
|
||||
```qml
|
||||
ccDetailContent: Component {
|
||||
Rectangle {
|
||||
implicitHeight: 200
|
||||
color: Theme.surfaceContainerHigh
|
||||
radius: Theme.cornerRadius
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
spacing: Theme.spacingS
|
||||
|
||||
// Detail UI here
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Visibility Control
|
||||
|
||||
Conditionally show/hide the bar pill:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
visibilityCommand: "pgrep -x myapp"
|
||||
visibilityInterval: 5 // seconds between checks
|
||||
}
|
||||
```
|
||||
|
||||
**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:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
layerNamespacePlugin: true
|
||||
}
|
||||
```
|
||||
|
||||
## Reading Plugin Data
|
||||
|
||||
Access saved settings reactively via the injected `pluginData`:
|
||||
|
||||
```qml
|
||||
PluginComponent {
|
||||
property string displayText: pluginData?.text || "Default"
|
||||
|
||||
Connections {
|
||||
target: pluginService
|
||||
function onPluginDataChanged(changedId) {
|
||||
if (changedId === pluginId)
|
||||
displayText = pluginService.loadPluginData(pluginId, "text", "Default")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Based on the ExampleEmojiPlugin pattern:
|
||||
|
||||
```qml
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Services
|
||||
import qs.Modules.Plugins
|
||||
|
||||
PluginComponent {
|
||||
id: root
|
||||
property var popoutService: null
|
||||
|
||||
property var emojis: ["star", "heart", "smile"]
|
||||
property int currentIndex: 0
|
||||
|
||||
Timer {
|
||||
interval: 2000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: currentIndex = (currentIndex + 1) % emojis.length
|
||||
}
|
||||
|
||||
popoutWidth: 350
|
||||
popoutHeight: 400
|
||||
|
||||
horizontalBarPill: Component {
|
||||
StyledRect {
|
||||
width: label.implicitWidth + Theme.spacingM * 2
|
||||
height: parent.widgetThickness
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
StyledText {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: root.emojis[root.currentIndex]
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
verticalBarPill: Component {
|
||||
StyledRect {
|
||||
width: parent.widgetThickness
|
||||
height: label.implicitHeight + Theme.spacingM * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
StyledText {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: root.emojis[root.currentIndex]
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
popoutContent: Component {
|
||||
PopoutComponent {
|
||||
headerText: "Emoji Picker"
|
||||
showCloseButton: true
|
||||
|
||||
DankGridView {
|
||||
width: parent.width
|
||||
height: 300
|
||||
cellWidth: 50
|
||||
cellHeight: 50
|
||||
model: root.emojis
|
||||
|
||||
delegate: Rectangle {
|
||||
width: 48
|
||||
height: 48
|
||||
radius: Theme.cornerRadius
|
||||
color: mouseArea.containsMouse ? Theme.surfaceContainerHighest : "transparent"
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: modelData
|
||||
font.pixelSize: 24
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: {
|
||||
Quickshell.execDetached(["dms", "cl", "copy", modelData])
|
||||
ToastService?.showInfo("Copied " + modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -7,32 +7,32 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
## DankMaterialShell Bug Report
|
||||
Limit your report to one issue per submission unless similarly related
|
||||
- type: dropdown
|
||||
Limit your report to one issue per submission unless closely related
|
||||
- type: checkboxes
|
||||
id: compositor
|
||||
attributes:
|
||||
label: Compositor
|
||||
options:
|
||||
- Niri
|
||||
- Hyprland
|
||||
- MangoWC (dwl)
|
||||
- Sway
|
||||
- label: Niri
|
||||
- label: Hyprland
|
||||
- label: MangoWC (dwl)
|
||||
- label: Sway
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
- type: checkboxes
|
||||
id: distribution
|
||||
attributes:
|
||||
label: Distribution
|
||||
options:
|
||||
- Arch Linux
|
||||
- CachyOS
|
||||
- Fedora
|
||||
- NixOS
|
||||
- Debian
|
||||
- Ubuntu
|
||||
- Gentoo
|
||||
- OpenSUSE
|
||||
- Other (specify below)
|
||||
- label: Arch Linux
|
||||
- label: CachyOS
|
||||
- label: Fedora
|
||||
- label: NixOS
|
||||
- label: Debian
|
||||
- label: Ubuntu
|
||||
- label: Gentoo
|
||||
- label: OpenSUSE
|
||||
- label: Other (specify below)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
@@ -42,45 +42,12 @@ body:
|
||||
placeholder: e.g., PikaOS, Void Linux, etc.
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: installation_method
|
||||
attributes:
|
||||
label: Select your Installation Method
|
||||
options:
|
||||
- DankInstaller
|
||||
- Distro Packaging
|
||||
- Source
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: original_installation_method
|
||||
attributes:
|
||||
label: Was this your original Installation method?
|
||||
options:
|
||||
- "Yes"
|
||||
- No (specify below)
|
||||
default: 0
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: original_installation_method_specify
|
||||
attributes:
|
||||
label: If no, specify
|
||||
placeholder: e.g., Distro Packaging, then Source
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: dms_doctor
|
||||
attributes:
|
||||
label: dms doctor -vC
|
||||
description: Output of `dms doctor -vC` command — paste between the details tags below to keep it collapsed in the issue
|
||||
description: Output of `dms doctor -vC` command
|
||||
placeholder: Paste the output of `dms doctor -vC` here
|
||||
value: |
|
||||
<details>
|
||||
<summary>Click to expand</summary>
|
||||
|
||||
|
||||
</details>
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
@@ -102,7 +69,7 @@ body:
|
||||
- type: textarea
|
||||
id: steps_to_reproduce
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
label: Steps to Reproduce & Installation Method
|
||||
description: Please provide detailed steps to reproduce the issue
|
||||
placeholder: |
|
||||
1. ...
|
||||
|
||||
@@ -23,25 +23,18 @@ body:
|
||||
placeholder: Why is this feature important?
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
- type: checkboxes
|
||||
id: compositor
|
||||
attributes:
|
||||
label: Compositor(s)
|
||||
description: Is this feature specific to one or more compositors?
|
||||
options:
|
||||
- All compositors
|
||||
- Niri
|
||||
- Hyprland
|
||||
- MangoWC (dwl)
|
||||
- Sway
|
||||
- Other (specify below)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: compositor_other
|
||||
attributes:
|
||||
label: If Other, please specify
|
||||
placeholder: e.g., Wayfire, Mutter, etc.
|
||||
- label: All compositors
|
||||
- label: Niri
|
||||
- label: Hyprland
|
||||
- label: MangoWC (dwl)
|
||||
- label: Sway
|
||||
- label: Other (specify below)
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
|
||||
@@ -7,87 +7,32 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
## DankMaterialShell Support Request
|
||||
- type: dropdown
|
||||
- type: checkboxes
|
||||
id: compositor
|
||||
attributes:
|
||||
label: Compositor
|
||||
options:
|
||||
- Niri
|
||||
- Hyprland
|
||||
- MangoWC (dwl)
|
||||
- Sway
|
||||
- Other (specify below)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: compositor_other
|
||||
attributes:
|
||||
label: If Other, please specify
|
||||
placeholder: e.g., Wayfire, Mutter, etc.
|
||||
- label: Niri
|
||||
- label: Hyprland
|
||||
- label: MangoWC (dwl)
|
||||
- label: Sway
|
||||
- label: Other (specify below)
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
- type: input
|
||||
id: distribution
|
||||
attributes:
|
||||
label: Distribution
|
||||
options:
|
||||
- Arch Linux
|
||||
- CachyOS
|
||||
- Fedora
|
||||
- NixOS
|
||||
- Debian
|
||||
- Ubuntu
|
||||
- Gentoo
|
||||
- OpenSUSE
|
||||
- Other (specify below)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: distribution_other
|
||||
attributes:
|
||||
label: If Other, please specify
|
||||
placeholder: e.g., PikaOS, Void Linux, etc.
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: installation_method
|
||||
attributes:
|
||||
label: Select your Installation Method
|
||||
options:
|
||||
- DankInstaller
|
||||
- Distro Packaging
|
||||
- Source
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: original_installation_method_different
|
||||
attributes:
|
||||
label: Was your original Installation method different?
|
||||
options:
|
||||
- "Yes"
|
||||
- No (specify below)
|
||||
default: 0
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: original_installation_method_specify
|
||||
attributes:
|
||||
label: If no, specify
|
||||
placeholder: e.g., Distro Packaging, then Source
|
||||
description: Which Linux distribution are you using? (e.g., Arch, Fedora, Debian, etc.)
|
||||
placeholder: Your Linux distribution
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: dms_doctor
|
||||
attributes:
|
||||
label: dms doctor -vC
|
||||
description: Output of `dms doctor -vC` command — paste between the lines below to keep it collapsed in the issue
|
||||
description: Output of `dms doctor -vC` command
|
||||
placeholder: Paste the output of `dms doctor -vC` here
|
||||
value: |
|
||||
<details>
|
||||
<summary>Click to expand</summary>
|
||||
|
||||
|
||||
</details>
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
|
||||
@@ -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
|
||||
@@ -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: ["*"]
|
||||
@@ -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
|
||||
@@ -0,0 +1,383 @@
|
||||
name: Update OBS Packages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: "Package to update (dms, dms-git, or all)"
|
||||
required: false
|
||||
default: "all"
|
||||
force_upload:
|
||||
description: "Force upload without version check"
|
||||
required: false
|
||||
default: "false"
|
||||
type: choice
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
rebuild_release:
|
||||
description: "Release number for rebuilds (e.g., 2, 3, 4 to increment spec Release)"
|
||||
required: false
|
||||
default: ""
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
schedule:
|
||||
- cron: "0 */3 * * *" # Every 3 hours for dms-git builds
|
||||
|
||||
jobs:
|
||||
check-updates:
|
||||
name: Check for updates
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
has_updates: ${{ steps.check.outputs.has_updates }}
|
||||
packages: ${{ steps.check.outputs.packages }}
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install OSC
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y osc
|
||||
|
||||
mkdir -p ~/.config/osc
|
||||
cat > ~/.config/osc/oscrc << EOF
|
||||
[general]
|
||||
apiurl = https://api.opensuse.org
|
||||
|
||||
[https://api.opensuse.org]
|
||||
user = ${{ secrets.OBS_USERNAME }}
|
||||
pass = ${{ secrets.OBS_PASSWORD }}
|
||||
EOF
|
||||
chmod 600 ~/.config/osc/oscrc
|
||||
|
||||
- name: Check for updates
|
||||
id: check
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
echo "packages=dms" >> $GITHUB_OUTPUT
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "Triggered by tag: $VERSION (always update)"
|
||||
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
echo "packages=dms-git" >> $GITHUB_OUTPUT
|
||||
echo "Checking if dms-git source has changed..."
|
||||
|
||||
# Get current commit hash (8 chars to match spec format)
|
||||
CURRENT_COMMIT=$(git rev-parse --short=8 HEAD)
|
||||
|
||||
# Check OBS for last uploaded commit
|
||||
OBS_BASE="$HOME/.cache/osc-checkouts"
|
||||
mkdir -p "$OBS_BASE"
|
||||
OBS_PROJECT="home:AvengeMedia:dms-git"
|
||||
|
||||
if [[ -d "$OBS_BASE/$OBS_PROJECT/dms-git" ]]; then
|
||||
cd "$OBS_BASE/$OBS_PROJECT/dms-git"
|
||||
osc up -q 2>/dev/null || true
|
||||
|
||||
# Extract commit hash from spec Version line & format like; 0.6.2+git2264.a679be68
|
||||
if [[ -f "dms-git.spec" ]]; then
|
||||
OBS_COMMIT=$(grep "^Version:" "dms-git.spec" | grep -oP '\.[a-f0-9]{8}' | tr -d '.' || echo "")
|
||||
|
||||
if [[ -n "$OBS_COMMIT" ]]; then
|
||||
if [[ "$CURRENT_COMMIT" == "$OBS_COMMIT" ]]; then
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
echo "📋 Commit $CURRENT_COMMIT already uploaded to OBS, skipping"
|
||||
else
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "📋 New commit detected: $CURRENT_COMMIT (OBS has $OBS_COMMIT)"
|
||||
fi
|
||||
else
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "📋 Could not extract OBS commit, proceeding with update"
|
||||
fi
|
||||
else
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "📋 No spec file in OBS, proceeding with update"
|
||||
fi
|
||||
|
||||
cd "${{ github.workspace }}"
|
||||
else
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "📋 First upload to OBS, update needed"
|
||||
fi
|
||||
elif [[ "${{ github.event.inputs.force_upload }}" == "true" ]]; then
|
||||
PKG="${{ github.event.inputs.package }}"
|
||||
if [[ -z "$PKG" || "$PKG" == "all" ]]; then
|
||||
echo "packages=all" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "🚀 Force upload: all packages"
|
||||
else
|
||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "🚀 Force upload: $PKG"
|
||||
fi
|
||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||
echo "packages=${{ github.event.inputs.package }}" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "Manual trigger: ${{ github.event.inputs.package }}"
|
||||
else
|
||||
echo "packages=all" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
update-obs:
|
||||
name: Upload to OBS
|
||||
needs: check-updates
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
if: |
|
||||
github.event.inputs.force_upload == 'true' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.check-updates.outputs.has_updates == 'true'
|
||||
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: generate_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@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
- name: Check if last commit was automated
|
||||
id: check-loop
|
||||
run: |
|
||||
LAST_COMMIT_MSG=$(git log -1 --pretty=%B | head -1)
|
||||
if [[ "$LAST_COMMIT_MSG" == "ci: Auto-update PPA packages"* ]] || [[ "$LAST_COMMIT_MSG" == "ci: Auto-update OBS packages"* ]]; then
|
||||
echo "⏭️ Last commit was automated ($LAST_COMMIT_MSG), skipping to prevent infinite loop"
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "✅ Last commit was not automated, proceeding"
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Determine packages to update
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
id: packages
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
echo "packages=dms" >> $GITHUB_OUTPUT
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Triggered by tag: $VERSION"
|
||||
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
echo "Triggered by schedule: updating git package"
|
||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||
echo "packages=${{ github.event.inputs.package }}" >> $GITHUB_OUTPUT
|
||||
echo "Manual trigger: ${{ github.event.inputs.package }}"
|
||||
else
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Update dms-git spec version
|
||||
if: steps.check-loop.outputs.skip != 'true' && (contains(steps.packages.outputs.packages, 'dms-git') || steps.packages.outputs.packages == 'all')
|
||||
run: |
|
||||
# Get commit info for dms-git versioning
|
||||
COMMIT_HASH=$(git rev-parse --short=8 HEAD)
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
BASE_VERSION=$(grep -oP '^Version:\s+\K[0-9.]+' distro/opensuse/dms.spec | head -1 || echo "0.6.2")
|
||||
|
||||
NEW_VERSION="${BASE_VERSION}+git${COMMIT_COUNT}.${COMMIT_HASH}"
|
||||
echo "📦 Updating dms-git.spec to version: $NEW_VERSION"
|
||||
|
||||
# Update version in spec
|
||||
sed -i "s/^Version:.*/Version: $NEW_VERSION/" distro/opensuse/dms-git.spec
|
||||
|
||||
# Add changelog entry
|
||||
DATE_STR=$(date "+%a %b %d %Y")
|
||||
CHANGELOG_ENTRY="* $DATE_STR Avenge Media <AvengeMedia.US@gmail.com> - ${NEW_VERSION}-1\n- Git snapshot (commit $COMMIT_COUNT: $COMMIT_HASH)"
|
||||
sed -i "/%changelog/a\\$CHANGELOG_ENTRY" distro/opensuse/dms-git.spec
|
||||
|
||||
- name: Update Debian dms-git changelog version
|
||||
if: steps.check-loop.outputs.skip != 'true' && (contains(steps.packages.outputs.packages, 'dms-git') || steps.packages.outputs.packages == 'all')
|
||||
run: |
|
||||
# Get commit info for dms-git versioning
|
||||
COMMIT_HASH=$(git rev-parse --short=8 HEAD)
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
BASE_VERSION=$(grep -oP '^Version:\s+\K[0-9.]+' distro/opensuse/dms.spec | head -1 || echo "0.6.2")
|
||||
|
||||
# Debian version format: 0.6.2+git2256.9162e314
|
||||
NEW_VERSION="${BASE_VERSION}+git${COMMIT_COUNT}.${COMMIT_HASH}"
|
||||
echo "📦 Updating Debian dms-git changelog to version: $NEW_VERSION"
|
||||
|
||||
CHANGELOG_DATE=$(date -R)
|
||||
|
||||
CHANGELOG_FILE="distro/debian/dms-git/debian/changelog"
|
||||
|
||||
# Get current version from changelog
|
||||
CURRENT_VERSION=$(head -1 "$CHANGELOG_FILE" | sed 's/.*(\([^)]*\)).*/\1/')
|
||||
|
||||
echo "Current Debian version: $CURRENT_VERSION"
|
||||
echo "New version: $NEW_VERSION"
|
||||
|
||||
# Only update if version changed
|
||||
if [ "$CURRENT_VERSION" != "$NEW_VERSION" ]; then
|
||||
# Create new changelog entry at top
|
||||
TEMP_CHANGELOG=$(mktemp)
|
||||
|
||||
cat > "$TEMP_CHANGELOG" << EOF
|
||||
dms-git ($NEW_VERSION) nightly; urgency=medium
|
||||
|
||||
* Git snapshot (commit $COMMIT_COUNT: $COMMIT_HASH)
|
||||
|
||||
-- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE
|
||||
|
||||
EOF
|
||||
|
||||
# Prepend to existing changelog
|
||||
cat "$CHANGELOG_FILE" >> "$TEMP_CHANGELOG"
|
||||
mv "$TEMP_CHANGELOG" "$CHANGELOG_FILE"
|
||||
|
||||
echo "✓ Updated Debian changelog: $CURRENT_VERSION → $NEW_VERSION"
|
||||
else
|
||||
echo "✓ Debian changelog already at version $NEW_VERSION"
|
||||
fi
|
||||
|
||||
- name: Update dms stable version
|
||||
if: steps.check-loop.outputs.skip != 'true' && steps.packages.outputs.version != ''
|
||||
run: |
|
||||
VERSION="${{ steps.packages.outputs.version }}"
|
||||
VERSION_NO_V="${VERSION#v}"
|
||||
echo "Updating packaging to version $VERSION_NO_V"
|
||||
|
||||
# Update openSUSE dms spec (stable only)
|
||||
sed -i "s/^Version:.*/Version: $VERSION_NO_V/" distro/opensuse/dms.spec
|
||||
|
||||
# Update openSUSE spec changelog
|
||||
DATE_STR=$(date "+%a %b %d %Y")
|
||||
CHANGELOG_ENTRY="* $DATE_STR AvengeMedia <maintainer@avengemedia.com> - ${VERSION_NO_V}-1\\n- Update to stable $VERSION release\\n- Bug fixes and improvements"
|
||||
sed -i "/%changelog/a\\$CHANGELOG_ENTRY\\n" distro/opensuse/dms.spec
|
||||
|
||||
# Update Debian _service files (both tar_scm and download_url formats)
|
||||
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)
|
||||
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
|
||||
done
|
||||
|
||||
# Update Debian changelog for dms stable
|
||||
if [[ -f "distro/debian/dms/debian/changelog" ]]; then
|
||||
CHANGELOG_DATE=$(date -R)
|
||||
TEMP_CHANGELOG=$(mktemp)
|
||||
|
||||
cat > "$TEMP_CHANGELOG" << EOF
|
||||
dms ($VERSION_NO_V) stable; urgency=medium
|
||||
|
||||
* Update to $VERSION stable release
|
||||
* Bug fixes and improvements
|
||||
|
||||
-- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE
|
||||
|
||||
EOF
|
||||
|
||||
cat "distro/debian/dms/debian/changelog" >> "$TEMP_CHANGELOG"
|
||||
mv "$TEMP_CHANGELOG" "distro/debian/dms/debian/changelog"
|
||||
|
||||
echo "✓ Updated Debian changelog to $VERSION_NO_V"
|
||||
fi
|
||||
|
||||
- name: Install Go
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
|
||||
- name: Install OSC
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y osc
|
||||
|
||||
mkdir -p ~/.config/osc
|
||||
cat > ~/.config/osc/oscrc << EOF
|
||||
[general]
|
||||
apiurl = https://api.opensuse.org
|
||||
|
||||
[https://api.opensuse.org]
|
||||
user = ${{ secrets.OBS_USERNAME }}
|
||||
pass = ${{ secrets.OBS_PASSWORD }}
|
||||
EOF
|
||||
chmod 600 ~/.config/osc/oscrc
|
||||
|
||||
- name: Upload to OBS
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
env:
|
||||
FORCE_UPLOAD: ${{ github.event.inputs.force_upload }}
|
||||
REBUILD_RELEASE: ${{ github.event.inputs.rebuild_release }}
|
||||
run: |
|
||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
||||
MESSAGE="Automated update from GitHub Actions"
|
||||
|
||||
if [[ -n "${{ steps.packages.outputs.version }}" ]]; then
|
||||
MESSAGE="Update to ${{ steps.packages.outputs.version }}"
|
||||
fi
|
||||
|
||||
if [[ "$PACKAGES" == "all" ]]; then
|
||||
bash distro/scripts/obs-upload.sh dms "$MESSAGE"
|
||||
bash distro/scripts/obs-upload.sh dms-git "Automated git update"
|
||||
else
|
||||
bash distro/scripts/obs-upload.sh "$PACKAGES" "$MESSAGE"
|
||||
fi
|
||||
|
||||
- name: Get changed packages
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
id: changed-packages
|
||||
run: |
|
||||
# Check if there are any changes to commit
|
||||
if git diff --exit-code distro/debian/ distro/opensuse/ >/dev/null 2>&1; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
echo "📋 No changelog or spec changes to commit"
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
# Get list of changed packages for commit message
|
||||
CHANGED_DEB=$(git diff --name-only distro/debian/ 2>/dev/null | grep 'debian/changelog' | xargs dirname 2>/dev/null | xargs dirname 2>/dev/null | xargs basename 2>/dev/null | tr '\n' ', ' | sed 's/, $//' || echo "")
|
||||
CHANGED_SUSE=$(git diff --name-only distro/opensuse/ 2>/dev/null | grep '\.spec$' | sed 's|distro/opensuse/||' | sed 's/\.spec$//' | tr '\n' ', ' | sed 's/, $//' || echo "")
|
||||
|
||||
PKGS=$(echo "$CHANGED_DEB,$CHANGED_SUSE" | tr ',' '\n' | grep -v '^$' | sort -u | tr '\n' ',' | sed 's/,$//')
|
||||
echo "packages=$PKGS" >> $GITHUB_OUTPUT
|
||||
echo "📋 Changed packages: $PKGS"
|
||||
fi
|
||||
|
||||
- name: Commit packaging changes
|
||||
if: steps.check-loop.outputs.skip != 'true' && steps.changed-packages.outputs.has_changes == 'true'
|
||||
run: |
|
||||
git config user.name "dms-ci[bot]"
|
||||
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||
git add distro/debian/*/debian/changelog distro/opensuse/*.spec
|
||||
git commit -m "ci: Auto-update OBS packages [${{ steps.changed-packages.outputs.packages }}]" -m "🤖 Automated by GitHub Actions"
|
||||
git pull --rebase origin master
|
||||
git push
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "### OBS Package Update Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Packages**: ${{ steps.packages.outputs.packages }}" >> $GITHUB_STEP_SUMMARY
|
||||
if [[ -n "${{ steps.packages.outputs.version }}" ]]; then
|
||||
echo "- **Version**: ${{ steps.packages.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
if [[ "${{ needs.check-updates.outputs.has_updates }}" == "false" ]]; then
|
||||
echo "- **Status**: Skipped (no changes detected)" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "- **Project**: https://build.opensuse.org/project/show/home:AvengeMedia" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,298 @@
|
||||
name: Update PPA Packages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: "Package to upload (dms, dms-git, dms-greeter, or all)"
|
||||
required: false
|
||||
default: "dms-git"
|
||||
force_upload:
|
||||
description: "Force upload without version check"
|
||||
required: false
|
||||
default: "false"
|
||||
type: choice
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
rebuild_release:
|
||||
description: "Release number for rebuilds (e.g., 2, 3, 4 for ppa2, ppa3, ppa4)"
|
||||
required: false
|
||||
default: ""
|
||||
schedule:
|
||||
- cron: "0 */3 * * *" # Every 3 hours for dms-git builds
|
||||
|
||||
jobs:
|
||||
check-updates:
|
||||
name: Check for updates
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
has_updates: ${{ steps.check.outputs.has_updates }}
|
||||
packages: ${{ steps.check.outputs.packages }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check for updates
|
||||
id: check
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
echo "packages=dms-git" >> $GITHUB_OUTPUT
|
||||
echo "Checking if dms-git source has changed..."
|
||||
|
||||
# Get current commit hash (8 chars to match changelog format)
|
||||
CURRENT_COMMIT=$(git rev-parse --short=8 HEAD)
|
||||
|
||||
# Extract commit hash from changelog
|
||||
# Format: dms-git (0.6.2+git2264.c5c5ce84) questing; urgency=medium
|
||||
CHANGELOG_FILE="distro/ubuntu/dms-git/debian/changelog"
|
||||
|
||||
if [[ -f "$CHANGELOG_FILE" ]]; then
|
||||
CHANGELOG_COMMIT=$(head -1 "$CHANGELOG_FILE" | grep -oP '\.[a-f0-9]{8}' | tr -d '.' || echo "")
|
||||
|
||||
if [[ -n "$CHANGELOG_COMMIT" ]]; then
|
||||
if [[ "$CURRENT_COMMIT" == "$CHANGELOG_COMMIT" ]]; then
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
echo "📋 Commit $CURRENT_COMMIT already in changelog, skipping upload"
|
||||
else
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "📋 New commit detected: $CURRENT_COMMIT (changelog has $CHANGELOG_COMMIT)"
|
||||
fi
|
||||
else
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "📋 Could not extract commit from changelog, proceeding with upload"
|
||||
fi
|
||||
else
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "📋 No changelog file found, proceeding with upload"
|
||||
fi
|
||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||
echo "packages=${{ github.event.inputs.package }}" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "Manual trigger: ${{ github.event.inputs.package }}"
|
||||
else
|
||||
echo "packages=dms-git" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
upload-ppa:
|
||||
name: Upload to PPA
|
||||
needs: check-updates
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
if: |
|
||||
github.event.inputs.force_upload == 'true' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.check-updates.outputs.has_updates == 'true'
|
||||
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: generate_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@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
- name: Check if last commit was automated
|
||||
id: check-loop
|
||||
run: |
|
||||
LAST_COMMIT_MSG=$(git log -1 --pretty=%B | head -1)
|
||||
if [[ "$LAST_COMMIT_MSG" == "ci: Auto-update PPA packages"* ]] || [[ "$LAST_COMMIT_MSG" == "ci: Auto-update OBS packages"* ]]; then
|
||||
echo "⏭️ Last commit was automated ($LAST_COMMIT_MSG), skipping to prevent infinite loop"
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "✅ Last commit was not automated, proceeding"
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Set up Go
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache: false
|
||||
|
||||
- name: Install build dependencies
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
debhelper \
|
||||
devscripts \
|
||||
dput \
|
||||
lftp \
|
||||
build-essential \
|
||||
fakeroot \
|
||||
dpkg-dev
|
||||
|
||||
- name: Configure GPG
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
env:
|
||||
GPG_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
run: |
|
||||
echo "$GPG_KEY" | gpg --import
|
||||
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2)
|
||||
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> $GITHUB_ENV
|
||||
|
||||
- name: Determine packages to upload
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
id: packages
|
||||
run: |
|
||||
if [[ "${{ github.event.inputs.force_upload }}" == "true" ]]; then
|
||||
PKG="${{ github.event.inputs.package }}"
|
||||
if [[ -z "$PKG" || "$PKG" == "all" ]]; then
|
||||
echo "packages=all" >> $GITHUB_OUTPUT
|
||||
echo "🚀 Force upload: all packages"
|
||||
else
|
||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||
echo "🚀 Force upload: $PKG"
|
||||
fi
|
||||
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
echo "Triggered by schedule: uploading git package"
|
||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||
# Manual package selection should respect change detection
|
||||
SELECTED_PKG="${{ github.event.inputs.package }}"
|
||||
UPDATED_PKG="${{ needs.check-updates.outputs.packages }}"
|
||||
|
||||
# Check if manually selected package is in the updated list
|
||||
if [[ "$UPDATED_PKG" == *"$SELECTED_PKG"* ]] || [[ "$SELECTED_PKG" == "all" ]]; then
|
||||
echo "packages=$SELECTED_PKG" >> $GITHUB_OUTPUT
|
||||
echo "📦 Manual selection (has updates): $SELECTED_PKG"
|
||||
else
|
||||
echo "packages=" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ Manual selection '$SELECTED_PKG' has no updates - skipping (use force_upload to override)"
|
||||
fi
|
||||
else
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Upload to PPA
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
run: |
|
||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
||||
REBUILD_RELEASE="${{ github.event.inputs.rebuild_release }}"
|
||||
|
||||
if [[ -z "$PACKAGES" ]]; then
|
||||
echo "No packages selected for upload. Skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Build command arguments
|
||||
BUILD_ARGS=()
|
||||
if [[ -n "$REBUILD_RELEASE" ]]; then
|
||||
BUILD_ARGS+=("$REBUILD_RELEASE")
|
||||
echo "✓ Using rebuild release number: ppa$REBUILD_RELEASE"
|
||||
fi
|
||||
|
||||
if [[ "$PACKAGES" == "all" ]]; then
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Uploading dms to PPA..."
|
||||
if [ -n "$REBUILD_RELEASE" ]; then
|
||||
echo "🔄 Using rebuild release number: ppa$REBUILD_RELEASE"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
bash distro/scripts/ppa-upload.sh dms dms questing "${BUILD_ARGS[@]}"
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Uploading dms-git to PPA..."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
bash distro/scripts/ppa-upload.sh dms-git dms-git questing "${BUILD_ARGS[@]}"
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Uploading dms-greeter to PPA..."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
bash distro/scripts/ppa-upload.sh dms-greeter danklinux questing "${BUILD_ARGS[@]}"
|
||||
else
|
||||
# Map package to PPA name
|
||||
case "$PACKAGES" in
|
||||
dms)
|
||||
PPA_NAME="dms"
|
||||
;;
|
||||
dms-git)
|
||||
PPA_NAME="dms-git"
|
||||
;;
|
||||
dms-greeter)
|
||||
PPA_NAME="danklinux"
|
||||
;;
|
||||
*)
|
||||
PPA_NAME="$PACKAGES"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Uploading $PACKAGES to PPA..."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
bash distro/scripts/ppa-upload.sh "$PACKAGES" "$PPA_NAME" questing "${BUILD_ARGS[@]}"
|
||||
fi
|
||||
|
||||
- name: Get changed packages
|
||||
if: steps.check-loop.outputs.skip != 'true'
|
||||
id: changed-packages
|
||||
run: |
|
||||
# Check if there are any changelog changes to commit
|
||||
if git diff --exit-code distro/ubuntu/ >/dev/null 2>&1; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
echo "📋 No changelog changes to commit"
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
# Get list of changed packages for commit message (deduplicate)
|
||||
CHANGED=$(git diff --name-only distro/ubuntu/ | grep 'debian/changelog' | sed 's|/debian/changelog||' | xargs -I{} basename {} | sort -u | tr '\n' ',' | sed 's/,$//')
|
||||
echo "packages=$CHANGED" >> $GITHUB_OUTPUT
|
||||
echo "📋 Changed packages: $CHANGED"
|
||||
echo "📋 Debug - Changed files:"
|
||||
git diff --name-only distro/ubuntu/ | grep 'debian/changelog' || echo "No changelog files found"
|
||||
fi
|
||||
|
||||
- name: Commit changelog changes
|
||||
if: steps.check-loop.outputs.skip != 'true' && steps.changed-packages.outputs.has_changes == 'true'
|
||||
run: |
|
||||
git config user.name "dms-ci[bot]"
|
||||
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||
git add distro/ubuntu/*/debian/changelog
|
||||
git commit -m "ci: Auto-update PPA packages [${{ steps.changed-packages.outputs.packages }}]" -m "🤖 Automated by GitHub Actions"
|
||||
git pull --rebase origin master
|
||||
git push
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "### PPA Package Upload Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Packages**: ${{ steps.packages.outputs.packages }}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [[ "${{ needs.check-updates.outputs.has_updates }}" == "false" ]]; then
|
||||
echo "- **Status**: Skipped (no changes detected)" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
||||
if [[ "$PACKAGES" == "all" ]]; then
|
||||
echo "- **PPA dms**: https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **PPA dms-git**: https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **PPA danklinux**: https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages" >> $GITHUB_STEP_SUMMARY
|
||||
elif [[ "$PACKAGES" == "dms" ]]; then
|
||||
echo "- **PPA**: https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages" >> $GITHUB_STEP_SUMMARY
|
||||
elif [[ "$PACKAGES" == "dms-git" ]]; then
|
||||
echo "- **PPA**: https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages" >> $GITHUB_STEP_SUMMARY
|
||||
elif [[ "$PACKAGES" == "dms-greeter" ]]; then
|
||||
echo "- **PPA**: https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ -n "${{ steps.packages.outputs.version }}" ]]; then
|
||||
echo "- **Version**: ${{ steps.packages.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Builds will appear once Launchpad processes the uploads." >> $GITHUB_STEP_SUMMARY
|
||||
@@ -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 }}
|
||||
@@ -7,23 +7,20 @@ 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 }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app_token.outputs.token }}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -26,9 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install flatpak
|
||||
run: sudo apt update && sudo apt install -y flatpak
|
||||
@@ -40,7 +38,7 @@ jobs:
|
||||
run: sudo flatpak install -y org.freedesktop.Platform/x86_64/24.08 app.zen_browser.zen
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: ./core/go.mod
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,41 +1,23 @@
|
||||
name: Nix flake and NixOS tests
|
||||
name: Check nix flake
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master, main, "stable-*"]
|
||||
branches: [master, main]
|
||||
paths:
|
||||
- "flake.*"
|
||||
- "distro/nix/**"
|
||||
jobs:
|
||||
check-flake:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
enable_kvm: true
|
||||
extra_nix_config: |
|
||||
system-features = nixos-test benchmark big-parallel kvm
|
||||
|
||||
- name: Check the flake
|
||||
run: nix flake check -L
|
||||
|
||||
- name: Run NixOS module test
|
||||
run: nix build .#nixosTests.x86_64-linux.nixos-module -L
|
||||
|
||||
- name: Run NixOS service start test
|
||||
run: nix build .#nixosTests.x86_64-linux.nixos-service-start-module -L
|
||||
|
||||
|
||||
- name: Run home-manager module test
|
||||
run: nix build .#nixosTests.x86_64-linux.home-manager-module -L
|
||||
|
||||
- name: Run niri home-manager module test
|
||||
run: nix build .#nixosTests.x86_64-linux.niri-home-module -L
|
||||
run: nix flake check
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install flatpak
|
||||
run: sudo apt update && sudo apt install -y flatpak
|
||||
@@ -23,9 +21,9 @@ jobs:
|
||||
run: sudo flatpak install -y org.freedesktop.Platform/x86_64/24.08 app.zen_browser.zen
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: core/go.mod
|
||||
|
||||
- name: run pre-commit hooks
|
||||
uses: j178/prek-action@v2
|
||||
uses: j178/prek-action@v1
|
||||
|
||||
+58
-138
@@ -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,24 +29,20 @@ 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
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
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'
|
||||
uses: actions/upload-artifact@v5
|
||||
- name: Upload artifacts (${{ matrix.arch }})
|
||||
if: matrix.arch == 'arm64'
|
||||
uses: actions/upload-artifact@v4
|
||||
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'
|
||||
uses: actions/upload-artifact@v5
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: actions/upload-artifact@v4
|
||||
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
|
||||
@@ -178,7 +147,7 @@ jobs:
|
||||
# private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
# - name: Checkout
|
||||
# uses: actions/checkout@v6
|
||||
# uses: actions/checkout@v4
|
||||
# with:
|
||||
# token: ${{ steps.app_token.outputs.token }}
|
||||
# fetch-depth: 0
|
||||
@@ -212,25 +181,23 @@ jobs:
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
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
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: ./core/go.mod
|
||||
|
||||
- name: Download core artifacts
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: core-assets-*
|
||||
merge-multiple: true
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -40,7 +46,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
@@ -128,7 +134,7 @@ jobs:
|
||||
rpm -qpi "$SRPM"
|
||||
|
||||
- name: Upload SRPM artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.package }}-stable-srpm-${{ steps.version.outputs.version }}
|
||||
path: ${{ steps.build.outputs.srpm_path }}
|
||||
@@ -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
|
||||
|
||||
+88
-136
@@ -23,7 +23,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 }}
|
||||
@@ -32,10 +31,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Check for updates
|
||||
id: check
|
||||
@@ -77,8 +75,8 @@ jobs:
|
||||
# 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
|
||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
# Tag selected or pushed - always update stable package
|
||||
echo "packages=dms" >> $GITHUB_OUTPUT
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
@@ -105,8 +103,9 @@ 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 package and build list of those needing updates
|
||||
PACKAGES_TO_UPDATE=()
|
||||
check_dms_git && PACKAGES_TO_UPDATE+=("dms-git")
|
||||
if check_dms_stable; then
|
||||
PACKAGES_TO_UPDATE+=("dms")
|
||||
if [[ -n "$LATEST_TAG" ]]; then
|
||||
@@ -165,19 +164,22 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Wait before OBS upload
|
||||
run: sleep 3
|
||||
|
||||
- name: Determine packages to update
|
||||
id: packages
|
||||
run: |
|
||||
# Use check-updates outputs when available
|
||||
if [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then
|
||||
# Check if GITHUB_REF points to a tag (works for both push events and workflow_dispatch with tag selected)
|
||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
# Tag selected or pushed - use the tag from GITHUB_REF
|
||||
echo "packages=dms" >> $GITHUB_OUTPUT
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Using tag from GITHUB_REF: $VERSION"
|
||||
# Check if check-updates already determined a version (from auto-detection)
|
||||
elif [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then
|
||||
# Use version from check-updates job
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
echo "version=${{ needs.check-updates.outputs.version }}" >> $GITHUB_OUTPUT
|
||||
@@ -189,16 +191,40 @@ jobs:
|
||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||
# Manual workflow dispatch
|
||||
|
||||
# Determine version for dms stable using the API
|
||||
# GITHUB_REF is unreliable when "Use workflow from" a tag; API works from any ref
|
||||
if [[ "${{ github.event.inputs.package }}" == "dms" ]] || [[ "${{ github.event.inputs.package }}" == "all" ]]; then
|
||||
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
|
||||
if [[ -n "$LATEST_TAG" ]]; then
|
||||
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||
echo "Using latest release from API: $LATEST_TAG"
|
||||
# Determine version for dms stable
|
||||
if [[ "${{ github.event.inputs.package }}" == "dms" ]]; then
|
||||
# Use github.ref if tag selected, otherwise auto-detect latest
|
||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Using tag from GITHUB_REF: $VERSION"
|
||||
else
|
||||
echo "ERROR: Could not fetch latest release from API"
|
||||
exit 1
|
||||
# Auto-detect latest release for dms
|
||||
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
|
||||
if [[ -n "$LATEST_TAG" ]]; then
|
||||
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||
echo "Auto-detected latest release: $LATEST_TAG"
|
||||
else
|
||||
echo "ERROR: Could not auto-detect latest release"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
elif [[ "${{ github.event.inputs.package }}" == "all" ]]; then
|
||||
# Use github.ref if tag selected, otherwise auto-detect latest
|
||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Using tag from GITHUB_REF: $VERSION"
|
||||
else
|
||||
# Auto-detect latest release for "all"
|
||||
LATEST_TAG=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
|
||||
if [[ -n "$LATEST_TAG" ]]; then
|
||||
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||
echo "Auto-detected latest release: $LATEST_TAG"
|
||||
else
|
||||
echo "ERROR: Could not auto-detect latest release"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -218,7 +244,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Update dms-git spec version
|
||||
if: contains(steps.packages.outputs.packages, 'dms-git')
|
||||
if: contains(steps.packages.outputs.packages, 'dms-git') || steps.packages.outputs.packages == 'all'
|
||||
run: |
|
||||
COMMIT_HASH=$(git rev-parse --short=8 HEAD)
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
@@ -239,7 +265,7 @@ jobs:
|
||||
} > distro/opensuse/dms-git.spec
|
||||
|
||||
- name: Update Debian dms-git changelog version
|
||||
if: contains(steps.packages.outputs.packages, 'dms-git')
|
||||
if: contains(steps.packages.outputs.packages, 'dms-git') || steps.packages.outputs.packages == 'all'
|
||||
run: |
|
||||
COMMIT_HASH=$(git rev-parse --short=8 HEAD)
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
@@ -257,55 +283,57 @@ jobs:
|
||||
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE"
|
||||
} > "distro/debian/dms-git/debian/changelog"
|
||||
|
||||
- name: Update stable version (dms)
|
||||
- name: Update dms stable version
|
||||
if: steps.packages.outputs.version != ''
|
||||
run: |
|
||||
VERSION="${{ steps.packages.outputs.version }}"
|
||||
VERSION_NO_V="${VERSION#v}"
|
||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
||||
echo "==> Updating packaging files to version: $VERSION_NO_V"
|
||||
|
||||
# Update dms spec and changelog when dms is in the upload list
|
||||
if [[ "$PACKAGES" == *"dms"* ]]; then
|
||||
sed -i "s/^Version:.*/Version: $VERSION_NO_V/" distro/opensuse/dms.spec
|
||||
UPDATED_VERSION=$(grep -oP '^Version:\s+\K[0-9.]+' distro/opensuse/dms.spec | head -1)
|
||||
echo "✓ dms spec now shows Version: $UPDATED_VERSION"
|
||||
# Update spec file
|
||||
sed -i "s/^Version:.*/Version: $VERSION_NO_V/" distro/opensuse/dms.spec
|
||||
|
||||
DATE_STR=$(date "+%a %b %d %Y")
|
||||
LOCAL_SPEC_HEAD=$(sed -n '1,/%changelog/{ /%changelog/d; p }' distro/opensuse/dms.spec)
|
||||
{
|
||||
echo "$LOCAL_SPEC_HEAD"
|
||||
echo "%changelog"
|
||||
echo "* $DATE_STR AvengeMedia <maintainer@avengemedia.com> - ${VERSION_NO_V}-1"
|
||||
echo "- Update to stable $VERSION release"
|
||||
} > distro/opensuse/dms.spec
|
||||
# Verify the update
|
||||
UPDATED_VERSION=$(grep -oP '^Version:\s+\K[0-9.]+' distro/opensuse/dms.spec | head -1)
|
||||
echo "✓ Spec file now shows Version: $UPDATED_VERSION"
|
||||
|
||||
if [[ -f "distro/debian/dms/debian/changelog" ]]; then
|
||||
CHANGELOG_DATE=$(date -R)
|
||||
{
|
||||
echo "dms (${VERSION_NO_V}db1) stable; urgency=medium"
|
||||
echo ""
|
||||
echo " * Update to $VERSION stable release"
|
||||
echo ""
|
||||
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE"
|
||||
} > "distro/debian/dms/debian/changelog"
|
||||
echo "✓ Updated dms changelog to ${VERSION_NO_V}db1"
|
||||
fi
|
||||
fi
|
||||
# Single changelog entry (full history on OBS website)
|
||||
DATE_STR=$(date "+%a %b %d %Y")
|
||||
LOCAL_SPEC_HEAD=$(sed -n '1,/%changelog/{ /%changelog/d; p }' distro/opensuse/dms.spec)
|
||||
{
|
||||
echo "$LOCAL_SPEC_HEAD"
|
||||
echo "%changelog"
|
||||
echo "* $DATE_STR AvengeMedia <maintainer@avengemedia.com> - ${VERSION_NO_V}-1"
|
||||
echo "- Update to stable $VERSION release"
|
||||
} > distro/opensuse/dms.spec
|
||||
|
||||
# Update Debian _service files for packages in upload list (download_url paths)
|
||||
# Update Debian _service files (both tar_scm and download_url formats)
|
||||
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)
|
||||
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
|
||||
done
|
||||
|
||||
# Update Debian changelog for dms stable (single entry, history on OBS website)
|
||||
if [[ -f "distro/debian/dms/debian/changelog" ]]; then
|
||||
CHANGELOG_DATE=$(date -R)
|
||||
{
|
||||
echo "dms (${VERSION_NO_V}db1) stable; urgency=medium"
|
||||
echo ""
|
||||
echo " * Update to $VERSION stable release"
|
||||
echo ""
|
||||
echo " -- Avenge Media <AvengeMedia.US@gmail.com> $CHANGELOG_DATE"
|
||||
} > "distro/debian/dms/debian/changelog"
|
||||
echo "✓ Updated Debian changelog to ${VERSION_NO_V}db1"
|
||||
fi
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: ./core/go.mod
|
||||
|
||||
@@ -325,18 +353,7 @@ jobs:
|
||||
EOF
|
||||
chmod 600 ~/.config/osc/oscrc
|
||||
|
||||
# Cache OBS bundled Go toolchains
|
||||
- name: Cache OBS bundled Go toolchains (dms-git)
|
||||
if: contains(steps.packages.outputs.packages, 'dms-git')
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /home/runner/.cache/dms-obs-go-toolchain
|
||||
key: dms-obs-go-toolchain-${{ runner.os }}-${{ hashFiles('core/go.mod') }}
|
||||
restore-keys: |
|
||||
dms-obs-go-toolchain-${{ runner.os }}-
|
||||
|
||||
- name: Upload to OBS
|
||||
id: upload
|
||||
env:
|
||||
REBUILD_RELEASE: ${{ github.event.inputs.rebuild_release }}
|
||||
TAG_VERSION: ${{ steps.packages.outputs.version }}
|
||||
@@ -345,8 +362,6 @@ jobs:
|
||||
|
||||
if [[ -z "$PACKAGES" ]]; then
|
||||
echo "✓ No packages need uploading. All up to date!"
|
||||
echo "uploaded_packages=" >> $GITHUB_OUTPUT
|
||||
echo "skipped_packages=" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -356,10 +371,7 @@ jobs:
|
||||
echo "==> Version being uploaded: ${{ steps.packages.outputs.version }}"
|
||||
fi
|
||||
|
||||
UPLOADED_PACKAGES=()
|
||||
SKIPPED_PACKAGES=()
|
||||
|
||||
# PACKAGES can be a space-separated list (from the "all" check)
|
||||
# PACKAGES can be space-separated list (e.g., "dms-git dms" from "all" check)
|
||||
# Loop through each package and upload
|
||||
for PKG in $PACKAGES; do
|
||||
echo ""
|
||||
@@ -370,37 +382,13 @@ jobs:
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
LOG_FILE=$(mktemp)
|
||||
set +e
|
||||
if [[ "$PKG" == "dms-git" ]]; then
|
||||
bash distro/scripts/obs-upload.sh dms-git "Automated git update" >"$LOG_FILE" 2>&1
|
||||
bash distro/scripts/obs-upload.sh dms-git "Automated git update"
|
||||
else
|
||||
bash distro/scripts/obs-upload.sh "$PKG" "$MESSAGE" >"$LOG_FILE" 2>&1
|
||||
bash distro/scripts/obs-upload.sh "$PKG" "$MESSAGE"
|
||||
fi
|
||||
STATUS=$?
|
||||
set -e
|
||||
|
||||
cat "$LOG_FILE"
|
||||
|
||||
if [[ $STATUS -ne 0 ]]; then
|
||||
rm -f "$LOG_FILE"
|
||||
echo "❌ Upload failed for $PKG"
|
||||
exit $STATUS
|
||||
fi
|
||||
|
||||
if grep -Eq "Exiting gracefully \(no changes needed\)|No changes needed for this package\. Exiting gracefully\." "$LOG_FILE"; then
|
||||
echo "ℹ️ $PKG is already up to date. Skipped."
|
||||
SKIPPED_PACKAGES+=("$PKG")
|
||||
else
|
||||
UPLOADED_PACKAGES+=("$PKG")
|
||||
fi
|
||||
|
||||
rm -f "$LOG_FILE"
|
||||
done
|
||||
|
||||
echo "uploaded_packages=${UPLOADED_PACKAGES[*]}" >> $GITHUB_OUTPUT
|
||||
echo "skipped_packages=${SKIPPED_PACKAGES[*]}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
@@ -414,56 +402,20 @@ jobs:
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "All packages are current. Run completed successfully." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
UPLOADED_PACKAGES="${{ steps.upload.outputs.uploaded_packages }}"
|
||||
SKIPPED_PACKAGES="${{ steps.upload.outputs.skipped_packages }}"
|
||||
TOTAL_COUNT=$(wc -w <<<"$PACKAGES" | tr -d ' ')
|
||||
UPLOADED_COUNT=0
|
||||
SKIPPED_COUNT=0
|
||||
if [[ -n "$UPLOADED_PACKAGES" ]]; then
|
||||
UPLOADED_COUNT=$(wc -w <<<"$UPLOADED_PACKAGES" | tr -d ' ')
|
||||
fi
|
||||
if [[ -n "$SKIPPED_PACKAGES" ]]; then
|
||||
SKIPPED_COUNT=$(wc -w <<<"$SKIPPED_PACKAGES" | tr -d ' ')
|
||||
fi
|
||||
in_list() {
|
||||
local item="$1"
|
||||
local list="$2"
|
||||
[[ " $list " == *" $item "* ]]
|
||||
}
|
||||
|
||||
if [[ "${{ job.status }}" == "success" ]]; then
|
||||
echo "**Status:** ✅ Completed successfully" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**Status:** ❌ Completed with errors" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Processed:** $TOTAL_COUNT package(s)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Uploaded:** $UPLOADED_COUNT package(s)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Skipped (up to date):** $SKIPPED_COUNT package(s)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Packages:**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Packages Uploaded:**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
for PKG in $PACKAGES; do
|
||||
STATUS_ICON="✅"
|
||||
STATUS_TEXT="uploaded"
|
||||
if in_list "$PKG" "$SKIPPED_PACKAGES"; then
|
||||
STATUS_ICON="ℹ️"
|
||||
STATUS_TEXT="up to date (skipped)"
|
||||
elif ! in_list "$PKG" "$UPLOADED_PACKAGES"; then
|
||||
STATUS_ICON="❌"
|
||||
STATUS_TEXT="failed"
|
||||
fi
|
||||
|
||||
case "$PKG" in
|
||||
dms)
|
||||
echo "- $STATUS_ICON **dms** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms/dms)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- ✅ **dms** → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms/dms)" >> $GITHUB_STEP_SUMMARY
|
||||
;;
|
||||
dms-git)
|
||||
echo "- $STATUS_ICON **dms-git** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms-git/dms-git)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- ✅ **dms-git** → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms-git/dms-git)" >> $GITHUB_STEP_SUMMARY
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [[ -n "${{ github.event.inputs.rebuild_release }}" ]]; then
|
||||
|
||||
+207
-77
@@ -4,14 +4,9 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: "Package to upload"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- dms
|
||||
- dms-git
|
||||
- all
|
||||
default: "dms"
|
||||
description: "Package to upload (dms, dms-git, dms-greeter, or all)"
|
||||
required: false
|
||||
default: "dms-git"
|
||||
rebuild_release:
|
||||
description: "Release number for rebuilds (e.g., 2, 3, 4 for ppa2, ppa3, ppa4)"
|
||||
required: false
|
||||
@@ -21,83 +16,147 @@ on:
|
||||
|
||||
jobs:
|
||||
check-updates:
|
||||
name: Check package/series updates
|
||||
name: Check for updates
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'AvengeMedia/DankMaterialShell'
|
||||
|
||||
outputs:
|
||||
has_updates: ${{ steps.check.outputs.has_updates }}
|
||||
targets: ${{ steps.check.outputs.targets }}
|
||||
targets_json: ${{ steps.check.outputs.targets_json }}
|
||||
packages: ${{ steps.check.outputs.packages }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y jq curl git
|
||||
|
||||
- name: Check for updates
|
||||
id: check
|
||||
run: |
|
||||
chmod +x distro/scripts/ppa-sync-plan.sh
|
||||
# Helper function to check dms-git commit
|
||||
check_dms_git() {
|
||||
local CURRENT_COMMIT=$(git rev-parse --short=8 HEAD)
|
||||
local PPA_VERSION=$(curl -s "https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/dms-git?ws.op=getPublishedSources&source_name=dms-git&status=Published" | grep -oP '"source_package_version":\s*"\K[^"]+' | head -1 || echo "")
|
||||
local PPA_COMMIT=$(echo "$PPA_VERSION" | grep -oP '\.[a-f0-9]{8}' | tr -d '.' || echo "")
|
||||
|
||||
if [[ -n "$PPA_COMMIT" && "$CURRENT_COMMIT" == "$PPA_COMMIT" ]]; then
|
||||
echo "📋 dms-git: Commit $CURRENT_COMMIT already exists, skipping"
|
||||
return 1 # No update needed
|
||||
else
|
||||
echo "📋 dms-git: New commit $CURRENT_COMMIT (PPA has ${PPA_COMMIT:-none})"
|
||||
return 0 # Update needed
|
||||
fi
|
||||
}
|
||||
|
||||
# Helper function to check stable package tag
|
||||
check_stable_package() {
|
||||
local PKG="$1"
|
||||
local PPA_NAME="$2"
|
||||
# Use git ls-remote to find the latest tag, sorted by version (descending)
|
||||
local LATEST_TAG=$(git ls-remote --tags --refs --sort='-v:refname' https://github.com/AvengeMedia/DankMaterialShell.git | head -n1 | awk -F/ '{print $NF}' | sed 's/^v//')
|
||||
local PPA_VERSION=$(curl -s "https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/$PPA_NAME?ws.op=getPublishedSources&source_name=$PKG&status=Published" | grep -oP '"source_package_version":\s*"\K[^"]+' | head -1 || echo "")
|
||||
local PPA_BASE_VERSION=$(echo "$PPA_VERSION" | sed 's/ppa[0-9]*$//')
|
||||
|
||||
if [[ -n "$LATEST_TAG" && "$LATEST_TAG" == "$PPA_BASE_VERSION" ]]; then
|
||||
echo "📋 $PKG: Tag $LATEST_TAG already exists, skipping"
|
||||
return 1 # No update needed
|
||||
else
|
||||
echo "📋 $PKG: New tag ${LATEST_TAG:-unknown} (PPA has ${PPA_BASE_VERSION:-none})"
|
||||
return 0 # Update needed
|
||||
fi
|
||||
}
|
||||
|
||||
# Main logic
|
||||
REBUILD="${{ github.event.inputs.rebuild_release }}"
|
||||
|
||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
PACKAGE="dms-git"
|
||||
# Scheduled run - check dms-git only
|
||||
echo "packages=dms-git" >> $GITHUB_OUTPUT
|
||||
if check_dms_git; then
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||
# Manual workflow trigger
|
||||
PKG="${{ github.event.inputs.package }}"
|
||||
|
||||
if [[ -n "$REBUILD" ]]; then
|
||||
# Rebuild requested - always proceed
|
||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "🔄 Manual rebuild requested: $PKG (ppa$REBUILD)"
|
||||
|
||||
elif [[ "$PKG" == "all" ]]; then
|
||||
# Check each package and build list of those needing updates
|
||||
PACKAGES_TO_UPDATE=()
|
||||
check_dms_git && PACKAGES_TO_UPDATE+=("dms-git")
|
||||
check_stable_package "dms" "dms" && PACKAGES_TO_UPDATE+=("dms")
|
||||
check_stable_package "dms-greeter" "danklinux" && PACKAGES_TO_UPDATE+=("dms-greeter")
|
||||
|
||||
if [[ ${#PACKAGES_TO_UPDATE[@]} -gt 0 ]]; then
|
||||
echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "✓ Packages to update: ${PACKAGES_TO_UPDATE[*]}"
|
||||
else
|
||||
echo "packages=" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
echo "✓ All packages up to date"
|
||||
fi
|
||||
|
||||
elif [[ "$PKG" == "dms-git" ]]; then
|
||||
if check_dms_git; then
|
||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "packages=" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
elif [[ "$PKG" == "dms" ]]; then
|
||||
if check_stable_package "dms" "dms"; then
|
||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "packages=" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
elif [[ "$PKG" == "dms-greeter" ]]; then
|
||||
if check_stable_package "dms-greeter" "danklinux"; then
|
||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "packages=" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
else
|
||||
# Unknown package - proceed anyway
|
||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
echo "Manual trigger: $PKG"
|
||||
fi
|
||||
else
|
||||
PACKAGE="${{ github.event.inputs.package }}"
|
||||
fi
|
||||
|
||||
REBUILD_RELEASE="${{ github.event.inputs.rebuild_release }}"
|
||||
ARGS=(--package "$PACKAGE" --json)
|
||||
if [[ -n "$REBUILD_RELEASE" ]]; then
|
||||
ARGS+=(--rebuild "$REBUILD_RELEASE")
|
||||
fi
|
||||
|
||||
TARGETS_JSON=$(distro/scripts/ppa-sync-plan.sh "${ARGS[@]}" 2> ppa-audit.log)
|
||||
cat ppa-audit.log
|
||||
|
||||
TARGETS=$(echo "$TARGETS_JSON" | jq -r 'join(" ")')
|
||||
if [[ "$TARGETS_JSON" != "[]" ]]; then
|
||||
echo "has_updates=true" >> "$GITHUB_OUTPUT"
|
||||
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
|
||||
echo "targets_json=$TARGETS_JSON" >> "$GITHUB_OUTPUT"
|
||||
echo "Package/series targets: $TARGETS"
|
||||
else
|
||||
echo "has_updates=false" >> "$GITHUB_OUTPUT"
|
||||
echo "targets=" >> "$GITHUB_OUTPUT"
|
||||
echo "targets_json=[]" >> "$GITHUB_OUTPUT"
|
||||
echo "No package/series uploads needed"
|
||||
# Fallback
|
||||
echo "packages=dms-git" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
upload-ppa:
|
||||
name: Upload ${{ matrix.target }}
|
||||
name: Upload to PPA
|
||||
needs: check-updates
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check-updates.outputs.has_updates == 'true'
|
||||
timeout-minutes: 120
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: ${{ fromJson(needs.check-updates.outputs.targets_json) }}
|
||||
concurrency:
|
||||
group: ppa-dms-${{ matrix.target }}
|
||||
cancel-in-progress: false
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: ./core/go.mod
|
||||
cache: false
|
||||
@@ -112,8 +171,7 @@ jobs:
|
||||
lftp \
|
||||
build-essential \
|
||||
fakeroot \
|
||||
dpkg-dev \
|
||||
openssh-client
|
||||
dpkg-dev
|
||||
|
||||
- name: Configure GPG
|
||||
env:
|
||||
@@ -121,30 +179,102 @@ jobs:
|
||||
run: |
|
||||
echo "$GPG_KEY" | gpg --import
|
||||
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2)
|
||||
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> "$GITHUB_ENV"
|
||||
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload target
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
LAUNCHPAD_SSH_PRIVATE_KEY: ${{ secrets.LAUNCHPAD_SSH_PRIVATE_KEY }}
|
||||
LAUNCHPAD_SSH_LOGIN: ${{ secrets.LAUNCHPAD_SSH_LOGIN }}
|
||||
- name: Determine packages to upload
|
||||
id: packages
|
||||
run: |
|
||||
IFS=':' read -r PACKAGE UBUNTU_SERIES PPA_NUM <<< "$TARGET"
|
||||
# Use packages determined by check-updates job
|
||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
||||
|
||||
case "$PACKAGE" in
|
||||
dms) PPA_NAME="dms" ;;
|
||||
dms-git) PPA_NAME="dms-git" ;;
|
||||
*) echo "::error::Unknown package $PACKAGE"; exit 1 ;;
|
||||
esac
|
||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
echo "Triggered by schedule: uploading git package"
|
||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||
echo "Manual trigger: ${{ needs.check-updates.outputs.packages }}"
|
||||
fi
|
||||
|
||||
echo "Uploading $PACKAGE to $PPA_NAME/$UBUNTU_SERIES with ppa$PPA_NUM"
|
||||
bash distro/scripts/ppa-upload.sh "$PACKAGE" "$PPA_NAME" "$UBUNTU_SERIES" "$PPA_NUM"
|
||||
- name: Upload to PPA
|
||||
run: |
|
||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
||||
REBUILD_RELEASE="${{ github.event.inputs.rebuild_release }}"
|
||||
|
||||
if [[ -z "$PACKAGES" ]]; then
|
||||
echo "✓ No packages need uploading. All up to date!"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Export REBUILD_RELEASE so ppa-build.sh can use it
|
||||
if [[ -n "$REBUILD_RELEASE" ]]; then
|
||||
export REBUILD_RELEASE
|
||||
echo "✓ Using rebuild release number: ppa$REBUILD_RELEASE"
|
||||
fi
|
||||
|
||||
# PACKAGES can be space-separated list (e.g., "dms-git dms" from "all" check)
|
||||
# Loop through each package and upload
|
||||
for PKG in $PACKAGES; do
|
||||
# Map package to PPA name
|
||||
case "$PKG" in
|
||||
dms)
|
||||
PPA_NAME="dms"
|
||||
;;
|
||||
dms-git)
|
||||
PPA_NAME="dms-git"
|
||||
;;
|
||||
dms-greeter)
|
||||
PPA_NAME="danklinux"
|
||||
;;
|
||||
*)
|
||||
echo "⚠️ Unknown package: $PKG, skipping"
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Uploading $PKG to PPA $PPA_NAME..."
|
||||
if [[ -n "$REBUILD_RELEASE" ]]; then
|
||||
echo "🔄 Using rebuild release number: ppa$REBUILD_RELEASE"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
bash distro/scripts/ppa-upload.sh "$PKG" "$PPA_NAME" questing ${REBUILD_RELEASE:+"$REBUILD_RELEASE"}
|
||||
done
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "### PPA Package Upload" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- **Target:** ${{ matrix.target }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- **DMS PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- **DMS-Git PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "### PPA Package Upload Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
||||
|
||||
if [[ -z "$PACKAGES" ]]; then
|
||||
echo "**Status:** ✅ All packages up to date (no uploads needed)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "All packages are current. Run will complete successfully." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**Packages Uploaded:**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
for PKG in $PACKAGES; do
|
||||
case "$PKG" in
|
||||
dms)
|
||||
echo "- ✅ **dms** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages)" >> $GITHUB_STEP_SUMMARY
|
||||
;;
|
||||
dms-git)
|
||||
echo "- ✅ **dms-git** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages)" >> $GITHUB_STEP_SUMMARY
|
||||
;;
|
||||
dms-greeter)
|
||||
echo "- ✅ **dms-greeter** → [View builds](https://launchpad.net/~avengemedia/+archive/ubuntu/danklinux/+packages)" >> $GITHUB_STEP_SUMMARY
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [[ -n "${{ github.event.inputs.rebuild_release }}" ]]; then
|
||||
echo "**Rebuild Number:** ppa${{ github.event.inputs.rebuild_release }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "Builds will appear once Launchpad processes the uploads." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
@@ -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
|
||||
@@ -18,13 +18,13 @@ 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 }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app_token.outputs.token }}
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
echo "Build succeeded, no hash update needed"
|
||||
exit 0
|
||||
fi
|
||||
new_hash=$(echo "$output" | grep -oP "got:\s+\K\S+" | head -n1 || true)
|
||||
new_hash=$(echo "$output" | grep -oP "got:\s+\K\S+" | head -n1)
|
||||
[ -n "$new_hash" ] || { echo "Could not extract new vendorHash"; echo "$output"; exit 1; }
|
||||
current_hash=$(grep -oP 'vendorHash = "\K[^"]+' flake.nix)
|
||||
[ "$current_hash" = "$new_hash" ] && { echo "vendorHash already up to date"; exit 0; }
|
||||
@@ -59,8 +59,8 @@ jobs:
|
||||
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||
git add flake.nix
|
||||
git commit -m "nix: update vendorHash for go.mod changes" || exit 0
|
||||
git pull --rebase origin ${{ github.ref_name }}
|
||||
git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:${{ github.ref_name }}
|
||||
git pull --rebase origin master
|
||||
git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:master
|
||||
else
|
||||
echo "No changes to flake.nix"
|
||||
fi
|
||||
|
||||
-19
@@ -107,27 +107,8 @@ vim/
|
||||
|
||||
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
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
[submodule "dank-qml-common"]
|
||||
path = dank-qml-common
|
||||
url = https://github.com/AvengeMedia/dank-qml-common.git
|
||||
branch = master
|
||||
+3
-37
@@ -5,13 +5,11 @@ repos:
|
||||
- id: trailing-whitespace
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- repo: local
|
||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||
rev: v0.10.0.1
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
name: shellcheck
|
||||
entry: shellcheck -e SC2164 -e SC2001 -e SC2012 -e SC2317
|
||||
language: system
|
||||
types: [shell]
|
||||
args: [-e, SC2164, -e, SC2001, -e, SC2012, -e, SC2317]
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: go-mod-tidy
|
||||
@@ -20,35 +18,3 @@ 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
|
||||
name: no console.* in QML (use Log service)
|
||||
entry: bash -c 'if grep -nE "console\.(log|error|info|warn|debug)" "$@"; then echo "Use the Log service (log.info/warn/error/debug/fatal) instead of console.*" >&2; exit 1; fi' --
|
||||
language: system
|
||||
files: ^quickshell/.*\.qml$
|
||||
exclude: ^quickshell/(Services/Log\.qml$|dms-plugins/|PLUGINS/)
|
||||
|
||||
@@ -1,21 +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
|
||||
- Greeter stuff
|
||||
- Terminal mux
|
||||
- Locale overrides
|
||||
- new neovim theming
|
||||
|
||||
# 1.4.0
|
||||
|
||||
- Overhauled system monitor, graphs, styling
|
||||
|
||||
+2
-55
@@ -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
|
||||
@@ -30,53 +22,12 @@ nix develop
|
||||
|
||||
This will provide:
|
||||
|
||||
- Go 1.25+ toolchain (go, gopls, delve, go-tools) and GNU Make
|
||||
- Go 1.24 toolchain (go, gopls, delve, go-tools) and GNU Make
|
||||
- Quickshell and required QML packages
|
||||
- Properly configured QML2_IMPORT_PATH
|
||||
|
||||
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.
|
||||
@@ -135,9 +86,7 @@ touch .qmlls.ini
|
||||
|
||||
4. Restart dms to generate the `.qmlls.ini` file
|
||||
|
||||
5. Run `make lint-qml` from the repo root to lint QML entrypoints (requires the `.qmlls.ini` generated above). The script needs the **Qt 6** `qmllint`; it checks `qmllint6`, Fedora's `qmllint-qt6`, `/usr/lib/qt6/bin/qmllint`, then `qmllint` in `PATH`. If your Qt 6 binary lives elsewhere, set `QMLLINT=/path/to/qmllint`.
|
||||
|
||||
6. Make your changes, test, and open a pull request.
|
||||
5. Make your changes, test, and open a pull request.
|
||||
|
||||
### I18n/Localization
|
||||
|
||||
@@ -153,8 +102,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)
|
||||
|
||||
@@ -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 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,26 +27,11 @@ 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
|
||||
@echo "Clean complete"
|
||||
|
||||
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 +40,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 +56,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,14 +71,12 @@ 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
|
||||
install: build install-bin install-shell install-completions install-systemd install-icon install-desktop
|
||||
@echo ""
|
||||
@echo "Installation complete!"
|
||||
@echo ""
|
||||
@@ -135,12 +113,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 ""
|
||||
@@ -154,7 +130,6 @@ help:
|
||||
@echo " all (default) - Build the DMS binary"
|
||||
@echo " build - Same as 'all'"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo " lint-qml - Run qmllint on shell entrypoints using the Quickshell tooling VFS"
|
||||
@echo ""
|
||||
@echo "Install:"
|
||||
@echo " install - Build and install everything (requires sudo)"
|
||||
|
||||
@@ -13,7 +13,7 @@ Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
|
||||
[](https://github.com/AvengeMedia/DankMaterialShell/stargazers)
|
||||
[](https://github.com/AvengeMedia/DankMaterialShell/blob/master/LICENSE)
|
||||
[](https://github.com/AvengeMedia/DankMaterialShell/releases)
|
||||
[](https://archlinux.org/packages/extra/x86_64/dms-shell/)
|
||||
[](https://aur.archlinux.org/packages/dms-shell-bin)
|
||||
[>)](https://aur.archlinux.org/packages/dms-shell-git)
|
||||
[](https://ko-fi.com/danklinux)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
+71
-80
@@ -1,85 +1,76 @@
|
||||
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/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,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/golangci/golangci-lint
|
||||
rev: v2.12.2
|
||||
rev: v2.9.0
|
||||
hooks:
|
||||
- id: golangci-lint-fmt
|
||||
require_serial: true
|
||||
|
||||
+29
-53
@@ -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,34 +40,42 @@ 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
|
||||
|
||||
install:
|
||||
install: build
|
||||
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
|
||||
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
|
||||
@echo "Installation complete"
|
||||
|
||||
install-all:
|
||||
install-all: build-all
|
||||
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
|
||||
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
|
||||
@echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..."
|
||||
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
|
||||
@echo "Installation complete"
|
||||
|
||||
install-dankinstall:
|
||||
install-dankinstall: dankinstall
|
||||
@echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..."
|
||||
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
|
||||
@echo "Installation complete"
|
||||
@@ -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
-49
@@ -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 with TUI for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -96,7 +96,7 @@ The on-screen preview displays the selected format. JSON output includes hex, RG
|
||||
|
||||
## Building
|
||||
|
||||
Requires Go 1.25+
|
||||
Requires Go 1.24+
|
||||
|
||||
**Development build:**
|
||||
|
||||
@@ -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`
|
||||
@@ -147,53 +147,13 @@ go-wayland-scanner -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
|
||||
|
||||
## Installation via dankinstall
|
||||
|
||||
**Interactive (TUI):**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.danklinux.com | sh
|
||||
```
|
||||
|
||||
**Headless (unattended):**
|
||||
|
||||
Headless mode requires cached sudo credentials. Run `sudo -v` first:
|
||||
|
||||
```bash
|
||||
sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c niri -t ghostty -y
|
||||
sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c hyprland -t kitty --include-deps dms-greeter -y
|
||||
```
|
||||
|
||||
| Flag | Short | Description |
|
||||
|------|-------|-------------|
|
||||
| `--compositor <niri|hyprland>` | `-c` | Compositor/WM to install (required for headless) |
|
||||
| `--term <ghostty|kitty|alacritty>` | `-t` | Terminal emulator (required for headless) |
|
||||
| `--include-deps <name,...>` | | Enable optional dependencies (e.g. `dms-greeter`) |
|
||||
| `--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 |
|
||||
|
||||
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.
|
||||
`dms-greeter` is disabled by default; use `--include-deps dms-greeter` to enable it.
|
||||
|
||||
When no flags are provided, `dankinstall` launches the interactive TUI.
|
||||
|
||||
### Headless mode validation rules
|
||||
|
||||
Headless mode activates when `--compositor` or `--term` is provided.
|
||||
|
||||
- Both `--compositor` and `--term` are required; providing only one results in an error.
|
||||
- Headless-only flags (`--include-deps`, `--exclude-deps`, `--replace-configs`, `--replace-configs-all`, `--yes`) are rejected in TUI mode.
|
||||
- Positional arguments are not accepted.
|
||||
|
||||
### Log file location
|
||||
|
||||
`dankinstall` writes logs to `/tmp` by default.
|
||||
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 +166,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 +174,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.
|
||||
|
||||
@@ -3,160 +3,20 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/headless"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
// Flag variables bound via pflag
|
||||
var (
|
||||
compositor string
|
||||
term string
|
||||
includeDeps []string
|
||||
excludeDeps []string
|
||||
replaceConfigs []string
|
||||
replaceConfigsAll bool
|
||||
yes bool
|
||||
danksearch bool
|
||||
dankcalendar bool
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "dankinstall",
|
||||
Short: "Install DankMaterialShell and its dependencies",
|
||||
Long: `dankinstall sets up DankMaterialShell with your chosen compositor and terminal.
|
||||
|
||||
Without flags, it launches an interactive TUI. Providing either --compositor
|
||||
or --term activates headless (unattended) mode, which requires both flags.
|
||||
|
||||
Headless mode requires cached sudo credentials. Run 'sudo -v' beforehand, or
|
||||
configure passwordless sudo for your user.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: runDankinstall,
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri, hyprland, or mango (enables headless mode)")
|
||||
rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)")
|
||||
rootCmd.Flags().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() {
|
||||
if os.Getuid() == 0 {
|
||||
fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runDankinstall(cmd *cobra.Command, args []string) error {
|
||||
headlessMode := compositor != "" || term != ""
|
||||
|
||||
if !headlessMode {
|
||||
// Reject headless-only flags when running in TUI mode.
|
||||
headlessOnly := []string{
|
||||
"include-deps",
|
||||
"exclude-deps",
|
||||
"replace-configs",
|
||||
"replace-configs-all",
|
||||
"yes",
|
||||
"danksearch",
|
||||
"dankcalendar",
|
||||
}
|
||||
var set []string
|
||||
for _, name := range headlessOnly {
|
||||
if cmd.Flags().Changed(name) {
|
||||
set = append(set, "--"+name)
|
||||
}
|
||||
}
|
||||
if len(set) > 0 {
|
||||
return fmt.Errorf("flags %s are only valid in headless mode (requires both --compositor and --term)", strings.Join(set, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
if headlessMode {
|
||||
return runHeadless()
|
||||
}
|
||||
return runTUI()
|
||||
}
|
||||
|
||||
func runHeadless() error {
|
||||
// Validate required flags
|
||||
if compositor == "" {
|
||||
return fmt.Errorf("--compositor is required for headless mode (niri, hyprland, or mango)")
|
||||
}
|
||||
if term == "" {
|
||||
return fmt.Errorf("--term is required for headless mode (ghostty, kitty, or alacritty)")
|
||||
}
|
||||
|
||||
cfg := headless.Config{
|
||||
Compositor: compositor,
|
||||
Terminal: term,
|
||||
IncludeDeps: includeDeps,
|
||||
ExcludeDeps: excludeDeps,
|
||||
ReplaceConfigs: replaceConfigs,
|
||||
ReplaceConfigsAll: replaceConfigsAll,
|
||||
Yes: yes,
|
||||
DankSearch: danksearch,
|
||||
DankCalendar: dankcalendar,
|
||||
}
|
||||
|
||||
runner := headless.NewRunner(cfg)
|
||||
|
||||
// Set up file logging
|
||||
fileLogger, err := log.NewFileLogger()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Failed to create log file: %v\n", err)
|
||||
}
|
||||
|
||||
if fileLogger != nil {
|
||||
fmt.Printf("Logging to: %s\n", fileLogger.GetLogPath())
|
||||
fileLogger.StartListening(runner.GetLogChan())
|
||||
defer func() {
|
||||
if err := fileLogger.Close(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Failed to close log file: %v\n", err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
// Drain the log channel to prevent blocking sends from deadlocking
|
||||
// downstream components (distros, config deployer) that write to it.
|
||||
// Use an explicit stop signal because this code does not own the
|
||||
// runner log channel and cannot assume it will be closed.
|
||||
defer drainLogChan(runner.GetLogChan())()
|
||||
}
|
||||
|
||||
if err := runner.Run(); err != nil {
|
||||
if fileLogger != nil {
|
||||
fmt.Fprintf(os.Stderr, "\nFull logs are available at: %s\n", fileLogger.GetLogPath())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if fileLogger != nil {
|
||||
fmt.Printf("\nFull logs are available at: %s\n", fileLogger.GetLogPath())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runTUI() error {
|
||||
fileLogger, err := log.NewFileLogger()
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to create log file: %v\n", err)
|
||||
@@ -178,50 +38,18 @@ func runTUI() error {
|
||||
|
||||
if fileLogger != nil {
|
||||
fileLogger.StartListening(model.GetLogChan())
|
||||
} else {
|
||||
// Drain the log channel to prevent blocking sends from deadlocking
|
||||
// downstream components (distros, config deployer) that write to it.
|
||||
// Use an explicit stop signal because this code does not own the
|
||||
// model log channel and cannot assume it will be closed.
|
||||
defer drainLogChan(model.GetLogChan())()
|
||||
}
|
||||
|
||||
p := tea.NewProgram(model, tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Printf("Error running program: %v\n", err)
|
||||
if logFilePath != "" {
|
||||
fmt.Fprintf(os.Stderr, "\nFull logs are available at: %s\n", logFilePath)
|
||||
fmt.Printf("\nFull logs are available at: %s\n", logFilePath)
|
||||
}
|
||||
return fmt.Errorf("error running program: %w", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if logFilePath != "" {
|
||||
fmt.Printf("\nFull logs are available at: %s\n", logFilePath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// drainLogChan starts a goroutine that discards all messages from logCh,
|
||||
// preventing blocking sends from deadlocking downstream components. It returns
|
||||
// a cleanup function that signals the goroutine to stop and waits for it to
|
||||
// exit. Callers should defer the returned function.
|
||||
func drainLogChan(logCh <-chan string) func() {
|
||||
drainStop := make(chan struct{})
|
||||
drainDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(drainDone)
|
||||
for {
|
||||
select {
|
||||
case <-drainStop:
|
||||
return
|
||||
case _, ok := <-logCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
close(drainStop)
|
||||
<-drainDone
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"policy_version": 1,
|
||||
"blocked_commands": [
|
||||
"greeter install",
|
||||
"greeter enable",
|
||||
"greeter uninstall",
|
||||
"setup"
|
||||
],
|
||||
"message": "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes."
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var authCmd = &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Manage DMS authentication sync",
|
||||
Long: "Manage PAM/authentication setup for the DMS lock screen",
|
||||
}
|
||||
|
||||
var authSyncCmd = &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "Sync DMS authentication configuration",
|
||||
Long: "Apply shared PAM/authentication changes for the lock screen and greeter based on current DMS settings",
|
||||
PreRunE: preRunPrivileged,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
term, _ := cmd.Flags().GetBool("terminal")
|
||||
if term {
|
||||
if err := syncAuthInTerminal(yes); err != nil {
|
||||
log.Fatalf("Error launching auth sync in terminal: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := syncAuth(yes); err != nil {
|
||||
log.Fatalf("Error syncing authentication: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
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 {
|
||||
if !nonInteractive {
|
||||
fmt.Println("=== DMS Authentication Sync ===")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
logFunc := func(msg string) {
|
||||
fmt.Println(msg)
|
||||
}
|
||||
|
||||
if err := sharedpam.SyncAuthConfig(logFunc, "", sharedpam.SyncAuthOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !nonInteractive {
|
||||
fmt.Println("\n=== Authentication Sync Complete ===")
|
||||
fmt.Println("\nAuthentication changes have been applied.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncAuthInTerminal(nonInteractive bool) error {
|
||||
syncFlags := make([]string, 0, 1)
|
||||
if nonInteractive {
|
||||
syncFlags = append(syncFlags, "--yes")
|
||||
}
|
||||
|
||||
shellSyncCmd := "dms auth sync"
|
||||
if len(syncFlags) > 0 {
|
||||
shellSyncCmd += " " + strings.Join(syncFlags, " ")
|
||||
}
|
||||
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,40 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/blur"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var blurCmd = &cobra.Command{
|
||||
Use: "blur",
|
||||
Short: "Background blur utilities",
|
||||
}
|
||||
|
||||
var blurCheckCmd = &cobra.Command{
|
||||
Use: "check",
|
||||
Short: "Check if the compositor supports background blur (ext-background-effect-v1)",
|
||||
Args: cobra.NoArgs,
|
||||
Run: runBlurCheck,
|
||||
}
|
||||
|
||||
func init() {
|
||||
blurCmd.AddCommand(blurCheckCmd)
|
||||
}
|
||||
|
||||
func runBlurCheck(cmd *cobra.Command, args []string) {
|
||||
supported, err := blur.ProbeSupport()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch supported {
|
||||
case true:
|
||||
fmt.Println("supported")
|
||||
default:
|
||||
fmt.Println("unsupported")
|
||||
}
|
||||
}
|
||||
@@ -236,7 +236,6 @@ func runBrightnessSet(cmd *cobra.Command, args []string) {
|
||||
defer ddc.Close()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if err := ddc.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, nil); err == nil {
|
||||
ddc.WaitPending()
|
||||
fmt.Printf("Set %s to %d%%\n", deviceID, percent)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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,27 +216,22 @@ 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) {
|
||||
var data []byte
|
||||
copyFromStdin := false
|
||||
|
||||
switch {
|
||||
case len(args) > 0:
|
||||
data = []byte(args[0])
|
||||
case clipCopyDownload || clipCopyType == "__multi__":
|
||||
default:
|
||||
var err error
|
||||
data, err = io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
log.Fatalf("read stdin: %v", err)
|
||||
}
|
||||
default:
|
||||
copyFromStdin = true
|
||||
}
|
||||
|
||||
if clipCopyDownload {
|
||||
@@ -271,13 +257,6 @@ func runClipCopy(cmd *cobra.Command, args []string) {
|
||||
return
|
||||
}
|
||||
|
||||
if copyFromStdin {
|
||||
if err := clipboard.CopyReader(os.Stdin, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil {
|
||||
log.Fatalf("copy: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := clipboard.CopyOpts(data, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil {
|
||||
log.Fatalf("copy: %v", err)
|
||||
}
|
||||
@@ -325,12 +304,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()
|
||||
|
||||
@@ -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)
|
||||
@@ -45,9 +37,6 @@ Output format flags (mutually exclusive, default: --hex):
|
||||
--cmyk - CMYK values (C% M% Y% K%)
|
||||
--json - JSON with all formats
|
||||
|
||||
Optional:
|
||||
--raw - Removes ANSI escape codes and background colors. Use this when piping to other commands
|
||||
|
||||
Examples:
|
||||
dms color pick # Pick color, output as hex
|
||||
dms color pick --rgb # Output as RGB
|
||||
@@ -64,7 +53,6 @@ func init() {
|
||||
colorPickCmd.Flags().Bool("hsv", false, "Output as HSV (H S% V%)")
|
||||
colorPickCmd.Flags().Bool("cmyk", false, "Output as CMYK (C% M% Y% K%)")
|
||||
colorPickCmd.Flags().Bool("json", false, "Output all formats as JSON")
|
||||
colorPickCmd.Flags().Bool("raw", false, "Removes ANSI escape codes and background colors. Use this when piping to other commands")
|
||||
colorPickCmd.Flags().StringVarP(&colorOutputFmt, "output-format", "o", "", "Custom output format template")
|
||||
colorPickCmd.Flags().BoolVarP(&colorAutocopy, "autocopy", "a", false, "Copy result to clipboard")
|
||||
colorPickCmd.Flags().BoolVarP(&colorLowercase, "lowercase", "l", false, "Output hex in lowercase")
|
||||
@@ -125,15 +113,7 @@ func runColorPick(cmd *cobra.Command, args []string) {
|
||||
|
||||
if jsonOutput {
|
||||
fmt.Println(output)
|
||||
return
|
||||
}
|
||||
|
||||
if raw, _ := cmd.Flags().GetBool("raw"); raw {
|
||||
fmt.Printf("%s\n", output)
|
||||
return
|
||||
}
|
||||
|
||||
if color.IsDark() {
|
||||
} else if color.IsDark() {
|
||||
fmt.Printf("\033[48;2;%d;%d;%dm\033[97m %s \033[0m\n", color.R, color.G, color.B, output)
|
||||
} else {
|
||||
fmt.Printf("\033[48;2;%d;%d;%dm\033[30m %s \033[0m\n", color.R, color.G, color.B, output)
|
||||
|
||||
+75
-277
@@ -9,7 +9,6 @@ import (
|
||||
"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 +18,57 @@ 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 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",
|
||||
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`,
|
||||
Use: "ipc [target] [function] [args...]",
|
||||
Short: "Send IPC commands to running DMS shell",
|
||||
PreRunE: findConfig,
|
||||
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 +76,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 +159,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 +170,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 +205,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 +255,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 +278,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 +318,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 +498,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 +515,6 @@ func getCommonCommands() []*cobra.Command {
|
||||
greeterCmd,
|
||||
setupCmd,
|
||||
colorCmd,
|
||||
qrCmd,
|
||||
screenshotCmd,
|
||||
notifyActionCmd,
|
||||
notifyCmd,
|
||||
@@ -722,10 +525,5 @@ func getCommonCommands() []*cobra.Command {
|
||||
doctorCmd,
|
||||
configCmd,
|
||||
dlCmd,
|
||||
randrCmd,
|
||||
blurCmd,
|
||||
trashCmd,
|
||||
systemCmd,
|
||||
switchUserCmd,
|
||||
}...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/luaconfig"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -28,23 +27,7 @@ var resolveIncludeCmd = &cobra.Command{
|
||||
case 0:
|
||||
return []string{"hyprland", "niri", "mangowc"}, cobra.ShellCompDirectiveNoFileComp
|
||||
case 1:
|
||||
return []string{
|
||||
"binds.lua",
|
||||
"binds-user.lua",
|
||||
"colors.lua",
|
||||
"layout.lua",
|
||||
"outputs.lua",
|
||||
"cursor.lua",
|
||||
"windowrules.lua",
|
||||
"cursor.kdl",
|
||||
"layout.kdl",
|
||||
"outputs.kdl",
|
||||
"binds.kdl",
|
||||
"cursor.conf",
|
||||
"layout.conf",
|
||||
"outputs.conf",
|
||||
"binds.conf",
|
||||
}, cobra.ShellCompDirectiveNoFileComp
|
||||
return []string{"cursor.kdl", "cursor.conf", "outputs.kdl", "outputs.conf", "binds.kdl", "binds.conf"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
@@ -56,10 +39,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 +55,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 +70,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{}
|
||||
@@ -98,41 +82,17 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
|
||||
result.Exists = true
|
||||
}
|
||||
|
||||
targetAbs, err := filepath.Abs(targetPath)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
targetRel := filepath.ToSlash(filepath.Join("dms", filename))
|
||||
|
||||
mainLua := filepath.Join(configDir, "hyprland.lua")
|
||||
if _, err := os.Stat(mainLua); err == nil {
|
||||
result.ConfigFormat = "lua"
|
||||
result.ReadOnly = false
|
||||
processedLua := make(map[string]bool)
|
||||
if luaconfig.RequiresTarget(mainLua, targetAbs, processedLua) {
|
||||
result.Included = true
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
return result, nil
|
||||
}
|
||||
mainConfig := filepath.Join(configDir, "hyprland.conf")
|
||||
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
processed := make(map[string]bool)
|
||||
result.Included = hyprlandFindInclude(mainConfig, "dms/"+filename, processed)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]bool) bool {
|
||||
func hyprlandFindInclude(filePath, target string, processed map[string]bool) bool {
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -181,7 +141,7 @@ func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]b
|
||||
continue
|
||||
}
|
||||
|
||||
if hyprlandFindIncludeHyprlang(expanded, target, processed) {
|
||||
if hyprlandFindInclude(expanded, target, processed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -190,7 +150,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 +229,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{}
|
||||
|
||||
+39
-214
@@ -11,7 +11,6 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/blur"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
@@ -83,7 +82,7 @@ func (ds *DoctorStatus) OKCount() int {
|
||||
}
|
||||
|
||||
var (
|
||||
quickshellVersionRegex = regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`)
|
||||
quickshellVersionRegex = regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`)
|
||||
hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
|
||||
niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`)
|
||||
swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`)
|
||||
@@ -91,7 +90,6 @@ var (
|
||||
wayfireVersionRegex = regexp.MustCompile(`wayfire (\d+\.\d+)`)
|
||||
labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`)
|
||||
mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`)
|
||||
miracleVersionRegex = regexp.MustCompile(`miracle-wm v?(\d+\.\d+\.\d+)`)
|
||||
)
|
||||
|
||||
var doctorCmd = &cobra.Command{
|
||||
@@ -125,7 +123,6 @@ const (
|
||||
catConfigFiles
|
||||
catServices
|
||||
catEnvironment
|
||||
catFonts
|
||||
)
|
||||
|
||||
func (c category) String() string {
|
||||
@@ -148,8 +145,6 @@ func (c category) String() string {
|
||||
return "Services"
|
||||
case catEnvironment:
|
||||
return "Environment"
|
||||
case catFonts:
|
||||
return "Fonts"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
@@ -216,7 +211,6 @@ func runDoctor(cmd *cobra.Command, args []string) {
|
||||
checkConfigurationFiles(),
|
||||
checkSystemdServices(),
|
||||
checkEnvironmentVars(),
|
||||
checkFonts(),
|
||||
)
|
||||
|
||||
switch {
|
||||
@@ -320,7 +314,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 +328,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 +371,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 +420,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
|
||||
}
|
||||
@@ -498,7 +468,6 @@ func checkWindowManagers() []checkResult {
|
||||
{"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}},
|
||||
{"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}},
|
||||
{"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}},
|
||||
{"Miracle WM", "miracle-wm", "--version", miracleVersionRegex, []string{"miracle-wm"}},
|
||||
}
|
||||
|
||||
var results []checkResult
|
||||
@@ -531,7 +500,7 @@ func checkWindowManagers() []checkResult {
|
||||
results = append(results, checkResult{
|
||||
catCompositor, "Compositor", statusError,
|
||||
"No supported Wayland compositor found",
|
||||
"Install Hyprland, niri, Sway, River, Wayfire, or miracle-wm",
|
||||
"Install Hyprland, niri, Sway, River, or Wayfire",
|
||||
doctorDocsURL + "#compositor-checks",
|
||||
})
|
||||
}
|
||||
@@ -540,24 +509,9 @@ func checkWindowManagers() []checkResult {
|
||||
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
|
||||
}
|
||||
|
||||
results = append(results, checkCompositorBlurSupport())
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func checkCompositorBlurSupport() checkResult {
|
||||
supported, err := blur.ProbeSupport()
|
||||
if err != nil {
|
||||
return checkResult{catCompositor, "Background Blur", statusInfo, "Unable to verify", err.Error(), doctorDocsURL + "#compositor-checks"}
|
||||
}
|
||||
|
||||
if supported {
|
||||
return checkResult{catCompositor, "Background Blur", statusOK, "Supported", "Compositor supports ext-background-effect-v1", doctorDocsURL + "#compositor-checks"}
|
||||
}
|
||||
|
||||
return checkResult{catCompositor, "Background Blur", statusWarn, "Unsupported", "Compositor does not support ext-background-effect-v1", doctorDocsURL + "#compositor-checks"}
|
||||
}
|
||||
|
||||
func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string {
|
||||
output, err := exec.Command(cmd, arg).CombinedOutput()
|
||||
if err != nil && len(output) == 0 {
|
||||
@@ -581,10 +535,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") != "":
|
||||
return os.Getenv("XDG_CURRENT_DESKTOP")
|
||||
}
|
||||
@@ -603,7 +553,6 @@ func checkQuickshellFeatures() ([]checkResult, bool) {
|
||||
qmlContent := `
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
|
||||
ShellRoot {
|
||||
id: root
|
||||
@@ -612,7 +561,6 @@ ShellRoot {
|
||||
property bool idleMonitorAvailable: false
|
||||
property bool idleInhibitorAvailable: false
|
||||
property bool shortcutInhibitorAvailable: false
|
||||
property bool backgroundBlurAvailable: false
|
||||
|
||||
Timer {
|
||||
interval: 50
|
||||
@@ -630,18 +578,16 @@ ShellRoot {
|
||||
|
||||
try {
|
||||
var testItem = Qt.createQmlObject(
|
||||
'import Quickshell; import Quickshell.Wayland; import QtQuick; QtObject { ' +
|
||||
'import Quickshell.Wayland; import QtQuick; QtObject { ' +
|
||||
'readonly property bool hasIdleMonitor: typeof IdleMonitor !== "undefined"; ' +
|
||||
'readonly property bool hasIdleInhibitor: typeof IdleInhibitor !== "undefined"; ' +
|
||||
'readonly property bool hasShortcutInhibitor: typeof ShortcutInhibitor !== "undefined"; ' +
|
||||
'readonly property bool hasBackgroundBlur: typeof BackgroundEffect !== "undefined" ' +
|
||||
'readonly property bool hasShortcutInhibitor: typeof ShortcutInhibitor !== "undefined" ' +
|
||||
'}',
|
||||
root
|
||||
)
|
||||
root.idleMonitorAvailable = testItem.hasIdleMonitor
|
||||
root.idleInhibitorAvailable = testItem.hasIdleInhibitor
|
||||
root.shortcutInhibitorAvailable = testItem.hasShortcutInhibitor
|
||||
root.backgroundBlurAvailable = testItem.hasBackgroundBlur
|
||||
testItem.destroy()
|
||||
} catch (e) {}
|
||||
|
||||
@@ -650,8 +596,6 @@ ShellRoot {
|
||||
console.warn(root.idleInhibitorAvailable ? "FEATURE:IdleInhibitor:OK" : "FEATURE:IdleInhibitor:UNAVAILABLE")
|
||||
console.warn(root.shortcutInhibitorAvailable ? "FEATURE:ShortcutInhibitor:OK" : "FEATURE:ShortcutInhibitor:UNAVAILABLE")
|
||||
|
||||
console.warn(root.backgroundBlurAvailable ? "FEATURE:BackgroundBlur:OK" : "FEATURE:BackgroundBlur:UNAVAILABLE")
|
||||
|
||||
Quickshell.execDetached(["kill", "-TERM", String(Quickshell.processId)])
|
||||
}
|
||||
}
|
||||
@@ -672,7 +616,6 @@ ShellRoot {
|
||||
{"IdleMonitor", "Idle detection"},
|
||||
{"IdleInhibitor", "Prevent idle/sleep"},
|
||||
{"ShortcutInhibitor", "Allow shortcut management (niri)"},
|
||||
{"BackgroundBlur", "Background blur API support in Quickshell"},
|
||||
}
|
||||
|
||||
var results []checkResult
|
||||
@@ -709,14 +652,16 @@ func checkI2CAvailability() checkResult {
|
||||
func checkImageFormatPlugins() []checkResult {
|
||||
url := doctorDocsURL + "#optional-features"
|
||||
|
||||
pluginDirs := findQtPluginDirs()
|
||||
if len(pluginDirs) == 0 {
|
||||
pluginDir := findQtPluginDir()
|
||||
if pluginDir == "" {
|
||||
return []checkResult{
|
||||
{catOptionalFeatures, "qt6-imageformats", statusInfo, "Cannot detect (plugin dir not found)", "WebP, TIFF, JP2 support", url},
|
||||
{catOptionalFeatures, "kimageformats", statusInfo, "Cannot detect (plugin dir not found)", "AVIF, HEIF, JXL support", url},
|
||||
}
|
||||
}
|
||||
|
||||
imageFormatsDir := filepath.Join(pluginDir, "imageformats")
|
||||
|
||||
type pluginCheck struct {
|
||||
name string
|
||||
desc string
|
||||
@@ -750,18 +695,9 @@ func checkImageFormatPlugins() []checkResult {
|
||||
var results []checkResult
|
||||
for _, c := range checks {
|
||||
var found []string
|
||||
var foundDirs []string
|
||||
for _, pluginDir := range pluginDirs {
|
||||
imageFormatsDir := filepath.Join(pluginDir, "imageformats")
|
||||
for _, p := range c.plugins {
|
||||
if _, err := os.Stat(filepath.Join(imageFormatsDir, p.file)); err == nil {
|
||||
if !slices.Contains(found, p.format) {
|
||||
found = append(found, p.format)
|
||||
}
|
||||
if !slices.Contains(foundDirs, imageFormatsDir) {
|
||||
foundDirs = append(foundDirs, imageFormatsDir)
|
||||
}
|
||||
}
|
||||
for _, p := range c.plugins {
|
||||
if _, err := os.Stat(filepath.Join(imageFormatsDir, p.file)); err == nil {
|
||||
found = append(found, p.format)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,7 +708,7 @@ func checkImageFormatPlugins() []checkResult {
|
||||
default:
|
||||
details := ""
|
||||
if doctorVerbose {
|
||||
details = fmt.Sprintf("Formats: %s (%s)", strings.Join(found, ", "), strings.Join(foundDirs, ":"))
|
||||
details = fmt.Sprintf("Formats: %s (%s)", strings.Join(found, ", "), imageFormatsDir)
|
||||
}
|
||||
result = checkResult{catOptionalFeatures, c.name, statusOK, fmt.Sprintf("Installed (%d formats)", len(found)), details, url}
|
||||
}
|
||||
@@ -782,28 +718,22 @@ func checkImageFormatPlugins() []checkResult {
|
||||
return results
|
||||
}
|
||||
|
||||
func findQtPluginDirs() []string {
|
||||
var dirs []string
|
||||
|
||||
addDir := func(dir string) {
|
||||
if dir != "" {
|
||||
if _, err := os.Stat(filepath.Join(dir, "imageformats")); err == nil {
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check all paths in QT_PLUGIN_PATH env var (used by NixOS and custom setups)
|
||||
func findQtPluginDir() string {
|
||||
// Check QT_PLUGIN_PATH env var first (used by NixOS and custom setups)
|
||||
if envPath := os.Getenv("QT_PLUGIN_PATH"); envPath != "" {
|
||||
for dir := range strings.SplitSeq(envPath, ":") {
|
||||
addDir(dir)
|
||||
if _, err := os.Stat(filepath.Join(dir, "imageformats")); err == nil {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try qtpaths
|
||||
for _, cmd := range []string{"qtpaths6", "qtpaths"} {
|
||||
if output, err := exec.Command(cmd, "-query", "QT_INSTALL_PLUGINS").Output(); err == nil {
|
||||
addDir(strings.TrimSpace(string(output)))
|
||||
if dir := strings.TrimSpace(string(output)); dir != "" {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,10 +744,12 @@ func findQtPluginDirs() []string {
|
||||
"/usr/lib/x86_64-linux-gnu/qt6/plugins",
|
||||
"/usr/lib/aarch64-linux-gnu/qt6/plugins",
|
||||
} {
|
||||
addDir(dir)
|
||||
if _, err := os.Stat(filepath.Join(dir, "imageformats")); err == nil {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
|
||||
return dirs
|
||||
return ""
|
||||
}
|
||||
|
||||
func detectNetworkBackend(stackResult *network.DetectResult) string {
|
||||
@@ -833,8 +765,6 @@ func detectNetworkBackend(stackResult *network.DetectResult) string {
|
||||
return "systemd-networkd"
|
||||
case network.BackendConnMan:
|
||||
return "ConnMan"
|
||||
case network.BackendWpaSupplicant:
|
||||
return "wpa_supplicant"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -879,14 +809,10 @@ func checkOptionalDependencies() []checkResult {
|
||||
results = append(results, checkImageFormatPlugins()...)
|
||||
|
||||
terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"}
|
||||
terminals = slices.DeleteFunc(terminals, func(t string) bool {
|
||||
return !utils.CommandExists(t)
|
||||
})
|
||||
|
||||
if len(terminals) > 0 {
|
||||
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, strings.Join(terminals, ", "), "", optionalFeaturesURL})
|
||||
if idx := slices.IndexFunc(terminals, utils.CommandExists); idx >= 0 {
|
||||
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, terminals[idx], "", optionalFeaturesURL})
|
||||
} else {
|
||||
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, foot or alacritty", optionalFeaturesURL})
|
||||
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, or alacritty", optionalFeaturesURL})
|
||||
}
|
||||
|
||||
networkResult, err := network.DetectNetworkStack()
|
||||
@@ -912,7 +838,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 +905,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"})
|
||||
@@ -1146,14 +1068,14 @@ func formatResultsPlain(results []checkResult) string {
|
||||
if currentCategory != -1 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
fmt.Fprintf(&sb, "**%s**\n", r.category.String())
|
||||
sb.WriteString(fmt.Sprintf("**%s**\n", r.category.String()))
|
||||
currentCategory = r.category
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, "- [%s] %s: %s\n", r.status, r.name, r.message)
|
||||
sb.WriteString(fmt.Sprintf("- [%s] %s: %s\n", r.status, r.name, r.message))
|
||||
|
||||
if doctorVerbose && r.details != "" {
|
||||
fmt.Fprintf(&sb, " - %s\n", r.details)
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", r.details))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1163,105 +1085,8 @@ func formatResultsPlain(results []checkResult) string {
|
||||
}
|
||||
|
||||
sb.WriteString("\n---\n")
|
||||
fmt.Fprintf(&sb, "**Summary:** %d error(s), %d warning(s), %d ok\n",
|
||||
ds.ErrorCount(), ds.WarningCount(), ds.OKCount())
|
||||
sb.WriteString(fmt.Sprintf("**Summary:** %d error(s), %d warning(s), %d ok\n",
|
||||
ds.ErrorCount(), ds.WarningCount(), ds.OKCount()))
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -16,7 +15,6 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -26,7 +24,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 +96,7 @@ func runUpdate() {
|
||||
}
|
||||
|
||||
log.Info("Update complete! Restarting DMS...")
|
||||
shellApp.Restart()
|
||||
restartShell()
|
||||
}
|
||||
|
||||
func updateArchLinux() error {
|
||||
@@ -111,37 +109,16 @@ func updateArchLinux() error {
|
||||
}
|
||||
|
||||
var packageName string
|
||||
var isAUR bool
|
||||
if isArchPackageInstalled("dms-shell") {
|
||||
packageName = "dms-shell"
|
||||
if isArchPackageInstalled("dms-shell-bin") {
|
||||
packageName = "dms-shell-bin"
|
||||
} else if isArchPackageInstalled("dms-shell-git") {
|
||||
packageName = "dms-shell-git"
|
||||
isAUR = true
|
||||
} else if isArchPackageInstalled("dms-shell-bin") {
|
||||
packageName = "dms-shell-bin"
|
||||
isAUR = true
|
||||
} else {
|
||||
fmt.Println("Info: No dms-shell package found.")
|
||||
fmt.Println("Info: Neither dms-shell-bin nor dms-shell-git package found.")
|
||||
fmt.Println("Info: Falling back to git-based update method...")
|
||||
return updateOtherDistros()
|
||||
}
|
||||
|
||||
if !isAUR {
|
||||
fmt.Printf("This will update %s using pacman.\n", packageName)
|
||||
if !confirmUpdate() {
|
||||
return errdefs.ErrUpdateCancelled
|
||||
}
|
||||
|
||||
fmt.Printf("\nRunning: pacman -S %s\n", packageName)
|
||||
if err := privesc.Run(context.Background(), "", "pacman", "-S", "--noconfirm", packageName); err != nil {
|
||||
fmt.Printf("Error: Failed to update using pacman: %v\n", err)
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("dms successfully updated")
|
||||
return nil
|
||||
}
|
||||
|
||||
var helper string
|
||||
var updateCmd *exec.Cmd
|
||||
|
||||
@@ -250,10 +227,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 +268,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 +297,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 +319,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 {
|
||||
@@ -496,7 +454,11 @@ func updateDMSBinary() error {
|
||||
|
||||
fmt.Printf("Installing to %s...\n", currentPath)
|
||||
|
||||
if err := privesc.Run(context.Background(), "", "install", "-m", "0755", decompressedPath, currentPath); err != nil {
|
||||
replaceCmd := exec.Command("sudo", "install", "-m", "0755", decompressedPath, currentPath)
|
||||
replaceCmd.Stdin = os.Stdin
|
||||
replaceCmd.Stdout = os.Stdout
|
||||
replaceCmd.Stderr = os.Stderr
|
||||
if err := replaceCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to replace binary: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,68 +4,777 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"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/utils"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
var greeterCmd = &cobra.Command{
|
||||
Use: "greeter",
|
||||
Short: "Deprecated: moved to the standalone dms-greeter binary",
|
||||
Long: "The greeter has moved to the standalone dms-greeter package.\nThis command forwards to 'dms-greeter' when it is installed.",
|
||||
DisableFlagParsing: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
binary, err := exec.LookPath("dms-greeter")
|
||||
if err != nil {
|
||||
return fmt.Errorf("'dms greeter' has moved to the standalone dms-greeter package.\n %s", greeterPackageInstallHint())
|
||||
Use: "greeter",
|
||||
Short: "Manage DMS greeter",
|
||||
Long: "Manage DMS greeter (greetd)",
|
||||
}
|
||||
|
||||
var greeterInstallCmd = &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Install and configure DMS greeter",
|
||||
Long: "Install greetd and configure it to use DMS as the greeter interface",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := installGreeter(); err != nil {
|
||||
log.Fatalf("Error installing greeter: %v", err)
|
||||
}
|
||||
if isLegacyWrapperScript(binary) {
|
||||
return fmt.Errorf("'dms greeter' has moved to the standalone dms-greeter package; %s is the old wrapper script.\n %s", binary, greeterPackageInstallHint())
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "warning: 'dms greeter' is deprecated; use 'dms-greeter' directly")
|
||||
return syscall.Exec(binary, append([]string{"dms-greeter"}, args...), os.Environ())
|
||||
},
|
||||
}
|
||||
|
||||
func isLegacyWrapperScript(path string) bool {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer file.Close()
|
||||
header := make([]byte, 2)
|
||||
if _, err := file.Read(header); err != nil {
|
||||
return false
|
||||
}
|
||||
return string(header) == "#!"
|
||||
var greeterSyncCmd = &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "Sync DMS theme and settings with greeter",
|
||||
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := syncGreeter(); err != nil {
|
||||
log.Fatalf("Error syncing greeter: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func greeterPackageInstallHint() string {
|
||||
osInfo, err := distros.GetOSInfo()
|
||||
if err != nil {
|
||||
return "Install package: dms-greeter"
|
||||
}
|
||||
config, exists := distros.Registry[osInfo.Distribution.ID]
|
||||
if !exists {
|
||||
return "Install package: dms-greeter"
|
||||
var greeterEnableCmd = &cobra.Command{
|
||||
Use: "enable",
|
||||
Short: "Enable DMS greeter in greetd config",
|
||||
Long: "Configure greetd to use DMS as the greeter",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := enableGreeter(); err != nil {
|
||||
log.Fatalf("Error enabling greeter: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var greeterStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Check greeter sync status",
|
||||
Long: "Check the status of greeter installation and configuration sync",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := checkGreeterStatus(); err != nil {
|
||||
log.Fatalf("Error checking greeter status: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func installGreeter() error {
|
||||
fmt.Println("=== DMS Greeter Installation ===")
|
||||
|
||||
logFunc := func(msg string) {
|
||||
fmt.Println(msg)
|
||||
}
|
||||
|
||||
switch config.Family {
|
||||
case distros.FamilyDebian:
|
||||
return "Install with 'sudo apt install dms-greeter' (requires DankLinux OBS repo — see https://danklinux.com/docs/dankgreeter/installation#debian)"
|
||||
case distros.FamilySUSE:
|
||||
return "Install with 'sudo zypper install dms-greeter' (requires DankLinux OBS repo — see https://danklinux.com/docs/dankgreeter/installation#opensuse)"
|
||||
case distros.FamilyUbuntu:
|
||||
return "Install with 'sudo apt install dms-greeter' (requires ppa:avengemedia/danklinux: sudo add-apt-repository ppa:avengemedia/danklinux)"
|
||||
case distros.FamilyFedora:
|
||||
return "Install with 'sudo dnf install dms-greeter' (requires COPR: sudo dnf copr enable avengemedia/danklinux)"
|
||||
case distros.FamilyArch:
|
||||
return "Install from AUR with 'paru -S greetd-dms-greeter-git' or 'yay -S greetd-dms-greeter-git'"
|
||||
case distros.FamilyVoid:
|
||||
return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=https://void.danklinux.com/dms/current' | sudo tee /etc/xbps.d/dms.conf)"
|
||||
if err := greeter.EnsureGreetdInstalled(logFunc, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("\nDetecting DMS installation...")
|
||||
dmsPath, err := greeter.DetectDMSPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ Found DMS at: %s\n", dmsPath)
|
||||
|
||||
fmt.Println("\nDetecting installed compositors...")
|
||||
compositors := greeter.DetectCompositors()
|
||||
if len(compositors) == 0 {
|
||||
return fmt.Errorf("no supported compositors found (niri or Hyprland required)")
|
||||
}
|
||||
|
||||
var selectedCompositor string
|
||||
if len(compositors) == 1 {
|
||||
selectedCompositor = compositors[0]
|
||||
fmt.Printf("✓ Found compositor: %s\n", selectedCompositor)
|
||||
} else {
|
||||
var err error
|
||||
selectedCompositor, err = greeter.PromptCompositorChoice(compositors)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ Selected compositor: %s\n", selectedCompositor)
|
||||
}
|
||||
|
||||
fmt.Println("\nSetting up dms-greeter group and permissions...")
|
||||
if err := greeter.SetupDMSGroup(logFunc, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("\nCopying greeter files...")
|
||||
if err := greeter.CopyGreeterFiles(dmsPath, selectedCompositor, logFunc, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("\nConfiguring greetd...")
|
||||
if err := greeter.ConfigureGreetd(dmsPath, selectedCompositor, logFunc, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("\nSynchronizing DMS configurations...")
|
||||
if err := greeter.SyncDMSConfigs(dmsPath, selectedCompositor, logFunc, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Installation Complete ===")
|
||||
fmt.Println("\nTo test the greeter, run:")
|
||||
fmt.Println(" sudo systemctl start greetd")
|
||||
fmt.Println("\nTo enable on boot, run:")
|
||||
fmt.Println(" sudo systemctl enable --now greetd")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncGreeter() error {
|
||||
fmt.Println("=== DMS Greeter Theme Sync ===")
|
||||
fmt.Println()
|
||||
|
||||
logFunc := func(msg string) {
|
||||
fmt.Println(msg)
|
||||
}
|
||||
|
||||
fmt.Println("Detecting DMS installation...")
|
||||
dmsPath, err := greeter.DetectDMSPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ Found DMS at: %s\n", dmsPath)
|
||||
|
||||
if !isGreeterEnabled() {
|
||||
fmt.Println("\n⚠ DMS greeter is not enabled in greetd config.")
|
||||
fmt.Print("Would you like to enable it now? (Y/n): ")
|
||||
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
|
||||
if response != "n" && response != "no" {
|
||||
if err := enableGreeter(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("greeter must be enabled before syncing")
|
||||
}
|
||||
}
|
||||
|
||||
cacheDir := "/var/cache/dms-greeter"
|
||||
if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("greeter cache directory not found at %s\nPlease install the greeter first", cacheDir)
|
||||
}
|
||||
|
||||
greeterGroup := greeter.DetectGreeterGroup()
|
||||
greeterGroupExists := utils.HasGroup(greeterGroup)
|
||||
if greeterGroupExists {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current user: %w", err)
|
||||
}
|
||||
|
||||
groupsCmd := exec.Command("groups", currentUser.Username)
|
||||
groupsOutput, err := groupsCmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check groups: %w", err)
|
||||
}
|
||||
|
||||
inGreeterGroup := strings.Contains(string(groupsOutput), greeterGroup)
|
||||
if !inGreeterGroup {
|
||||
fmt.Printf("\n⚠ Warning: You are not in the %s group.\n", greeterGroup)
|
||||
fmt.Printf("Would you like to add your user to the %s group? (Y/n): ", greeterGroup)
|
||||
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
|
||||
if response != "n" && response != "no" {
|
||||
fmt.Printf("\nAdding user to %s group...\n", greeterGroup)
|
||||
addUserCmd := exec.Command("sudo", "usermod", "-aG", greeterGroup, currentUser.Username)
|
||||
addUserCmd.Stdout = os.Stdout
|
||||
addUserCmd.Stderr = os.Stderr
|
||||
if err := addUserCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to add user to %s group: %w", greeterGroup, err)
|
||||
}
|
||||
fmt.Printf("✓ User added to %s group\n", greeterGroup)
|
||||
fmt.Println("⚠ You will need to log out and back in for the group change to take effect")
|
||||
} else {
|
||||
return fmt.Errorf("aborted: user must be in the greeter group before syncing")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compositor := detectConfiguredCompositor()
|
||||
if compositor == "" {
|
||||
compositors := greeter.DetectCompositors()
|
||||
switch len(compositors) {
|
||||
case 0:
|
||||
return fmt.Errorf("no supported compositors found")
|
||||
case 1:
|
||||
compositor = compositors[0]
|
||||
fmt.Printf("✓ Using compositor: %s\n", compositor)
|
||||
default:
|
||||
var err error
|
||||
compositor, err = promptCompositorChoice(compositors)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ Selected compositor: %s\n", compositor)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("✓ Detected compositor from config: %s\n", compositor)
|
||||
}
|
||||
|
||||
fmt.Println("\nSetting up permissions and ACLs...")
|
||||
if err := greeter.SetupDMSGroup(logFunc, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("\nSynchronizing DMS configurations...")
|
||||
if err := greeter.SyncDMSConfigs(dmsPath, compositor, logFunc, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Sync Complete ===")
|
||||
fmt.Println("\nYour theme, settings, and wallpaper configuration have been synced with the greeter.")
|
||||
fmt.Println("The changes will be visible on the next login screen.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func disableDisplayManager(dmName string) (bool, error) {
|
||||
state, err := getSystemdServiceState(dmName)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check %s state: %w", dmName, err)
|
||||
}
|
||||
|
||||
if !state.Exists {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
fmt.Printf("\nChecking %s...\n", dmName)
|
||||
fmt.Printf(" Current state: enabled=%s\n", state.EnabledState)
|
||||
|
||||
actionTaken := false
|
||||
|
||||
if state.NeedsDisable {
|
||||
var disableCmd *exec.Cmd
|
||||
var actionVerb string
|
||||
|
||||
if state.EnabledState == "static" {
|
||||
fmt.Printf(" Masking %s (static service cannot be disabled)...\n", dmName)
|
||||
disableCmd = exec.Command("sudo", "systemctl", "mask", dmName)
|
||||
actionVerb = "masked"
|
||||
} else {
|
||||
fmt.Printf(" Disabling %s...\n", dmName)
|
||||
disableCmd = exec.Command("sudo", "systemctl", "disable", dmName)
|
||||
actionVerb = "disabled"
|
||||
}
|
||||
|
||||
disableCmd.Stdout = os.Stdout
|
||||
disableCmd.Stderr = os.Stderr
|
||||
if err := disableCmd.Run(); err != nil {
|
||||
return actionTaken, fmt.Errorf("failed to disable/mask %s: %w", dmName, err)
|
||||
}
|
||||
|
||||
enabledState, shouldDisable, verifyErr := checkSystemdServiceEnabled(dmName)
|
||||
if verifyErr != nil {
|
||||
fmt.Printf(" ⚠ Warning: Could not verify %s was %s: %v\n", dmName, actionVerb, verifyErr)
|
||||
} else if shouldDisable {
|
||||
return actionTaken, fmt.Errorf("%s is still in state '%s' after %s operation", dmName, enabledState, actionVerb)
|
||||
} else {
|
||||
fmt.Printf(" ✓ %s %s (now: %s)\n", cases.Title(language.English).String(actionVerb), dmName, enabledState)
|
||||
}
|
||||
|
||||
actionTaken = true
|
||||
} else {
|
||||
if state.EnabledState == "masked" || state.EnabledState == "masked-runtime" {
|
||||
fmt.Printf(" ✓ %s is already masked\n", dmName)
|
||||
} else {
|
||||
fmt.Printf(" ✓ %s is already disabled\n", dmName)
|
||||
}
|
||||
}
|
||||
|
||||
return actionTaken, nil
|
||||
}
|
||||
|
||||
func ensureGreetdEnabled() error {
|
||||
fmt.Println("\nChecking greetd service status...")
|
||||
|
||||
state, err := getSystemdServiceState("greetd")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check greetd state: %w", err)
|
||||
}
|
||||
|
||||
if !state.Exists {
|
||||
return fmt.Errorf("greetd service not found. Please install greetd first")
|
||||
}
|
||||
|
||||
fmt.Printf(" Current state: %s\n", state.EnabledState)
|
||||
|
||||
if state.EnabledState == "masked" || state.EnabledState == "masked-runtime" {
|
||||
fmt.Println(" Unmasking greetd...")
|
||||
unmaskCmd := exec.Command("sudo", "systemctl", "unmask", "greetd")
|
||||
unmaskCmd.Stdout = os.Stdout
|
||||
unmaskCmd.Stderr = os.Stderr
|
||||
if err := unmaskCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to unmask greetd: %w", err)
|
||||
}
|
||||
fmt.Println(" ✓ Unmasked greetd")
|
||||
}
|
||||
|
||||
switch state.EnabledState {
|
||||
case "disabled", "masked", "masked-runtime":
|
||||
fmt.Println(" Enabling greetd service...")
|
||||
enableCmd := exec.Command("sudo", "systemctl", "enable", "greetd")
|
||||
enableCmd.Stdout = os.Stdout
|
||||
enableCmd.Stderr = os.Stderr
|
||||
if err := enableCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to enable greetd: %w", err)
|
||||
}
|
||||
fmt.Println(" ✓ Enabled greetd service")
|
||||
case "enabled", "enabled-runtime":
|
||||
fmt.Println(" ✓ greetd is already enabled")
|
||||
default:
|
||||
return "Install the dms-greeter package for your distribution"
|
||||
fmt.Printf(" ℹ greetd is in state '%s' (should work, no action needed)\n", state.EnabledState)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureGraphicalTarget() error {
|
||||
getDefaultCmd := exec.Command("systemctl", "get-default")
|
||||
currentTarget, err := getDefaultCmd.Output()
|
||||
if err != nil {
|
||||
fmt.Println("⚠ Warning: Could not detect current default systemd target")
|
||||
return nil
|
||||
}
|
||||
|
||||
currentTargetStr := strings.TrimSpace(string(currentTarget))
|
||||
if currentTargetStr != "graphical.target" {
|
||||
fmt.Printf("\nSetting graphical.target as default (current: %s)...\n", currentTargetStr)
|
||||
setDefaultCmd := exec.Command("sudo", "systemctl", "set-default", "graphical.target")
|
||||
setDefaultCmd.Stdout = os.Stdout
|
||||
setDefaultCmd.Stderr = os.Stderr
|
||||
if err := setDefaultCmd.Run(); err != nil {
|
||||
fmt.Println("⚠ Warning: Failed to set graphical.target as default")
|
||||
fmt.Println(" Greeter may not start on boot. Run manually:")
|
||||
fmt.Println(" sudo systemctl set-default graphical.target")
|
||||
return nil
|
||||
}
|
||||
fmt.Println("✓ Set graphical.target as default")
|
||||
} else {
|
||||
fmt.Println("✓ Default target already set to graphical.target")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleConflictingDisplayManagers() error {
|
||||
fmt.Println("\n=== Checking for Conflicting Display Managers ===")
|
||||
|
||||
conflictingDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"}
|
||||
|
||||
disabledAny := false
|
||||
var errors []string
|
||||
|
||||
for _, dm := range conflictingDMs {
|
||||
actionTaken, err := disableDisplayManager(dm)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("Failed to handle %s: %v", dm, err)
|
||||
errors = append(errors, errMsg)
|
||||
fmt.Printf(" ⚠⚠⚠ ERROR: %s\n", errMsg)
|
||||
continue
|
||||
}
|
||||
if actionTaken {
|
||||
disabledAny = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
fmt.Println("\n╔════════════════════════════════════════════════════════════╗")
|
||||
fmt.Println("║ ⚠⚠⚠ ERRORS OCCURRED ⚠⚠⚠ ║")
|
||||
fmt.Println("╚════════════════════════════════════════════════════════════╝")
|
||||
fmt.Println("\nSome display managers could not be disabled:")
|
||||
for _, err := range errors {
|
||||
fmt.Printf(" ✗ %s\n", err)
|
||||
}
|
||||
fmt.Println("\nThis may prevent greetd from starting properly.")
|
||||
fmt.Println("You may need to manually disable them before greetd will work.")
|
||||
fmt.Println("\nManual commands to try:")
|
||||
for _, dm := range conflictingDMs {
|
||||
fmt.Printf(" sudo systemctl disable %s\n", dm)
|
||||
fmt.Printf(" sudo systemctl mask %s\n", dm)
|
||||
}
|
||||
fmt.Print("\nContinue with greeter enablement anyway? (Y/n): ")
|
||||
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
|
||||
if response == "n" || response == "no" {
|
||||
return fmt.Errorf("aborted due to display manager conflicts")
|
||||
}
|
||||
fmt.Println("\nContinuing despite errors...")
|
||||
}
|
||||
|
||||
if !disabledAny && len(errors) == 0 {
|
||||
fmt.Println("\n✓ No conflicting display managers found")
|
||||
} else if disabledAny && len(errors) == 0 {
|
||||
fmt.Println("\n✓ Successfully handled all conflicting display managers")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func enableGreeter() error {
|
||||
fmt.Println("=== DMS Greeter Enable ===")
|
||||
fmt.Println()
|
||||
|
||||
configPath := "/etc/greetd/config.toml"
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("greetd config not found at %s\nPlease install greetd first", configPath)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read greetd config: %w", err)
|
||||
}
|
||||
|
||||
configContent := string(data)
|
||||
configAlreadyCorrect := strings.Contains(configContent, "dms-greeter")
|
||||
|
||||
if configAlreadyCorrect {
|
||||
fmt.Println("✓ Greeter is already configured with dms-greeter")
|
||||
|
||||
if err := ensureGraphicalTarget(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := handleConflictingDisplayManagers(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ensureGreetdEnabled(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Enable Complete ===")
|
||||
fmt.Println("\nGreeter configuration verified and system state corrected.")
|
||||
fmt.Println("To start the greeter now, run:")
|
||||
fmt.Println(" sudo systemctl start greetd")
|
||||
fmt.Println("\nOr reboot to see the greeter at boot time.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("Detecting installed compositors...")
|
||||
compositors := greeter.DetectCompositors()
|
||||
|
||||
if utils.CommandExists("sway") {
|
||||
compositors = append(compositors, "sway")
|
||||
}
|
||||
|
||||
if len(compositors) == 0 {
|
||||
return fmt.Errorf("no supported compositors found (niri, Hyprland, or sway required)")
|
||||
}
|
||||
|
||||
var selectedCompositor string
|
||||
if len(compositors) == 1 {
|
||||
selectedCompositor = compositors[0]
|
||||
fmt.Printf("✓ Found compositor: %s\n", selectedCompositor)
|
||||
} else {
|
||||
var err error
|
||||
selectedCompositor, err = promptCompositorChoice(compositors)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ Selected compositor: %s\n", selectedCompositor)
|
||||
}
|
||||
|
||||
backupPath := configPath + ".backup"
|
||||
backupCmd := exec.Command("sudo", "cp", configPath, backupPath)
|
||||
if err := backupCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to backup config: %w", err)
|
||||
}
|
||||
fmt.Printf("✓ Backed up config to %s\n", backupPath)
|
||||
|
||||
lines := strings.Split(configContent, "\n")
|
||||
var newLines []string
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "command =") && !strings.HasPrefix(trimmed, "command=") {
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
wrapperCmd, err := findCommandPath("dms-greeter")
|
||||
if err != nil {
|
||||
return fmt.Errorf("dms-greeter not found in PATH. Please ensure it is installed and accessible")
|
||||
}
|
||||
|
||||
compositorLower := strings.ToLower(selectedCompositor)
|
||||
commandLine := fmt.Sprintf(`command = "%s --command %s"`, wrapperCmd, compositorLower)
|
||||
|
||||
var finalLines []string
|
||||
inDefaultSession := false
|
||||
commandAdded := false
|
||||
|
||||
for _, line := range newLines {
|
||||
finalLines = append(finalLines, line)
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
if trimmed == "[default_session]" {
|
||||
inDefaultSession = true
|
||||
}
|
||||
|
||||
if inDefaultSession && !commandAdded {
|
||||
if strings.HasPrefix(trimmed, "user =") || strings.HasPrefix(trimmed, "user=") {
|
||||
finalLines = append(finalLines, commandLine)
|
||||
commandAdded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !commandAdded {
|
||||
finalLines = append(finalLines, commandLine)
|
||||
}
|
||||
|
||||
newConfig := strings.Join(finalLines, "\n")
|
||||
|
||||
tmpFile := "/tmp/greetd-config.toml"
|
||||
if err := os.WriteFile(tmpFile, []byte(newConfig), 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write temp config: %w", err)
|
||||
}
|
||||
|
||||
moveCmd := exec.Command("sudo", "mv", tmpFile, configPath)
|
||||
if err := moveCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to update config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Updated greetd configuration to use %s\n", selectedCompositor)
|
||||
|
||||
if err := ensureGraphicalTarget(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := handleConflictingDisplayManagers(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ensureGreetdEnabled(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Enable Complete ===")
|
||||
fmt.Println("\nTo start the greeter now, run:")
|
||||
fmt.Println(" sudo systemctl start greetd")
|
||||
fmt.Println("\nOr reboot to see the greeter at boot time.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isGreeterEnabled() bool {
|
||||
data, err := os.ReadFile("/etc/greetd/config.toml")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(data), "dms-greeter")
|
||||
}
|
||||
|
||||
func detectConfiguredCompositor() string {
|
||||
data, err := os.ReadFile("/etc/greetd/config.toml")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "command") || !strings.Contains(trimmed, "dms-greeter") {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(trimmed, "--command niri"):
|
||||
return "niri"
|
||||
case strings.Contains(trimmed, "--command hyprland"):
|
||||
return "hyprland"
|
||||
case strings.Contains(trimmed, "--command sway"):
|
||||
return "sway"
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func promptCompositorChoice(compositors []string) (string, error) {
|
||||
fmt.Println("\nMultiple compositors detected:")
|
||||
for i, comp := range compositors {
|
||||
fmt.Printf("%d) %s\n", i+1, comp)
|
||||
}
|
||||
|
||||
var response string
|
||||
fmt.Print("Choose compositor for greeter: ")
|
||||
fmt.Scanln(&response)
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
choice := 0
|
||||
fmt.Sscanf(response, "%d", &choice)
|
||||
|
||||
if choice < 1 || choice > len(compositors) {
|
||||
return "", fmt.Errorf("invalid choice")
|
||||
}
|
||||
|
||||
return compositors[choice-1], nil
|
||||
}
|
||||
|
||||
func checkGreeterStatus() error {
|
||||
fmt.Println("=== DMS Greeter Status ===")
|
||||
fmt.Println()
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||
}
|
||||
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current user: %w", err)
|
||||
}
|
||||
|
||||
configPath := "/etc/greetd/config.toml"
|
||||
fmt.Println("Greeter Configuration:")
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
configContent := string(data)
|
||||
if strings.Contains(configContent, "dms-greeter") {
|
||||
lines := strings.SplitSeq(configContent, "\n")
|
||||
for line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "command =") || strings.HasPrefix(trimmed, "command=") {
|
||||
parts := strings.SplitN(trimmed, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
command := strings.Trim(strings.TrimSpace(parts[1]), `"`)
|
||||
fmt.Println(" ✓ Greeter is enabled")
|
||||
|
||||
if strings.Contains(command, "--command niri") {
|
||||
fmt.Println(" Compositor: niri")
|
||||
} else if strings.Contains(command, "--command hyprland") {
|
||||
fmt.Println(" Compositor: Hyprland")
|
||||
} else if strings.Contains(command, "--command sway") {
|
||||
fmt.Println(" Compositor: sway")
|
||||
} else {
|
||||
fmt.Println(" Compositor: unknown")
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" ✗ Greeter is NOT enabled")
|
||||
fmt.Println(" Run 'dms greeter enable' to enable it")
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" ✗ Greeter config not found")
|
||||
fmt.Println(" Run 'dms greeter install' to install greeter")
|
||||
}
|
||||
|
||||
fmt.Println("\nGroup Membership:")
|
||||
groupsCmd := exec.Command("groups", currentUser.Username)
|
||||
groupsOutput, err := groupsCmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check groups: %w", err)
|
||||
}
|
||||
|
||||
inGreeterGroup := strings.Contains(string(groupsOutput), "greeter")
|
||||
if inGreeterGroup {
|
||||
fmt.Println(" ✓ User is in greeter group")
|
||||
} else {
|
||||
fmt.Println(" ✗ User is NOT in greeter group")
|
||||
fmt.Println(" Run 'dms greeter install' to add user to greeter group")
|
||||
}
|
||||
|
||||
cacheDir := "/var/cache/dms-greeter"
|
||||
fmt.Println("\nGreeter Cache Directory:")
|
||||
if stat, err := os.Stat(cacheDir); err == nil && stat.IsDir() {
|
||||
fmt.Printf(" ✓ %s exists\n", cacheDir)
|
||||
} else {
|
||||
fmt.Printf(" ✗ %s not found\n", cacheDir)
|
||||
fmt.Println(" Run 'dms greeter install' to create cache directory")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("\nConfiguration Symlinks:")
|
||||
symlinks := []struct {
|
||||
source string
|
||||
target string
|
||||
desc string
|
||||
}{
|
||||
{
|
||||
source: filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"),
|
||||
target: filepath.Join(cacheDir, "settings.json"),
|
||||
desc: "Settings",
|
||||
},
|
||||
{
|
||||
source: filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"),
|
||||
target: filepath.Join(cacheDir, "session.json"),
|
||||
desc: "Session state",
|
||||
},
|
||||
{
|
||||
source: filepath.Join(homeDir, ".cache", "DankMaterialShell", "dms-colors.json"),
|
||||
target: filepath.Join(cacheDir, "colors.json"),
|
||||
desc: "Color theme",
|
||||
},
|
||||
}
|
||||
|
||||
allGood := true
|
||||
for _, link := range symlinks {
|
||||
targetInfo, err := os.Lstat(link.target)
|
||||
if err != nil {
|
||||
fmt.Printf(" ✗ %s: symlink not found at %s\n", link.desc, link.target)
|
||||
allGood = false
|
||||
continue
|
||||
}
|
||||
|
||||
if targetInfo.Mode()&os.ModeSymlink == 0 {
|
||||
fmt.Printf(" ✗ %s: %s is not a symlink\n", link.desc, link.target)
|
||||
allGood = false
|
||||
continue
|
||||
}
|
||||
|
||||
linkDest, err := os.Readlink(link.target)
|
||||
if err != nil {
|
||||
fmt.Printf(" ✗ %s: failed to read symlink\n", link.desc)
|
||||
allGood = false
|
||||
continue
|
||||
}
|
||||
|
||||
if linkDest != link.source {
|
||||
fmt.Printf(" ✗ %s: symlink points to wrong location\n", link.desc)
|
||||
fmt.Printf(" Expected: %s\n", link.source)
|
||||
fmt.Printf(" Got: %s\n", linkDest)
|
||||
allGood = false
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := os.Stat(link.source); os.IsNotExist(err) {
|
||||
fmt.Printf(" ⚠ %s: symlink OK, but source file doesn't exist yet\n", link.desc)
|
||||
fmt.Printf(" Will be created when you run DMS\n")
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf(" ✓ %s: synced correctly\n", link.desc)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
if allGood && inGreeterGroup {
|
||||
fmt.Println("✓ All checks passed! Greeter is properly configured.")
|
||||
} else if !allGood {
|
||||
fmt.Println("⚠ Some issues detected. Run 'dms greeter sync' to fix symlinks.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -51,20 +51,12 @@ var keybindsSetCmd = &cobra.Command{
|
||||
|
||||
var keybindsRemoveCmd = &cobra.Command{
|
||||
Use: "remove <provider> <key>",
|
||||
Short: "Remove a keybind",
|
||||
Long: "Remove a keybind. For Hyprland this writes a negative override to dms/binds-user.lua so the key stays unbound across DMS updates. For other providers it deletes the entry from the managed file.",
|
||||
Short: "Remove a keybind override",
|
||||
Long: "Remove a keybind override from the specified provider",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: runKeybindsRemove,
|
||||
}
|
||||
|
||||
var keybindsResetCmd = &cobra.Command{
|
||||
Use: "reset <provider> <key>",
|
||||
Short: "Reset a keybind override to its DMS default",
|
||||
Long: "Drop the user override for the given key so the DMS default re-applies. For providers without a separate default file (Niri, MangoWC) this is equivalent to remove.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: runKeybindsReset,
|
||||
}
|
||||
|
||||
func init() {
|
||||
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
|
||||
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
|
||||
@@ -80,7 +72,6 @@ func init() {
|
||||
keybindsCmd.AddCommand(keybindsShowCmd)
|
||||
keybindsCmd.AddCommand(keybindsSetCmd)
|
||||
keybindsCmd.AddCommand(keybindsRemoveCmd)
|
||||
keybindsCmd.AddCommand(keybindsResetCmd)
|
||||
|
||||
keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) {
|
||||
return providers.NewJSONFileProvider(filePath)
|
||||
@@ -272,19 +263,3 @@ func runKeybindsRemove(_ *cobra.Command, args []string) {
|
||||
}, "", " ")
|
||||
fmt.Fprintln(os.Stdout, string(output))
|
||||
}
|
||||
|
||||
func runKeybindsReset(_ *cobra.Command, args []string) {
|
||||
providerName, key := args[0], args[1]
|
||||
writable := getWritableProvider(providerName)
|
||||
|
||||
if err := writable.ResetBind(key); err != nil {
|
||||
log.Fatalf("Error resetting keybind: %v", err)
|
||||
}
|
||||
|
||||
output, _ := json.MarshalIndent(map[string]any{
|
||||
"success": true,
|
||||
"key": key,
|
||||
"reset": true,
|
||||
}, "", " ")
|
||||
fmt.Fprintln(os.Stdout, string(output))
|
||||
}
|
||||
|
||||
@@ -3,9 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
@@ -38,17 +36,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")
|
||||
@@ -64,13 +55,10 @@ func init() {
|
||||
cmd.Flags().Bool("sync-mode-with-portal", false, "Sync color scheme with GNOME portal")
|
||||
cmd.Flags().Bool("terminals-always-dark", false, "Force terminal themes to dark variant")
|
||||
cmd.Flags().String("skip-templates", "", "Comma-separated list of templates to skip")
|
||||
cmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
|
||||
}
|
||||
|
||||
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)")
|
||||
matugenQueueCmd.Flags().Duration("timeout", 30*time.Second, "Timeout for waiting")
|
||||
}
|
||||
|
||||
func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
||||
@@ -87,7 +75,6 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
||||
syncModeWithPortal, _ := cmd.Flags().GetBool("sync-mode-with-portal")
|
||||
terminalsAlwaysDark, _ := cmd.Flags().GetBool("terminals-always-dark")
|
||||
skipTemplates, _ := cmd.Flags().GetString("skip-templates")
|
||||
contrast, _ := cmd.Flags().GetFloat64("contrast")
|
||||
|
||||
return matugen.Options{
|
||||
StateDir: stateDir,
|
||||
@@ -98,7 +85,6 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
||||
Mode: matugen.ColorMode(mode),
|
||||
IconTheme: iconTheme,
|
||||
MatugenType: matugenType,
|
||||
Contrast: contrast,
|
||||
RunUserTemplates: runUserTemplates,
|
||||
StockColors: stockColors,
|
||||
SyncModeWithPortal: syncModeWithPortal,
|
||||
@@ -109,11 +95,7 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
||||
|
||||
func runMatugenGenerate(cmd *cobra.Command, args []string) {
|
||||
opts := buildMatugenOptions(cmd)
|
||||
err := matugen.Run(opts)
|
||||
switch {
|
||||
case errors.Is(err, matugen.ErrNoChanges):
|
||||
os.Exit(2)
|
||||
case err != nil:
|
||||
if err := matugen.Run(opts); err != nil {
|
||||
log.Fatalf("Theme generation failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -140,7 +122,6 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
||||
"syncModeWithPortal": opts.SyncModeWithPortal,
|
||||
"terminalsAlwaysDark": opts.TerminalsAlwaysDark,
|
||||
"skipTemplates": opts.SkipTemplates,
|
||||
"contrast": opts.Contrast,
|
||||
"wait": wait,
|
||||
},
|
||||
}
|
||||
@@ -148,11 +129,7 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
||||
if !wait {
|
||||
if err := sendServerRequestFireAndForget(request); err != nil {
|
||||
log.Info("Server unavailable, running synchronously")
|
||||
err := matugen.Run(opts)
|
||||
switch {
|
||||
case errors.Is(err, matugen.ErrNoChanges):
|
||||
os.Exit(2)
|
||||
case err != nil:
|
||||
if err := matugen.Run(opts); err != nil {
|
||||
log.Fatalf("Theme generation failed: %v", err)
|
||||
}
|
||||
return
|
||||
@@ -169,15 +146,11 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
||||
resp, ok := tryServerRequest(request)
|
||||
if !ok {
|
||||
log.Info("Server unavailable, running synchronously")
|
||||
err := matugen.Run(opts)
|
||||
switch {
|
||||
case errors.Is(err, matugen.ErrNoChanges):
|
||||
resultCh <- matugen.ErrNoChanges
|
||||
case err != nil:
|
||||
if err := matugen.Run(opts); err != nil {
|
||||
resultCh <- err
|
||||
default:
|
||||
resultCh <- nil
|
||||
return
|
||||
}
|
||||
resultCh <- nil
|
||||
return
|
||||
}
|
||||
if resp.Error != "" {
|
||||
@@ -189,10 +162,7 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
|
||||
|
||||
select {
|
||||
case err := <-resultCh:
|
||||
switch {
|
||||
case errors.Is(err, matugen.ErrNoChanges):
|
||||
os.Exit(2)
|
||||
case err != nil:
|
||||
if err != nil {
|
||||
log.Fatalf("Theme generation failed: %v", err)
|
||||
}
|
||||
fmt.Println("Theme generation completed")
|
||||
@@ -209,17 +179,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))
|
||||
}
|
||||
|
||||
@@ -28,9 +28,9 @@ with flags to handle different MIME types or application categories.
|
||||
|
||||
Examples:
|
||||
dms open https://example.com # Open URL with browser picker
|
||||
dms open file.pdf # Open file (MIME auto-detected)
|
||||
dms open file.pdf --mime application/pdf # Override MIME detection
|
||||
dms open document.odt --category Office # Open with office applications`,
|
||||
dms open file.pdf --mime application/pdf # Open PDF with compatible apps
|
||||
dms open document.odt --category Office # Open with office applications
|
||||
dms open --mime image/png image.png # Open image with image viewers`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runOpen(args[0])
|
||||
@@ -47,58 +47,123 @@ func init() {
|
||||
})
|
||||
}
|
||||
|
||||
func detectMimeFromPath(path string) string {
|
||||
ext := filepath.Ext(path)
|
||||
if ext == "" {
|
||||
return ""
|
||||
// mimeTypeToCategories maps MIME types to desktop file categories
|
||||
func mimeTypeToCategories(mimeType string) []string {
|
||||
// Split MIME type to get the main type
|
||||
parts := strings.Split(mimeType, "/")
|
||||
if len(parts) < 1 {
|
||||
return nil
|
||||
}
|
||||
return mime.TypeByExtension(ext)
|
||||
|
||||
mainType := parts[0]
|
||||
|
||||
switch mainType {
|
||||
case "image":
|
||||
return []string{"Graphics", "Viewer"}
|
||||
case "video":
|
||||
return []string{"Video", "AudioVideo"}
|
||||
case "audio":
|
||||
return []string{"Audio", "AudioVideo"}
|
||||
case "text":
|
||||
if strings.Contains(mimeType, "html") {
|
||||
return []string{"WebBrowser"}
|
||||
}
|
||||
return []string{"TextEditor", "Office"}
|
||||
case "application":
|
||||
if strings.Contains(mimeType, "pdf") {
|
||||
return []string{"Office", "Viewer"}
|
||||
}
|
||||
if strings.Contains(mimeType, "document") || strings.Contains(mimeType, "spreadsheet") ||
|
||||
strings.Contains(mimeType, "presentation") || strings.Contains(mimeType, "msword") ||
|
||||
strings.Contains(mimeType, "ms-excel") || strings.Contains(mimeType, "ms-powerpoint") ||
|
||||
strings.Contains(mimeType, "opendocument") {
|
||||
return []string{"Office"}
|
||||
}
|
||||
if strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "tar") ||
|
||||
strings.Contains(mimeType, "gzip") || strings.Contains(mimeType, "compress") {
|
||||
return []string{"Archiving", "Utility"}
|
||||
}
|
||||
return []string{"Office", "Viewer"}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runOpen(target string) {
|
||||
// Parse file:// URIs to extract the actual file path
|
||||
actualTarget := target
|
||||
detectedMimeType := openMimeType
|
||||
detectedCategories := openCategories
|
||||
detectedRequestType := openRequestType
|
||||
|
||||
log.Infof("Processing target: %s", target)
|
||||
|
||||
switch {
|
||||
case isScheme(target, "file://"):
|
||||
parsedURL, err := url.Parse(target)
|
||||
if err == nil {
|
||||
actualTarget = parsedURL.Path
|
||||
}
|
||||
if abs, err := filepath.Abs(actualTarget); err == nil {
|
||||
actualTarget = abs
|
||||
if parsedURL, err := url.Parse(target); err == nil && parsedURL.Scheme == "file" {
|
||||
// Extract file path from file:// URI and convert to absolute path
|
||||
actualTarget = parsedURL.Path
|
||||
if absPath, err := filepath.Abs(actualTarget); err == nil {
|
||||
actualTarget = absPath
|
||||
}
|
||||
|
||||
if detectedRequestType == "url" || detectedRequestType == "" {
|
||||
detectedRequestType = "file"
|
||||
}
|
||||
if detectedMimeType == "" {
|
||||
detectedMimeType = detectMimeFromPath(actualTarget)
|
||||
}
|
||||
log.Infof("Detected file:// URI, absolute path: %s", actualTarget)
|
||||
|
||||
case isScheme(target, "http://"), isScheme(target, "https://"), isScheme(target, "dms://"):
|
||||
log.Infof("Detected file:// URI, extracted absolute path: %s", actualTarget)
|
||||
|
||||
// Auto-detect MIME type if not provided
|
||||
if detectedMimeType == "" {
|
||||
ext := filepath.Ext(actualTarget)
|
||||
if ext != "" {
|
||||
detectedMimeType = mime.TypeByExtension(ext)
|
||||
log.Infof("Detected MIME type from extension %s: %s", ext, detectedMimeType)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect categories based on MIME type if not provided
|
||||
if len(detectedCategories) == 0 && detectedMimeType != "" {
|
||||
detectedCategories = mimeTypeToCategories(detectedMimeType)
|
||||
log.Infof("Detected categories from MIME type: %v", detectedCategories)
|
||||
}
|
||||
} else if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
|
||||
// Handle HTTP(S) URLs
|
||||
if detectedRequestType == "" {
|
||||
detectedRequestType = "url"
|
||||
}
|
||||
log.Infof("Detected URL: %s", target)
|
||||
log.Infof("Detected HTTP(S) URL")
|
||||
} else if strings.HasPrefix(target, "dms://") {
|
||||
// Handle DMS internal URLs (theme/plugin install, etc.)
|
||||
if detectedRequestType == "" {
|
||||
detectedRequestType = "url"
|
||||
}
|
||||
log.Infof("Detected DMS internal URL")
|
||||
} else if _, err := os.Stat(target); err == nil {
|
||||
// Handle local file paths directly (not file:// URIs)
|
||||
// Convert to absolute path
|
||||
if absPath, err := filepath.Abs(target); err == nil {
|
||||
actualTarget = absPath
|
||||
}
|
||||
|
||||
default:
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
break
|
||||
}
|
||||
if abs, err := filepath.Abs(target); err == nil {
|
||||
actualTarget = abs
|
||||
}
|
||||
if detectedRequestType == "url" || detectedRequestType == "" {
|
||||
detectedRequestType = "file"
|
||||
}
|
||||
|
||||
log.Infof("Detected local file path, converted to absolute: %s", actualTarget)
|
||||
|
||||
// Auto-detect MIME type if not provided
|
||||
if detectedMimeType == "" {
|
||||
detectedMimeType = detectMimeFromPath(actualTarget)
|
||||
ext := filepath.Ext(actualTarget)
|
||||
if ext != "" {
|
||||
detectedMimeType = mime.TypeByExtension(ext)
|
||||
log.Infof("Detected MIME type from extension %s: %s", ext, detectedMimeType)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect categories based on MIME type if not provided
|
||||
if len(detectedCategories) == 0 && detectedMimeType != "" {
|
||||
detectedCategories = mimeTypeToCategories(detectedMimeType)
|
||||
log.Infof("Detected categories from MIME type: %v", detectedCategories)
|
||||
}
|
||||
log.Infof("Detected local file path: %s", actualTarget)
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
@@ -109,8 +174,8 @@ func runOpen(target string) {
|
||||
params["mimeType"] = detectedMimeType
|
||||
}
|
||||
|
||||
if len(openCategories) > 0 {
|
||||
params["categories"] = openCategories
|
||||
if len(detectedCategories) > 0 {
|
||||
params["categories"] = detectedCategories
|
||||
}
|
||||
|
||||
if detectedRequestType != "" {
|
||||
@@ -118,7 +183,7 @@ func runOpen(target string) {
|
||||
}
|
||||
|
||||
method := "apppicker.open"
|
||||
if detectedMimeType == "" && len(openCategories) == 0 && (isScheme(target, "http://") || isScheme(target, "https://") || isScheme(target, "dms://")) {
|
||||
if detectedMimeType == "" && len(detectedCategories) == 0 && (strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "dms://")) {
|
||||
method = "browser.open"
|
||||
params["url"] = target
|
||||
}
|
||||
@@ -138,7 +203,3 @@ func runOpen(target string) {
|
||||
|
||||
log.Infof("Request sent successfully")
|
||||
}
|
||||
|
||||
func isScheme(target, prefix string) bool {
|
||||
return strings.HasPrefix(target, prefix)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var randrCmd = &cobra.Command{
|
||||
Use: "randr",
|
||||
Short: "Query output display information",
|
||||
Long: "Query Wayland compositor for output names, scales, resolutions and refresh rates via zwlr-output-management",
|
||||
Run: runRandr,
|
||||
}
|
||||
|
||||
func init() {
|
||||
randrCmd.Flags().Bool("json", false, "Output in JSON format")
|
||||
}
|
||||
|
||||
type randrJSON struct {
|
||||
Outputs []randrOutput `json:"outputs"`
|
||||
}
|
||||
|
||||
func runRandr(cmd *cobra.Command, args []string) {
|
||||
outputs, err := queryRandr()
|
||||
if err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
|
||||
jsonFlag, _ := cmd.Flags().GetBool("json")
|
||||
|
||||
if jsonFlag {
|
||||
data, err := json.Marshal(randrJSON{Outputs: outputs})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to marshal JSON: %v", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
return
|
||||
}
|
||||
|
||||
for i, out := range outputs {
|
||||
if i > 0 {
|
||||
fmt.Println()
|
||||
}
|
||||
status := "enabled"
|
||||
if !out.Enabled {
|
||||
status = "disabled"
|
||||
}
|
||||
fmt.Printf("%s (%s)\n", out.Name, status)
|
||||
fmt.Printf(" Scale: %.4g\n", out.Scale)
|
||||
fmt.Printf(" Resolution: %dx%d\n", out.Width, out.Height)
|
||||
if out.Refresh > 0 {
|
||||
fmt.Printf(" Refresh: %.2f Hz\n", float64(out.Refresh)/1000.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -25,22 +22,9 @@ var (
|
||||
ssNoClipboard bool
|
||||
ssNoFile bool
|
||||
ssNoNotify bool
|
||||
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 +35,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)
|
||||
@@ -67,15 +50,10 @@ Examples:
|
||||
dms screenshot output -o DP-1 # Specific output
|
||||
dms screenshot window # Focused window (Hyprland)
|
||||
dms screenshot last # Last region (pre-selected)
|
||||
dms screenshot --reset # Reset last region pre-selection
|
||||
dms screenshot --no-clipboard # Save file only
|
||||
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 +91,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",
|
||||
@@ -164,15 +119,9 @@ func init() {
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssNoClipboard, "no-clipboard", false, "Don't copy to clipboard")
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssNoFile, "no-file", false, "Don't save to file")
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssNoNotify, "no-notify", false, "Don't show notification")
|
||||
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)
|
||||
@@ -193,8 +142,6 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
|
||||
config.Clipboard = !ssNoClipboard
|
||||
config.SaveFile = !ssNoFile
|
||||
config.Notify = !ssNoNotify
|
||||
config.NoConfirm = ssNoConfirm
|
||||
config.Reset = ssReset
|
||||
config.Stdout = ssStdout
|
||||
|
||||
if ssOutputDir != "" {
|
||||
@@ -224,76 +171,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 +191,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 +212,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 +338,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)
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var switchUserCmd = &cobra.Command{
|
||||
Use: "switch-user [target]",
|
||||
Short: "Switch to another active session on this seat",
|
||||
Long: `Switch the active VT to another running session.
|
||||
|
||||
With no target, prints the list of switchable sessions. Pass a username or a
|
||||
numeric session ID to switch directly. Requires the target to already be a
|
||||
running session on the same seat (use the greeter for a fresh login).`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: runSwitchUser,
|
||||
}
|
||||
|
||||
type sessionInfo struct {
|
||||
ID string
|
||||
Name string
|
||||
Seat string
|
||||
TTY string
|
||||
Type string
|
||||
Class string
|
||||
Active bool
|
||||
State string
|
||||
Current bool
|
||||
}
|
||||
|
||||
func runSwitchUser(cmd *cobra.Command, args []string) {
|
||||
currentID := os.Getenv("XDG_SESSION_ID")
|
||||
sessions, err := listSessions(currentID)
|
||||
if err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
|
||||
switchable := make([]sessionInfo, 0, len(sessions))
|
||||
for _, s := range sessions {
|
||||
if s.Class != "user" || s.State == "closing" || s.Current {
|
||||
continue
|
||||
}
|
||||
switchable = append(switchable, s)
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
if len(switchable) == 0 {
|
||||
fmt.Println("No other active sessions on this seat.")
|
||||
return
|
||||
}
|
||||
printSessions(switchable)
|
||||
return
|
||||
}
|
||||
|
||||
target := args[0]
|
||||
picked, err := pickSession(switchable, target)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
if len(switchable) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "No other active sessions on this seat. Only already-running sessions can be switched to.")
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "\nSwitchable sessions:")
|
||||
printSessions(switchable)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := activateSession(picked.ID); err != nil {
|
||||
log.Fatalf("loginctl activate %s: %v", picked.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func listSessions(currentID string) ([]sessionInfo, error) {
|
||||
listOut, err := exec.Command("loginctl", "list-sessions", "--no-legend").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loginctl list-sessions: %w", err)
|
||||
}
|
||||
|
||||
var ids []string
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(listOut)))
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, fields[0])
|
||||
}
|
||||
|
||||
out := make([]sessionInfo, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
s, err := showSession(id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s.Current = currentID != "" && s.ID == currentID
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Name != out[j].Name {
|
||||
return out[i].Name < out[j].Name
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func showSession(id string) (sessionInfo, error) {
|
||||
out, err := exec.Command("loginctl", "show-session", id,
|
||||
"-p", "Id", "-p", "Name", "-p", "Seat", "-p", "TTY",
|
||||
"-p", "Type", "-p", "Class", "-p", "Active", "-p", "State").Output()
|
||||
if err != nil {
|
||||
return sessionInfo{}, err
|
||||
}
|
||||
fields := map[string]string{}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
idx := strings.IndexByte(line, '=')
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
fields[line[:idx]] = line[idx+1:]
|
||||
}
|
||||
if fields["Id"] == "" {
|
||||
return sessionInfo{}, fmt.Errorf("session %s: no Id", id)
|
||||
}
|
||||
return sessionInfo{
|
||||
ID: fields["Id"],
|
||||
Name: fields["Name"],
|
||||
Seat: fields["Seat"],
|
||||
TTY: fields["TTY"],
|
||||
Type: fields["Type"],
|
||||
Class: fields["Class"],
|
||||
Active: fields["Active"] == "yes",
|
||||
State: fields["State"],
|
||||
}, nil
|
||||
}
|
||||
|
||||
func pickSession(sessions []sessionInfo, target string) (sessionInfo, error) {
|
||||
for _, s := range sessions {
|
||||
if s.ID == target {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
matches := make([]sessionInfo, 0, 2)
|
||||
for _, s := range sessions {
|
||||
if s.Name == target {
|
||||
matches = append(matches, s)
|
||||
}
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
return matches[0], nil
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
ids := make([]string, len(matches))
|
||||
for i, m := range matches {
|
||||
ids[i] = m.ID
|
||||
}
|
||||
return sessionInfo{}, fmt.Errorf("%s has multiple active sessions (%s); pass a session ID instead", target, strings.Join(ids, ", "))
|
||||
}
|
||||
return sessionInfo{}, fmt.Errorf("no switchable session matches %q", target)
|
||||
}
|
||||
|
||||
func activateSession(id string) error {
|
||||
return exec.Command("loginctl", "activate", id).Run()
|
||||
}
|
||||
|
||||
func printSessions(sessions []sessionInfo) {
|
||||
fmt.Printf("%-6s %-12s %-8s %-8s %-8s\n", "ID", "USER", "TYPE", "SEAT", "TTY")
|
||||
for _, s := range sessions {
|
||||
tty := s.TTY
|
||||
if tty == "" {
|
||||
tty = "-"
|
||||
}
|
||||
seat := s.Seat
|
||||
if seat == "" {
|
||||
seat = "-"
|
||||
}
|
||||
fmt.Printf("%-6s %-12s %-8s %-8s %-8s\n", s.ID, s.Name, s.Type, seat, tty)
|
||||
}
|
||||
}
|
||||
+47
-151
@@ -4,24 +4,21 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var setupCmd = &cobra.Command{
|
||||
Use: "setup",
|
||||
Short: "Deploy DMS configurations",
|
||||
Long: "Deploy compositor and terminal configurations with interactive prompts",
|
||||
PersistentPreRunE: preRunPrivileged,
|
||||
Use: "setup",
|
||||
Short: "Deploy DMS configurations",
|
||||
Long: "Deploy compositor and terminal configurations with interactive prompts",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSetup(); err != nil {
|
||||
log.Fatalf("Error during setup: %v", err)
|
||||
@@ -100,72 +97,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.conf",
|
||||
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)
|
||||
return strings.ReplaceAll(config.HyprBindsConfig, "{{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.conf",
|
||||
niriContent: func(_ string) string { return config.NiriLayoutConfig },
|
||||
hyprContent: func(_ string) string { return config.HyprLayoutConfig },
|
||||
},
|
||||
"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.conf",
|
||||
niriContent: func(_ string) string { return config.NiriColorsConfig },
|
||||
hyprContent: func(_ string) string { return config.HyprColorsConfig },
|
||||
},
|
||||
"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.conf",
|
||||
niriContent: func(_ string) string { return "" },
|
||||
hyprContent: func(_ string) string { return "" },
|
||||
},
|
||||
"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.conf",
|
||||
niriContent: func(_ string) string { return "" },
|
||||
hyprContent: func(_ string) string { return "" },
|
||||
},
|
||||
"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.conf",
|
||||
niriContent: func(_ string) string { return "" },
|
||||
hyprContent: func(_ string) string { return "" },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -204,34 +185,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 +221,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 +232,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 {
|
||||
@@ -304,21 +266,9 @@ func runSetupDmsConfig(name string) error {
|
||||
func runSetup() error {
|
||||
fmt.Println("=== DMS Configuration Setup ===")
|
||||
|
||||
ensureInputGroup()
|
||||
|
||||
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,55 +339,14 @@ 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() {
|
||||
if !utils.HasGroup("input") {
|
||||
return
|
||||
}
|
||||
currentUser := os.Getenv("USER")
|
||||
if currentUser == "" {
|
||||
currentUser = os.Getenv("LOGNAME")
|
||||
}
|
||||
if currentUser == "" {
|
||||
return
|
||||
}
|
||||
out, err := execGroups(currentUser)
|
||||
if err == nil && strings.Contains(out, "input") {
|
||||
fmt.Printf("✓ %s is already in the input group (Caps Lock OSD enabled)\n", currentUser)
|
||||
return
|
||||
}
|
||||
fmt.Println("Adding user to input group for Caps Lock OSD support...")
|
||||
if err := privesc.Run(context.Background(), "", "usermod", "-aG", "input", currentUser); err != nil {
|
||||
fmt.Printf("⚠ Could not add %s to input group (Caps Lock OSD will be unavailable): %v\n", currentUser, err)
|
||||
} else {
|
||||
fmt.Printf("✓ Added %s to input group (logout/login required to take effect)\n", currentUser)
|
||||
}
|
||||
}
|
||||
|
||||
func execGroups(user string) (string, error) {
|
||||
out, err := exec.Command("groups", user).Output()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
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 +355,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
|
||||
}
|
||||
@@ -495,27 +402,16 @@ func checkExistingConfigs(wm deps.WindowManager, wmSelected bool, terminal deps.
|
||||
willBackup := false
|
||||
|
||||
if wmSelected {
|
||||
var configPaths []string
|
||||
var configPath string
|
||||
switch wm {
|
||||
case deps.WindowManagerNiri:
|
||||
configPaths = []string{filepath.Join(homeDir, ".config", "niri", "config.kdl")}
|
||||
configPath = filepath.Join(homeDir, ".config", "niri", "config.kdl")
|
||||
case deps.WindowManagerHyprland:
|
||||
configPaths = []string{
|
||||
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"),
|
||||
}
|
||||
configPath = filepath.Join(homeDir, ".config", "hypr", "hyprland.conf")
|
||||
}
|
||||
|
||||
for _, configPath := range configPaths {
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
willBackup = true
|
||||
break
|
||||
}
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
willBackup = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var systemCmd = &cobra.Command{
|
||||
Use: "system",
|
||||
Short: "System operations",
|
||||
Long: "System-level operations (updates, etc.). Runs against installed package managers directly; does not require the DMS server.",
|
||||
}
|
||||
|
||||
var systemUpdateCmd = &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Apply or list system updates",
|
||||
Long: `Apply or list system updates across detected package managers.
|
||||
|
||||
Default behavior is to apply available updates after prompting for confirmation.
|
||||
Use --check to list updates without applying.
|
||||
|
||||
Examples:
|
||||
dms system update --check # list available updates
|
||||
dms system update # apply updates (interactive prompt)
|
||||
dms system update --noconfirm # apply updates without prompting
|
||||
dms system update --dry # simulate without changing anything
|
||||
dms system update --no-flatpak --noconfirm # apply system updates only
|
||||
dms system update --interval 3600 # set the server poll interval to 1h`,
|
||||
Run: runSystemUpdate,
|
||||
}
|
||||
|
||||
var (
|
||||
sysUpdateCheck bool
|
||||
sysUpdateNoConfirm bool
|
||||
sysUpdateDry bool
|
||||
sysUpdateJSON bool
|
||||
sysUpdateNoFlatpak bool
|
||||
sysUpdateNoAUR bool
|
||||
sysUpdateIgnore []string
|
||||
sysUpdateIntervalS int
|
||||
sysUpdateListPmTime = 5 * time.Minute
|
||||
)
|
||||
|
||||
func init() {
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateCheck, "check", false, "List available updates without applying")
|
||||
systemUpdateCmd.Flags().BoolVarP(&sysUpdateNoConfirm, "noconfirm", "y", false, "Apply updates without prompting")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip 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)
|
||||
}
|
||||
|
||||
func runSystemUpdate(cmd *cobra.Command, args []string) {
|
||||
switch {
|
||||
case sysUpdateIntervalS >= 0:
|
||||
runSystemUpdateSetInterval(sysUpdateIntervalS)
|
||||
case sysUpdateCheck:
|
||||
runSystemUpdateCheck()
|
||||
default:
|
||||
runSystemUpdateApply()
|
||||
}
|
||||
}
|
||||
|
||||
func selectBackends(ctx context.Context) []sysupdate.Backend {
|
||||
sel := sysupdate.Select(ctx)
|
||||
backends := sel.All()
|
||||
if !sysUpdateNoFlatpak {
|
||||
return backends
|
||||
}
|
||||
out := backends[:0]
|
||||
for _, b := range backends {
|
||||
if b.Repo() == sysupdate.RepoFlatpak {
|
||||
continue
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runSystemUpdateCheck() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
|
||||
defer cancel()
|
||||
|
||||
backends := selectBackends(ctx)
|
||||
if len(backends) == 0 {
|
||||
log.Fatal("No supported package manager found")
|
||||
}
|
||||
|
||||
stopSpin := startSpinner("Checking for updates… ")
|
||||
allPkgs, firstErr := collectUpdates(ctx, backends)
|
||||
stopSpin()
|
||||
allPkgs = filterUpdateTargets(allPkgs)
|
||||
|
||||
if sysUpdateJSON {
|
||||
out, _ := json.MarshalIndent(map[string]any{
|
||||
"backends": backendResults(backends, allPkgs),
|
||||
"packages": allPkgs,
|
||||
"error": errOrEmpty(firstErr),
|
||||
"count": len(allPkgs),
|
||||
}, "", " ")
|
||||
fmt.Println(string(out))
|
||||
return
|
||||
}
|
||||
|
||||
printBackends(backends)
|
||||
fmt.Printf("Updates: %d\n", len(allPkgs))
|
||||
if firstErr != nil {
|
||||
fmt.Printf("Error: %v\n", firstErr)
|
||||
}
|
||||
if len(allPkgs) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Println()
|
||||
for _, p := range allPkgs {
|
||||
printPackage(p)
|
||||
}
|
||||
}
|
||||
|
||||
type backendResult struct {
|
||||
ID string `json:"id"`
|
||||
Display string `json:"displayName"`
|
||||
Packages []sysupdate.Package `json:"packages"`
|
||||
}
|
||||
|
||||
func backendResults(backends []sysupdate.Backend, pkgs []sysupdate.Package) []backendResult {
|
||||
results := make([]backendResult, 0, len(backends))
|
||||
for _, b := range backends {
|
||||
var backendPkgs []sysupdate.Package
|
||||
for _, p := range pkgs {
|
||||
if sysupdate.BackendHasTargets(b, []sysupdate.Package{p}, true, true) {
|
||||
backendPkgs = append(backendPkgs, p)
|
||||
}
|
||||
}
|
||||
results = append(results, backendResult{ID: b.ID(), Display: b.DisplayName(), Packages: backendPkgs})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func runSystemUpdateApply() {
|
||||
checkCtx, checkCancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
|
||||
defer checkCancel()
|
||||
|
||||
backends := selectBackends(checkCtx)
|
||||
if len(backends) == 0 {
|
||||
log.Fatal("No supported package manager found")
|
||||
}
|
||||
|
||||
stopSpin := startSpinner("Checking for updates…")
|
||||
pkgs, firstErr := collectUpdates(checkCtx, backends)
|
||||
stopSpin()
|
||||
pkgs = filterUpdateTargets(pkgs)
|
||||
if firstErr != nil {
|
||||
fmt.Printf("Warning: %v\n\n", firstErr)
|
||||
}
|
||||
|
||||
printBackends(backends)
|
||||
fmt.Printf("Updates: %d\n", len(pkgs))
|
||||
if len(pkgs) == 0 {
|
||||
fmt.Println("Nothing to upgrade.")
|
||||
return
|
||||
}
|
||||
fmt.Println()
|
||||
for _, p := range pkgs {
|
||||
printPackage(p)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if !sysUpdateNoConfirm && !sysUpdateDry {
|
||||
if !promptYesNo("Proceed with upgrade? [Y/n]: ") {
|
||||
fmt.Println("Aborted.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
opts := sysupdate.UpgradeOptions{
|
||||
Targets: pkgs,
|
||||
IncludeFlatpak: !sysUpdateNoFlatpak,
|
||||
IncludeAUR: !sysUpdateNoAUR,
|
||||
Ignored: sysUpdateIgnore,
|
||||
DryRun: sysUpdateDry,
|
||||
UseSudo: true,
|
||||
}
|
||||
opts.AttachStdio = sysupdate.UpgradeNeedsPrivilege(backends, pkgs, opts)
|
||||
|
||||
onLine := func(line string) { fmt.Println(line) }
|
||||
ran := false
|
||||
for _, b := range backends {
|
||||
if !sysupdate.BackendHasTargets(b, pkgs, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||
continue
|
||||
}
|
||||
ran = true
|
||||
fmt.Printf("\n== %s ==\n", b.DisplayName())
|
||||
if err := b.Upgrade(ctx, opts, onLine); err != nil {
|
||||
log.Fatalf("%s upgrade failed: %v", b.ID(), err)
|
||||
}
|
||||
}
|
||||
if !ran {
|
||||
fmt.Println("Nothing to upgrade.")
|
||||
return
|
||||
}
|
||||
if sysUpdateDry {
|
||||
fmt.Println("\nDry run complete (no changes applied).")
|
||||
return
|
||||
}
|
||||
fmt.Println("\nUpgrade complete.")
|
||||
}
|
||||
|
||||
func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupdate.Package, error) {
|
||||
var all []sysupdate.Package
|
||||
var firstErr error
|
||||
for _, b := range backends {
|
||||
pkgs, err := b.CheckUpdates(ctx)
|
||||
if err != nil && firstErr == nil {
|
||||
firstErr = fmt.Errorf("%s: %w", b.ID(), err)
|
||||
}
|
||||
all = append(all, pkgs...)
|
||||
}
|
||||
return all, firstErr
|
||||
}
|
||||
|
||||
func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
|
||||
if !sysUpdateNoAUR && len(sysUpdateIgnore) == 0 {
|
||||
return pkgs
|
||||
}
|
||||
ignored := make(map[string]bool, len(sysUpdateIgnore))
|
||||
for _, name := range sysUpdateIgnore {
|
||||
ignored[name] = true
|
||||
}
|
||||
out := pkgs[:0]
|
||||
for _, p := range pkgs {
|
||||
if sysUpdateNoAUR && p.Repo == sysupdate.RepoAUR {
|
||||
continue
|
||||
}
|
||||
if ignored[p.Name] {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runSystemUpdateSetInterval(seconds int) {
|
||||
resp, err := sendServerRequest(models.Request{
|
||||
ID: 1,
|
||||
Method: "sysupdate.setInterval",
|
||||
Params: map[string]any{"seconds": float64(seconds)},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed: %v (is dms server running?)", err)
|
||||
}
|
||||
if resp.Error != "" {
|
||||
log.Fatalf("Error: %s", resp.Error)
|
||||
}
|
||||
fmt.Printf("Interval set to %d seconds.\n", seconds)
|
||||
}
|
||||
|
||||
func promptYesNo(prompt string) bool {
|
||||
if !stdinIsTTY() {
|
||||
log.Fatal("Refusing to apply updates non-interactively. Re-run with --noconfirm or --check.")
|
||||
}
|
||||
fmt.Print(prompt)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "n", "no":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func printBackends(backends []sysupdate.Backend) {
|
||||
if len(backends) == 0 {
|
||||
return
|
||||
}
|
||||
names := make([]string, 0, len(backends))
|
||||
for _, b := range backends {
|
||||
names = append(names, b.DisplayName())
|
||||
}
|
||||
fmt.Printf("Backends: %s\n", strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
func stdinIsTTY() bool {
|
||||
fi, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return (fi.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
|
||||
func stdoutIsTTY() bool {
|
||||
fi, err := os.Stdout.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return (fi.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
|
||||
// startSpinner prints an animated spinner to stdout for progress indication
|
||||
func startSpinner(msg string) func() {
|
||||
if !stdoutIsTTY() {
|
||||
return func() {}
|
||||
}
|
||||
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for i := 0; ; i++ {
|
||||
select {
|
||||
case <-done:
|
||||
fmt.Print("\r\033[K")
|
||||
return
|
||||
case <-time.After(80 * time.Millisecond):
|
||||
fmt.Printf("\r%s %s", frames[i%len(frames)], msg)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() { close(done) }
|
||||
}
|
||||
|
||||
var (
|
||||
styleRepo = lipgloss.NewStyle().Foreground(lipgloss.Color("244")).Bold(false)
|
||||
styleName = lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true)
|
||||
styleFrom = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
|
||||
styleArrow = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
|
||||
styleTo = lipgloss.NewStyle().Foreground(lipgloss.Color("76")).Bold(true)
|
||||
)
|
||||
|
||||
func printPackage(p sysupdate.Package) {
|
||||
if !stdoutIsTTY() {
|
||||
fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?"))
|
||||
return
|
||||
}
|
||||
fmt.Printf(" %s %s %s %s %s\n",
|
||||
styleRepo.Render("["+string(p.Repo)+"]"),
|
||||
styleName.Render(p.Name),
|
||||
styleFrom.Render(defaultIfEmpty(p.FromVersion, "?")),
|
||||
styleArrow.Render("->"),
|
||||
styleTo.Render(defaultIfEmpty(p.ToVersion, "?")),
|
||||
)
|
||||
}
|
||||
|
||||
func errOrEmpty(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func defaultIfEmpty(s, def string) string {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/trash"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var trashCmd = &cobra.Command{
|
||||
Use: "trash",
|
||||
Short: "Manage the user's trash (XDG Trash spec 1.0)",
|
||||
}
|
||||
|
||||
var trashPutCmd = &cobra.Command{
|
||||
Use: "put <path...>",
|
||||
Short: "Move files or directories into the trash",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: runTrashPut,
|
||||
}
|
||||
|
||||
var trashListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List trashed items across all known trash directories",
|
||||
Run: runTrashList,
|
||||
}
|
||||
|
||||
var trashCountCmd = &cobra.Command{
|
||||
Use: "count",
|
||||
Short: "Print the total number of trashed items",
|
||||
Run: runTrashCount,
|
||||
}
|
||||
|
||||
var trashEmptyCmd = &cobra.Command{
|
||||
Use: "empty",
|
||||
Short: "Permanently delete every trashed item",
|
||||
Run: runTrashEmpty,
|
||||
}
|
||||
|
||||
var trashRestoreCmd = &cobra.Command{
|
||||
Use: "restore <name>",
|
||||
Short: "Restore a trashed item to its original location",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: runTrashRestore,
|
||||
}
|
||||
|
||||
var (
|
||||
trashJSONOutput bool
|
||||
trashRestoreDir string
|
||||
)
|
||||
|
||||
func init() {
|
||||
trashListCmd.Flags().BoolVar(&trashJSONOutput, "json", false, "Output as JSON")
|
||||
trashRestoreCmd.Flags().StringVar(&trashRestoreDir, "trash-dir", "", "Trash directory containing the item (default: home trash)")
|
||||
trashCmd.AddCommand(trashPutCmd, trashListCmd, trashCountCmd, trashEmptyCmd, trashRestoreCmd)
|
||||
}
|
||||
|
||||
func runTrashPut(cmd *cobra.Command, args []string) {
|
||||
var failed int
|
||||
for _, p := range args {
|
||||
if _, err := trash.Put(p); err != nil {
|
||||
log.Errorf("trash %s: %v", p, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
fmt.Println(p)
|
||||
}
|
||||
if failed > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runTrashList(cmd *cobra.Command, args []string) {
|
||||
entries, err := trash.List()
|
||||
if err != nil {
|
||||
log.Fatalf("list trash: %v", err)
|
||||
}
|
||||
|
||||
if trashJSONOutput {
|
||||
if entries == nil {
|
||||
entries = []trash.Entry{}
|
||||
}
|
||||
out, _ := json.MarshalIndent(entries, "", " ")
|
||||
fmt.Println(string(out))
|
||||
return
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
fmt.Println("Trash is empty")
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
marker := "F"
|
||||
if e.IsDir {
|
||||
marker = "D"
|
||||
}
|
||||
fmt.Printf("%s %s %s %s\n", marker, e.DeletionDate, e.Name, e.OriginalPath)
|
||||
}
|
||||
}
|
||||
|
||||
func runTrashCount(cmd *cobra.Command, args []string) {
|
||||
n, err := trash.Count()
|
||||
if err != nil {
|
||||
log.Fatalf("count trash: %v", err)
|
||||
}
|
||||
fmt.Println(n)
|
||||
}
|
||||
|
||||
func runTrashEmpty(cmd *cobra.Command, args []string) {
|
||||
if err := trash.Empty(); err != nil {
|
||||
log.Fatalf("empty trash: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runTrashRestore(cmd *cobra.Command, args []string) {
|
||||
if err := trash.Restore(args[0], trashRestoreDir); err != nil {
|
||||
log.Fatalf("restore: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
@@ -41,7 +40,8 @@ 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
|
||||
// ! disabled hyprland return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||
return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
@@ -55,7 +55,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{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
@@ -69,7 +69,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{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
@@ -83,7 +83,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{"niri"}, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
@@ -118,12 +118,9 @@ func getCompositor(args []string) string {
|
||||
if os.Getenv("NIRI_SOCKET") != "" {
|
||||
return "niri"
|
||||
}
|
||||
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
|
||||
return "hyprland"
|
||||
}
|
||||
if os.Getenv("MANGO_INSTANCE_SIGNATURE") != "" {
|
||||
return "mango"
|
||||
}
|
||||
// if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
|
||||
// return "hyprland"
|
||||
// }
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -143,14 +140,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 +183,11 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
|
||||
result.DMSStatus = parseResult.DMSStatus
|
||||
|
||||
case "hyprland":
|
||||
configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
|
||||
log.Fatalf("Hyprland support is currently disabled.") // ! disabled hyprland
|
||||
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 +219,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 +317,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
|
||||
}
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
cliPolicyPackagedPath = "/usr/share/dms/cli-policy.json"
|
||||
cliPolicyAdminPath = "/etc/dms/cli-policy.json"
|
||||
)
|
||||
|
||||
var (
|
||||
immutablePolicyOnce sync.Once
|
||||
immutablePolicy immutableCommandPolicy
|
||||
immutablePolicyErr error
|
||||
)
|
||||
|
||||
//go:embed assets/cli-policy.default.json
|
||||
var defaultCLIPolicyJSON []byte
|
||||
|
||||
type immutableCommandPolicy struct {
|
||||
ImmutableSystem bool
|
||||
ImmutableReason string
|
||||
BlockedCommands []string
|
||||
Message string
|
||||
}
|
||||
|
||||
type cliPolicyFile struct {
|
||||
PolicyVersion int `json:"policy_version"`
|
||||
ImmutableSystem *bool `json:"immutable_system"`
|
||||
BlockedCommands *[]string `json:"blocked_commands"`
|
||||
Message *string `json:"message"`
|
||||
}
|
||||
|
||||
func normalizeCommandSpec(raw string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(raw))
|
||||
normalized = strings.TrimPrefix(normalized, "dms ")
|
||||
return strings.Join(strings.Fields(normalized), " ")
|
||||
}
|
||||
|
||||
func normalizeBlockedCommands(raw []string) []string {
|
||||
normalized := make([]string, 0, len(raw))
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, cmd := range raw {
|
||||
spec := normalizeCommandSpec(cmd)
|
||||
if spec == "" || seen[spec] {
|
||||
continue
|
||||
}
|
||||
seen[spec] = true
|
||||
normalized = append(normalized, spec)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
func commandBlockedByPolicy(commandPath string, blocked []string) bool {
|
||||
normalizedPath := normalizeCommandSpec(commandPath)
|
||||
if normalizedPath == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, entry := range blocked {
|
||||
spec := normalizeCommandSpec(entry)
|
||||
if spec == "" {
|
||||
continue
|
||||
}
|
||||
if normalizedPath == spec || strings.HasPrefix(normalizedPath, spec+" ") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func loadPolicyFile(path string) (*cliPolicyFile, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read %s: %w", path, err)
|
||||
}
|
||||
|
||||
var policy cliPolicyFile
|
||||
if err := json.Unmarshal(data, &policy); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse %s: %w", path, err)
|
||||
}
|
||||
|
||||
return &policy, nil
|
||||
}
|
||||
|
||||
func mergePolicyFile(base *immutableCommandPolicy, path string) error {
|
||||
policyFile, err := loadPolicyFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if policyFile == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if policyFile.ImmutableSystem != nil {
|
||||
base.ImmutableSystem = *policyFile.ImmutableSystem
|
||||
}
|
||||
if policyFile.BlockedCommands != nil {
|
||||
base.BlockedCommands = normalizeBlockedCommands(*policyFile.BlockedCommands)
|
||||
}
|
||||
if policyFile.Message != nil {
|
||||
msg := strings.TrimSpace(*policyFile.Message)
|
||||
if msg != "" {
|
||||
base.Message = msg
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func readOSReleaseMap(path string) map[string]string {
|
||||
values := make(map[string]string)
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return values
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToUpper(strings.TrimSpace(parts[0]))
|
||||
value := strings.Trim(strings.TrimSpace(parts[1]), "\"")
|
||||
values[key] = strings.ToLower(value)
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
func hasAnyToken(text string, tokens ...string) bool {
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
for _, token := range tokens {
|
||||
if strings.Contains(text, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func detectImmutableSystem() (bool, string) {
|
||||
if _, err := os.Stat("/run/ostree-booted"); err == nil {
|
||||
return true, "/run/ostree-booted is present"
|
||||
}
|
||||
|
||||
osRelease := readOSReleaseMap("/etc/os-release")
|
||||
if len(osRelease) == 0 {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
id := osRelease["ID"]
|
||||
idLike := osRelease["ID_LIKE"]
|
||||
variantID := osRelease["VARIANT_ID"]
|
||||
name := osRelease["NAME"]
|
||||
prettyName := osRelease["PRETTY_NAME"]
|
||||
|
||||
immutableIDs := map[string]bool{
|
||||
"bluefin": true,
|
||||
"bazzite": true,
|
||||
"silverblue": true,
|
||||
"kinoite": true,
|
||||
"sericea": true,
|
||||
"onyx": true,
|
||||
"aurora": true,
|
||||
"fedora-iot": true,
|
||||
"fedora-coreos": true,
|
||||
}
|
||||
if immutableIDs[id] {
|
||||
return true, "os-release ID=" + id
|
||||
}
|
||||
|
||||
markers := []string{"silverblue", "kinoite", "sericea", "onyx", "bazzite", "bluefin", "aurora", "ostree", "atomic"}
|
||||
if hasAnyToken(variantID, markers...) {
|
||||
return true, "os-release VARIANT_ID=" + variantID
|
||||
}
|
||||
if hasAnyToken(idLike, "ostree", "rpm-ostree") {
|
||||
return true, "os-release ID_LIKE=" + idLike
|
||||
}
|
||||
if hasAnyToken(name, markers...) || hasAnyToken(prettyName, markers...) {
|
||||
return true, "os-release identifies an atomic/ostree variant"
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func getImmutablePolicy() (*immutableCommandPolicy, error) {
|
||||
immutablePolicyOnce.Do(func() {
|
||||
detectedImmutable, reason := detectImmutableSystem()
|
||||
immutablePolicy = immutableCommandPolicy{
|
||||
ImmutableSystem: detectedImmutable,
|
||||
ImmutableReason: reason,
|
||||
BlockedCommands: []string{"greeter install", "greeter enable", "setup"},
|
||||
Message: "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes.",
|
||||
}
|
||||
|
||||
var defaultPolicy cliPolicyFile
|
||||
if err := json.Unmarshal(defaultCLIPolicyJSON, &defaultPolicy); err != nil {
|
||||
immutablePolicyErr = fmt.Errorf("failed to parse embedded default CLI policy: %w", err)
|
||||
return
|
||||
}
|
||||
if defaultPolicy.BlockedCommands != nil {
|
||||
immutablePolicy.BlockedCommands = normalizeBlockedCommands(*defaultPolicy.BlockedCommands)
|
||||
}
|
||||
if defaultPolicy.Message != nil {
|
||||
msg := strings.TrimSpace(*defaultPolicy.Message)
|
||||
if msg != "" {
|
||||
immutablePolicy.Message = msg
|
||||
}
|
||||
}
|
||||
|
||||
if err := mergePolicyFile(&immutablePolicy, cliPolicyPackagedPath); err != nil {
|
||||
immutablePolicyErr = err
|
||||
return
|
||||
}
|
||||
if err := mergePolicyFile(&immutablePolicy, cliPolicyAdminPath); err != nil {
|
||||
immutablePolicyErr = err
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
if immutablePolicyErr != nil {
|
||||
return nil, immutablePolicyErr
|
||||
}
|
||||
return &immutablePolicy, nil
|
||||
}
|
||||
|
||||
func requireMutableSystemCommand(cmd *cobra.Command, _ []string) error {
|
||||
policy, err := getImmutablePolicy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !policy.ImmutableSystem {
|
||||
return nil
|
||||
}
|
||||
|
||||
commandPath := normalizeCommandSpec(cmd.CommandPath())
|
||||
if !commandBlockedByPolicy(commandPath, policy.BlockedCommands) {
|
||||
return nil
|
||||
}
|
||||
|
||||
reason := ""
|
||||
if policy.ImmutableReason != "" {
|
||||
reason = "Detected immutable system: " + policy.ImmutableReason + "\n"
|
||||
}
|
||||
|
||||
return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, cliPolicyPackagedPath, cliPolicyAdminPath)
|
||||
}
|
||||
|
||||
// preRunPrivileged combines the immutable-system check with a privesc tool
|
||||
// selection prompt (shown only when multiple tools are available and the
|
||||
// $DMS_PRIVESC env var isn't set).
|
||||
func preRunPrivileged(cmd *cobra.Command, args []string) error {
|
||||
if err := requireMutableSystemCommand(cmd, args); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := privesc.PromptCLI(os.Stdout, os.Stdin); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+16
-6
@@ -5,29 +5,39 @@ package main
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
)
|
||||
|
||||
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().MarkHidden("daemon-child")
|
||||
|
||||
// Add subcommands to greeter
|
||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd)
|
||||
|
||||
// Add subcommands to setup
|
||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||
|
||||
// Add subcommands to update
|
||||
updateCmd.AddCommand(updateCheckCmd)
|
||||
|
||||
// Add subcommands to plugins
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
|
||||
// Add common commands to root
|
||||
rootCmd.AddCommand(getCommonCommands()...)
|
||||
|
||||
rootCmd.AddCommand(authCmd)
|
||||
rootCmd.AddCommand(updateCmd)
|
||||
|
||||
rootCmd.SetHelpTemplate(getHelpTemplate())
|
||||
}
|
||||
|
||||
func main() {
|
||||
clipboard.MaybeServeAndExit()
|
||||
|
||||
if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) {
|
||||
if os.Geteuid() == 0 {
|
||||
log.Fatal("This program should not be run as root. Exiting.")
|
||||
}
|
||||
|
||||
|
||||
@@ -5,26 +5,36 @@ package main
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
func init() {
|
||||
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
|
||||
// Add flags
|
||||
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().MarkHidden("daemon-child")
|
||||
|
||||
// Add subcommands to greeter
|
||||
greeterCmd.AddCommand(greeterSyncCmd, greeterEnableCmd, greeterStatusCmd)
|
||||
|
||||
// Add subcommands to setup
|
||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||
|
||||
// Add subcommands to plugins
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
|
||||
// Add common commands to root
|
||||
rootCmd.AddCommand(getCommonCommands()...)
|
||||
rootCmd.AddCommand(authCmd)
|
||||
|
||||
rootCmd.SetHelpTemplate(getHelpTemplate())
|
||||
}
|
||||
|
||||
func main() {
|
||||
clipboard.MaybeServeAndExit()
|
||||
|
||||
if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) {
|
||||
// Block root
|
||||
if os.Geteuid() == 0 {
|
||||
log.Fatal("This program should not be run as root. Exiting.")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
type randrOutput struct {
|
||||
Name string `json:"name"`
|
||||
Scale float64 `json:"scale"`
|
||||
Width int32 `json:"width"`
|
||||
Height int32 `json:"height"`
|
||||
Refresh int32 `json:"refresh"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type randrHead struct {
|
||||
name string
|
||||
enabled bool
|
||||
scale float64
|
||||
currentModeID uint32
|
||||
modeIDs []uint32
|
||||
}
|
||||
|
||||
type randrMode struct {
|
||||
width int32
|
||||
height int32
|
||||
refresh int32
|
||||
}
|
||||
|
||||
type randrClient struct {
|
||||
display *wlclient.Display
|
||||
ctx *wlclient.Context
|
||||
manager *wlr_output_management.ZwlrOutputManagerV1
|
||||
heads map[uint32]*randrHead
|
||||
modes map[uint32]*randrMode
|
||||
done bool
|
||||
err error
|
||||
}
|
||||
|
||||
func queryRandr() ([]randrOutput, error) {
|
||||
display, err := wlclient.Connect("")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to Wayland: %w", err)
|
||||
}
|
||||
|
||||
c := &randrClient{
|
||||
display: display,
|
||||
ctx: display.Context(),
|
||||
heads: make(map[uint32]*randrHead),
|
||||
modes: make(map[uint32]*randrMode),
|
||||
}
|
||||
defer c.ctx.Close()
|
||||
|
||||
registry, err := display.GetRegistry()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get registry: %w", err)
|
||||
}
|
||||
|
||||
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
||||
if e.Interface == wlr_output_management.ZwlrOutputManagerV1InterfaceName {
|
||||
mgr := wlr_output_management.NewZwlrOutputManagerV1(c.ctx)
|
||||
version := min(e.Version, 4)
|
||||
|
||||
mgr.SetHeadHandler(func(e wlr_output_management.ZwlrOutputManagerV1HeadEvent) {
|
||||
c.handleHead(e)
|
||||
})
|
||||
|
||||
mgr.SetDoneHandler(func(e wlr_output_management.ZwlrOutputManagerV1DoneEvent) {
|
||||
c.done = true
|
||||
})
|
||||
|
||||
if err := registry.Bind(e.Name, e.Interface, version, mgr); err == nil {
|
||||
c.manager = mgr
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// First roundtrip: discover globals and bind manager
|
||||
syncCallback, err := display.Sync()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sync display: %w", err)
|
||||
}
|
||||
syncCallback.SetDoneHandler(func(e wlclient.CallbackDoneEvent) {
|
||||
if c.manager == nil {
|
||||
c.err = fmt.Errorf("zwlr_output_manager_v1 protocol not supported by compositor")
|
||||
c.done = true
|
||||
}
|
||||
// Otherwise wait for manager's DoneHandler
|
||||
})
|
||||
|
||||
for !c.done {
|
||||
if err := c.ctx.Dispatch(); err != nil {
|
||||
return nil, fmt.Errorf("dispatch error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
|
||||
return c.buildOutputs(), nil
|
||||
}
|
||||
|
||||
func (c *randrClient) handleHead(e wlr_output_management.ZwlrOutputManagerV1HeadEvent) {
|
||||
handle := e.Head
|
||||
headID := handle.ID()
|
||||
|
||||
head := &randrHead{
|
||||
modeIDs: make([]uint32, 0),
|
||||
}
|
||||
c.heads[headID] = head
|
||||
|
||||
handle.SetNameHandler(func(e wlr_output_management.ZwlrOutputHeadV1NameEvent) {
|
||||
head.name = e.Name
|
||||
})
|
||||
|
||||
handle.SetEnabledHandler(func(e wlr_output_management.ZwlrOutputHeadV1EnabledEvent) {
|
||||
head.enabled = e.Enabled != 0
|
||||
})
|
||||
|
||||
handle.SetScaleHandler(func(e wlr_output_management.ZwlrOutputHeadV1ScaleEvent) {
|
||||
head.scale = e.Scale
|
||||
})
|
||||
|
||||
handle.SetCurrentModeHandler(func(e wlr_output_management.ZwlrOutputHeadV1CurrentModeEvent) {
|
||||
head.currentModeID = e.Mode.ID()
|
||||
})
|
||||
|
||||
handle.SetModeHandler(func(e wlr_output_management.ZwlrOutputHeadV1ModeEvent) {
|
||||
modeHandle := e.Mode
|
||||
modeID := modeHandle.ID()
|
||||
|
||||
head.modeIDs = append(head.modeIDs, modeID)
|
||||
|
||||
mode := &randrMode{}
|
||||
c.modes[modeID] = mode
|
||||
|
||||
modeHandle.SetSizeHandler(func(e wlr_output_management.ZwlrOutputModeV1SizeEvent) {
|
||||
mode.width = e.Width
|
||||
mode.height = e.Height
|
||||
})
|
||||
|
||||
modeHandle.SetRefreshHandler(func(e wlr_output_management.ZwlrOutputModeV1RefreshEvent) {
|
||||
mode.refresh = e.Refresh
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (c *randrClient) buildOutputs() []randrOutput {
|
||||
outputs := make([]randrOutput, 0, len(c.heads))
|
||||
|
||||
for _, head := range c.heads {
|
||||
out := randrOutput{
|
||||
Name: head.name,
|
||||
Scale: head.scale,
|
||||
Enabled: head.enabled,
|
||||
}
|
||||
|
||||
if mode, ok := c.modes[head.currentModeID]; ok {
|
||||
out.Width = mode.width
|
||||
out.Height = mode.height
|
||||
out.Refresh = mode.refresh
|
||||
}
|
||||
|
||||
outputs = append(outputs, out)
|
||||
}
|
||||
|
||||
return outputs
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
+532
-160
@@ -2,28 +2,537 @@ 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 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.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)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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 +568,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 +590,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)
|
||||
}
|
||||
@@ -138,11 +626,12 @@ 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"}
|
||||
if qsHasAnyDisplay() {
|
||||
cmdArgs = append(cmdArgs, "--any-display")
|
||||
}
|
||||
cmdArgs := append(baseArgs, args...)
|
||||
cmdArgs = append(cmdArgs, "-p", configPath)
|
||||
cmdArgs = append(cmdArgs, args...)
|
||||
cmd := exec.Command("qs", cmdArgs...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
@@ -154,20 +643,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 +683,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+66
-13
@@ -1,24 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// isReadOnlyCommand returns true if the CLI args indicate a command that is
|
||||
// safe to run as root (e.g. shell completion, help).
|
||||
func isReadOnlyCommand(args []string) bool {
|
||||
for _, arg := range args[1:] {
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
continue
|
||||
}
|
||||
switch arg {
|
||||
case "completion", "help", "__complete", "system":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
func findCommandPath(cmd string) (string, error) {
|
||||
path, err := exec.LookPath(cmd)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("command '%s' not found in PATH", cmd)
|
||||
}
|
||||
return false
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func isArchPackageInstalled(packageName string) bool {
|
||||
@@ -26,3 +20,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
|
||||
}
|
||||
|
||||
+30
-54
@@ -1,100 +1,76 @@
|
||||
module github.com/AvengeMedia/DankMaterialShell/core
|
||||
|
||||
go 1.26.4
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/alecthomas/chroma/v2 v2.23.1
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/charmbracelet/log v0.4.2
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/godbus/dbus/v5 v5.2.2
|
||||
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847
|
||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
|
||||
github.com/pilebones/go-udev v0.9.1
|
||||
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
|
||||
github.com/spf13/cobra v1.10.2
|
||||
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.7.16
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||
go.etcd.io/bbolt v1.5.0
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745
|
||||
golang.org/x/image v0.44.0
|
||||
tailscale.com v1.100.0
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a
|
||||
golang.org/x/image v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
||||
github.com/akutz/memconn v0.1.0 // indirect
|
||||
github.com/charmbracelet/log v1.0.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.10.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/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // 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-20260209124918-37866f83c2d3 // 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/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // 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/mitchellh/go-ps v1.0.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
golang.org/x/crypto v0.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.zx2c4.com/wireguard/windows v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // 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
|
||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // 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-20260216160506-e6a3f881772f
|
||||
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/lucasb-eyer/go-colorful v1.3.0
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
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.19 // 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // 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.41.0
|
||||
golang.org/x/text v0.34.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
|
||||
+63
-123
@@ -1,20 +1,14 @@
|
||||
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=
|
||||
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
|
||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
|
||||
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
|
||||
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
|
||||
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||
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=
|
||||
@@ -30,95 +24,72 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5f
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
|
||||
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
|
||||
github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
|
||||
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
|
||||
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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
|
||||
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca h1:h1Awca4lQOspNR/2eeo04Ricn5NixDX9mb17WSAgLhQ=
|
||||
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca/go.mod h1:2TGl1jRJrRpbzykmg7asHm3h08TqutUgQqY5v9k/g3c=
|
||||
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.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/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=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
|
||||
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
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-20260209124918-37866f83c2d3 h1:UU7oARtwQ5g85aFiCSwIUA6PBmAshYj0sytl/5CCBgs=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3/go.mod h1:ZW9JC5gionMP1kv5uiaOaV23q0FFmNrVOV8VW+y/acc=
|
||||
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67 h1:3hutPZF+/FBjR/9MdsLJ7e1mlt9pwHgwxMW7CrbmWII=
|
||||
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f h1:TBkCJv9YwPOuXq1OG0r01bcxRrvs15Hp/DtZuPt4H6s=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f/go.mod h1:B88nWzfnhTlIikoJ4d84Nc9noKS5mJoA7SgDdkt0aPU=
|
||||
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=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
|
||||
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847 h1:1rQ5UQXFm02DXEtsIpotfA32WJ9KceS6t8w5K8QtFqc=
|
||||
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
|
||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 h1:B+A58zGFuDrvEZpPN+yS6swJA0nzqgZvDzgl/OPyefU=
|
||||
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90=
|
||||
github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM=
|
||||
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
||||
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/klauspost/cpuid/v2 v2.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=
|
||||
@@ -126,20 +97,14 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
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/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
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/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/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
@@ -148,13 +113,10 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8=
|
||||
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
|
||||
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -178,62 +140,40 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
|
||||
github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad h1:Ky26FR5yZ5IKEB0xtm5A8xSTb06ImY7kxBFrvgOmJSg=
|
||||
github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=
|
||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx4rE1PqeoW3U+53t34=
|
||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR91j81dr5XLT7heuljcNXW+oQ=
|
||||
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.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
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.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
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=
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package blur
|
||||
|
||||
import (
|
||||
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
|
||||
client "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
const extBackgroundEffectInterface = "ext_background_effect_manager_v1"
|
||||
|
||||
func ProbeSupport() (bool, error) {
|
||||
display, err := client.Connect("")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer display.Context().Close()
|
||||
|
||||
registry, err := display.GetRegistry()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
found := false
|
||||
registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) {
|
||||
switch e.Interface {
|
||||
case extBackgroundEffectInterface:
|
||||
found = true
|
||||
}
|
||||
})
|
||||
|
||||
if err := wlhelpers.Roundtrip(display, display.Context()); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return found, nil
|
||||
}
|
||||
@@ -5,212 +5,29 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"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"
|
||||
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() {
|
||||
if os.Getenv(envServe) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
mimeType := os.Getenv(envMime)
|
||||
pasteOnce := os.Getenv(envPasteOnce) == "1"
|
||||
cachePath := os.Getenv(envCacheFile)
|
||||
|
||||
var data []byte
|
||||
var err error
|
||||
|
||||
switch {
|
||||
case cachePath != "":
|
||||
data, err = os.ReadFile(cachePath)
|
||||
os.Remove(cachePath)
|
||||
default:
|
||||
data, err = io.ReadAll(os.Stdin)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "clipboard: read source: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := serveOffers(ExpandOffers(data, mimeType), pasteOnce); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
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")
|
||||
return CopyOpts(data, mimeType, false, false)
|
||||
}
|
||||
|
||||
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
|
||||
if foreground {
|
||||
return serveOffers(ExpandOffers(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)
|
||||
return copyServe(data, mimeType, pasteOnce)
|
||||
}
|
||||
|
||||
func CopyMulti(offers []Offer, foreground, pasteOnce bool) error {
|
||||
if foreground {
|
||||
return serveOffers(offers, pasteOnce)
|
||||
}
|
||||
return copyMultiFork(offers, pasteOnce)
|
||||
}
|
||||
|
||||
func newForkCmd(mimeType string, pasteOnce bool, extra ...string) *exec.Cmd {
|
||||
cmd := exec.Command(os.Args[0])
|
||||
cmd.Stderr = nil
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
cmd.Env = append(os.Environ(),
|
||||
envServe+"=1",
|
||||
envMime+"="+mimeType,
|
||||
)
|
||||
if pasteOnce {
|
||||
cmd.Env = append(cmd.Env, envPasteOnce+"=1")
|
||||
}
|
||||
cmd.Env = append(cmd.Env, extra...)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func waitReady(cmd *exec.Cmd) error {
|
||||
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)
|
||||
}
|
||||
var buf [1]byte
|
||||
if _, err := stdout.Read(buf[:]); err != nil {
|
||||
return fmt.Errorf("waiting for clipboard ready: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyForkCached(data []byte, mimeType string, pasteOnce bool) error {
|
||||
cacheFile, err := createClipboardCacheFile()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create cache file: %w", err)
|
||||
}
|
||||
cachePath := cacheFile.Name()
|
||||
|
||||
if _, err := cacheFile.Write(data); err != nil {
|
||||
cacheFile.Close()
|
||||
os.Remove(cachePath)
|
||||
return fmt.Errorf("write cache file: %w", err)
|
||||
}
|
||||
if err := cacheFile.Close(); err != nil {
|
||||
os.Remove(cachePath)
|
||||
return fmt.Errorf("close cache file: %w", err)
|
||||
}
|
||||
|
||||
cmd := newForkCmd(mimeType, pasteOnce, envCacheFile+"="+cachePath)
|
||||
cmd.Stdin = nil
|
||||
if err := waitReady(cmd); err != nil {
|
||||
os.Remove(cachePath)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
|
||||
cmd := newForkCmd(mimeType, pasteOnce)
|
||||
|
||||
if src, ok := data.(*os.File); ok {
|
||||
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__"}
|
||||
func copyFork(data []byte, mimeType string, pasteOnce bool) error {
|
||||
args := []string{os.Args[0], "cl", "copy", "--foreground"}
|
||||
if pasteOnce {
|
||||
args = append(args, "--paste-once")
|
||||
}
|
||||
args = append(args, "--type", mimeType)
|
||||
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.Stdin = nil
|
||||
@@ -227,64 +44,68 @@ func copyMultiFork(offers []Offer, pasteOnce bool) error {
|
||||
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)
|
||||
}
|
||||
if _, err := stdin.Write(data); err != nil {
|
||||
stdin.Close()
|
||||
return fmt.Errorf("write stdin: %w", err)
|
||||
}
|
||||
stdin.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func signalReady() {
|
||||
if os.Getenv(envServe) == "" {
|
||||
return
|
||||
}
|
||||
os.Stdout.Write([]byte{1})
|
||||
}
|
||||
|
||||
func createClipboardCacheFile() (*os.File, error) {
|
||||
preferredDirs := []string{}
|
||||
|
||||
if cacheDir, err := os.UserCacheDir(); err == nil {
|
||||
preferredDirs = append(preferredDirs, filepath.Join(cacheDir, "dms", "clipboard"))
|
||||
}
|
||||
preferredDirs = append(preferredDirs, "/var/tmp/dms/clipboard")
|
||||
|
||||
for _, dir := range preferredDirs {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
continue
|
||||
}
|
||||
cachedData, err := os.CreateTemp(dir, "dms-clipboard-*")
|
||||
if err == nil {
|
||||
return cachedData, nil
|
||||
}
|
||||
}
|
||||
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 copyServe(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)
|
||||
}
|
||||
|
||||
device, err := dataControlMgr.GetDataDevice(s.seat)
|
||||
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)
|
||||
}
|
||||
@@ -295,26 +116,35 @@ 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{})
|
||||
pasted := make(chan struct{}, 1)
|
||||
|
||||
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
|
||||
_ = syscall.SetNonblock(e.Fd, false)
|
||||
defer syscall.Close(e.Fd)
|
||||
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,8 +159,7 @@ func serveOffers(offers []Offer, pasteOnce bool) error {
|
||||
return fmt.Errorf("set selection: %w", err)
|
||||
}
|
||||
|
||||
s.display.Roundtrip()
|
||||
signalReady()
|
||||
display.Roundtrip()
|
||||
|
||||
for {
|
||||
select {
|
||||
@@ -341,26 +170,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 +259,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 +284,7 @@ func Paste() ([]byte, string, error) {
|
||||
}
|
||||
w.Close()
|
||||
|
||||
s.display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
@@ -456,3 +330,163 @@ 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) {
|
||||
defer syscall.Close(e.Fd)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user