Compare commits

..
Author SHA1 Message Date
bbedward cc307aff98 restore overlay option and tweak inset padding behavior 2026-07-22 14:48:58 -04:00
bbedward cee7957e15 fix hover popouts in frame mode 2026-07-22 13:21:43 -04:00
bbedward 602697ef51 frame: host bar and dock inside the frame surface in connected mode
Previously the bar-in-frame was rendered on the overlay layer always.
Now they are rendered inside of the frames top-layer surface, which lets
the compositor handle layer-shell layering natively.
2026-07-22 13:21:43 -04:00
1270 changed files with 51020 additions and 164979 deletions
-31
View File
@@ -514,37 +514,6 @@ Quickshell.execDetached(["dms", "cl", "copy", textToCopy])
**Do NOT use** `globalThis.clipboard` or browser JavaScript APIs - they don't exist in the QML runtime.
## Translations (Optional)
Ship per-locale files in a `translations/` directory; DMS loads the one matching the active locale and re-reads it on locale changes:
```
MyPlugin/
└── translations/
├── es.json
└── zh_CN.json
```
File names follow DMS locale naming (`es.json`, `pt.json`, `zh_CN.json`); resolution tries the exact tag, then the hyphenated form, then the bare language. No `en.json` - the QML terms are the English strings.
Each file maps the English term to its translation, using the term as its own context bucket:
```json
{
"Cycle Speed": {
"Cycle Speed": "Velocidad de ciclo"
}
}
```
In QML, call `I18n.trFor` with the plugin id as a literal string matching `plugin.json`:
```qml
text: I18n.trFor("myPlugin", "Cycle Speed")
```
Lookup order: plugin file, then the global DMS catalog, then the English term. `ExampleEmojiPlugin` in `quickshell/PLUGINS/` demonstrates the full setup. Registry plugins can apply for central POEditor translation via the registry's CONTRIBUTING guide.
## Step 11: Validate and Test
1. Validate `plugin.json` against the schema at [assets/plugin-schema.json](assets/plugin-schema.json)
-150
View File
@@ -1,150 +0,0 @@
#!/usr/bin/env bash
# Prunes retired XBPS packages from the R2 bucket so it stays inside the
# Cloudflare free tier. Only objects under ARCHIVE_PREFIX are eligible; live
# repositories (<repo>/current/) are never touched.
#
# publish-void-r2.sh writes retired packages to archive/<repo>/<published_at>/.
# Snapshots are dropped oldest first:
# 1. snapshots older than KEEP_DAYS, keeping KEEP_MIN_SNAPSHOTS per repo
# 2. then more until the whole bucket is <= MAX_BUCKET_GB
set -euo pipefail
require_env() {
local name
for name in "$@"; do
if [[ -z "${!name:-}" ]]; then
echo "error: $name is required" >&2
exit 2
fi
done
}
require_env R2_BUCKET R2_ENDPOINT
ARCHIVE_PREFIX="${ARCHIVE_PREFIX:-archive/}"
MAX_BUCKET_GB="${MAX_BUCKET_GB:-8}"
KEEP_DAYS="${KEEP_DAYS:-30}"
KEEP_MIN_SNAPSHOTS="${KEEP_MIN_SNAPSHOTS:-2}"
DRY_RUN="${DRY_RUN:-false}"
case "$ARCHIVE_PREFIX" in
*/) ;;
*) echo "error: ARCHIVE_PREFIX must end with /" >&2; exit 2 ;;
esac
aws_r2() {
aws --endpoint-url "$R2_ENDPOINT" "$@"
}
human() {
numfmt --to=iec-i --suffix=B "$1"
}
objects="$(aws_r2 s3api list-objects-v2 \
--bucket "$R2_BUCKET" \
--output json \
--query 'Contents[].{key:Key,size:Size}')"
[[ -n "$objects" && "$objects" != null ]] || objects='[]'
total="$(jq 'map(.size) | add // 0' <<<"$objects")"
budget="$(awk -v g="$MAX_BUCKET_GB" 'BEGIN { printf "%d", g * 1073741824 }')"
now="$(date -u +%s)"
snapshots="$(jq -c --arg p "$ARCHIVE_PREFIX" '
map(select(.key | startswith($p)))
| map(.rel = (.key[($p | length):] | split("/")))
| map(select((.rel | length) >= 3))
| group_by(.rel[0:2])
| map({
repo: .[0].rel[0],
stamp: .[0].rel[1],
prefix: ($p + (.[0].rel[0:2] | join("/")) + "/"),
size: (map(.size) | add),
count: length
})
| sort_by(.stamp)
' <<<"$objects")"
to_delete="$(jq -c \
--argjson now "$now" \
--argjson keepDays "$KEEP_DAYS" \
--argjson keepMin "$KEEP_MIN_SNAPSHOTS" \
--argjson total "$total" \
--argjson budget "$budget" '
def epoch:
.stamp
| sub("T(?<h>[0-9]{2})-(?<m>[0-9]{2})-(?<s>[0-9]{2})Z$"; "T\(.h):\(.m):\(.s)Z")
| try fromdateiso8601 catch null;
(group_by(.repo) | map({key: .[0].repo, value: length}) | from_entries) as $perRepo
| reduce .[] as $s ({kept: $perRepo, total: $total, delete: []};
($s | epoch) as $e
| (.kept[$s.repo] > $keepMin and $e != null and ($now - $e) > $keepDays * 86400) as $expired
| (.total > $budget) as $over
| if $expired or $over then
.delete += [$s + {reason: (if $over then "budget" else "age" end)}]
| .total -= $s.size
| .kept[$s.repo] -= 1
else . end)
| .delete
' <<<"$snapshots")"
echo "bucket: $R2_BUCKET ($(human "$total") in $(jq length <<<"$objects") objects)"
echo "budget: $(human "$budget") (MAX_BUCKET_GB=$MAX_BUCKET_GB)"
echo "archive: $(jq length <<<"$snapshots") snapshots, $(human "$(jq 'map(.size) | add // 0' <<<"$snapshots")")"
echo "policy: KEEP_DAYS=$KEEP_DAYS KEEP_MIN_SNAPSHOTS=$KEEP_MIN_SNAPSHOTS DRY_RUN=$DRY_RUN"
deleted=0
freed=0
while IFS=$'\t' read -r prefix size count reason; do
[[ -n "$prefix" ]] || continue
# Belt and braces: never delete outside the archive or anything live.
case "$prefix" in
"$ARCHIVE_PREFIX"*) ;;
*) echo "error: refusing to delete $prefix (outside $ARCHIVE_PREFIX)" >&2; exit 1 ;;
esac
case "$prefix" in
*/current/*) echo "error: refusing to delete live prefix $prefix" >&2; exit 1 ;;
esac
if [[ "$DRY_RUN" == "true" ]]; then
echo "would delete $prefix ($count objects, $(human "$size"), $reason)"
else
echo "deleting $prefix ($count objects, $(human "$size"), $reason)"
aws_r2 s3 rm "s3://${R2_BUCKET}/${prefix}" --recursive --only-show-errors
fi
deleted=$((deleted + 1))
freed=$((freed + size))
done < <(jq -r '.[] | [.prefix, .size, .count, .reason] | @tsv' <<<"$to_delete")
remaining=$((total - freed))
echo "pruned: $deleted snapshots, $(human "$freed") freed, $(human "$remaining") remaining"
if (( remaining > budget )); then
echo "::warning::bucket is still $(human "$remaining") after pruning (budget $(human "$budget")); live repositories exceed the budget"
fi
note=""
if [[ "$DRY_RUN" == "true" ]]; then
note=" (dry run)"
fi
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
{
echo "### R2 prune: \`$R2_BUCKET\`"
echo
echo "| | |"
echo "|---|---|"
echo "| Before | $(human "$total") |"
echo "| Budget | $(human "$budget") |"
echo "| Snapshots pruned | $deleted$note |"
echo "| Freed | $(human "$freed") |"
echo "| After | $(human "$remaining") |"
if (( deleted > 0 )); then
echo
echo "| Snapshot | Objects | Bytes | Reason |"
echo "|---|---|---|---|"
jq -r '.[] | "| `\(.prefix)` | \(.count) | \(.size) | \(.reason) |"' <<<"$to_delete"
fi
} >> "$GITHUB_STEP_SUMMARY"
fi
-115
View File
@@ -1,115 +0,0 @@
name: Claude Review
# `/claude review` on a PR by a member of an allowed team. Review only, no edits or pushes.
# Secrets: CLAUDE_CODE_OAUTH_TOKEN (claude setup-token), CLAUDE_REVIEW_ORG_READ_TOKEN (PAT, org Members: read)
on:
issue_comment:
types: [created]
permissions: {}
jobs:
gate:
if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/claude review')
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
outputs:
allowed: ${{ steps.team.outputs.allowed }}
steps:
- name: Check team membership
id: team
env:
GH_TOKEN: ${{ secrets.CLAUDE_REVIEW_ORG_READ_TOKEN }}
ORG: ${{ github.repository_owner }}
USER: ${{ github.event.comment.user.login }}
TEAMS: dms-collaborators owners
run: |
allowed=false
for team in $TEAMS; do
state=$(gh api "orgs/$ORG/teams/$team/memberships/$USER" --jq .state 2>/dev/null || true)
[ "$state" = active ] && allowed=true && break
done
echo "allowed=$allowed" >> "$GITHUB_OUTPUT"
- name: React
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ALLOWED: ${{ steps.team.outputs.allowed }}
COMMENT_ID: ${{ github.event.comment.id }}
run: |
[ "$ALLOWED" = true ] && content=eyes || content=-1
gh api -X POST "repos/${{ github.repository }}/issues/comments/$COMMENT_ID/reactions" -f "content=$content"
review:
needs: gate
if: needs.gate.outputs.allowed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: claude-review-${{ github.event.issue.number }}
cancel-in-progress: false
permissions:
contents: read
pull-requests: write
issues: write
id-token: write
steps:
- uses: actions/checkout@v6
with:
ref: refs/pull/${{ github.event.issue.number }}/head
fetch-depth: 0
submodules: recursive
- uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
Review pull request #${{ github.event.issue.number }} in ${{ github.repository }}, requested by @${{ github.event.comment.user.login }}:
---
${{ github.event.comment.body }}
---
Text after `/claude review` is an optional focus. Honor it but still report anything serious outside it.
The PR head is checked out. Use `gh pr view ${{ github.event.issue.number }}` and `gh pr diff ${{ github.event.issue.number }}`, read surrounding code as needed.
Steps:
1. Read CONTRIBUTING.md.
2. Read the PR title, description and diff.
3. Check existing comments so you don't repeat them: `gh api repos/${{ github.repository }}/pulls/${{ github.event.issue.number }}/comments` and `gh pr view ${{ github.event.issue.number }} --comments`. Skip findings already covered by a comment that still applies.
4. Verify each candidate finding against the code. Drop anything you can't confirm.
5. Post one inline comment per confirmed finding with `mcp__github_inline_comment__create_inline_comment` (`confirmed: true`). Say what's wrong, why, and the fix. Add a committable suggestion only if it alone fully fixes the issue.
6. Post one new summary comment with `gh pr comment ${{ github.event.issue.number }} --body ...`. Never edit earlier comments. Format:
## Claude review
<one line verdict>
- <finding, file:line> per inline comment posted this run
Checked: <one line>. Model: claude-opus-5.
No findings: "No issues found." plus what was checked.
Flag only issues introduced by this PR:
- Bugs: wrong logic, crashes, nil/undefined access, races, leaks (timers, connections, processes, handles), bad Go error handling, edge cases that will clearly be hit.
- QML: undefined refs, missing imports, binding loops, bindings that never update, handlers on the wrong object, raw Flickable/ListView/ScrollView instead of the Dank* wrappers.
- Security: command injection, path traversal, unsafe file permissions, secrets in code.
- CONTRIBUTING.md rules: user-facing strings in `I18n.tr()` with translator context, reuse existing terms from quickshell/translations/en.json; en.json and template.json must not be touched in a PR; mocks under core/internal/mocks/ are regenerated not hand-edited; new dank-qml-common contract properties need the matching library change.
- Go: unformatted code, go.mod/go.sum out of sync with imports, tests that no longer compile.
Don't flag style or naming, pre-existing issues, anything a linter or compiler catches, speculative input-dependent problems, missing tests or docs, or refactor ideas. Not sure it's real: don't post it.
Review only. Never edit files, commit, push, approve or request changes. Post each finding once. Keep comments short.
claude_args: |
--model claude-opus-5
--max-turns 80
--allowedTools "Read,Grep,Glob,LS,Task,mcp__github_inline_comment__create_inline_comment,Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr comment:*),Bash(gh api repos/${{ github.repository }}/pulls/${{ github.event.issue.number }}/comments:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*)"
--disallowedTools "Edit,Write,MultiEdit,NotebookEdit,WebFetch,WebSearch"
- if: failure()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr comment ${{ github.event.issue.number }} --body "Claude review failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
+5 -13
View File
@@ -31,25 +31,18 @@ jobs:
submodules: recursive
- name: Install flatpak
run: sudo apt-get update && sudo apt-get install -y flatpak
run: sudo apt update && sudo apt install -y flatpak
- name: Cache flatpak runtime
uses: actions/cache@v4
with:
path: ~/.local/share/flatpak
key: flatpak-org.freedesktop.Platform-24.08
- name: Add flathub
run: sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
- name: Install a flatpak for tests
run: |
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak info org.freedesktop.Platform//24.08 >/dev/null 2>&1 || \
flatpak install --user -y org.freedesktop.Platform//24.08
- name: Add a flatpak that mutagen could support
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
with:
go-version-file: ./core/go.mod
cache-dependency-path: ./core/go.sum
- name: Test
run: go test -v ./...
@@ -82,7 +75,6 @@ jobs:
uses: actions/setup-go@v6
with:
go-version-file: ./core/go.mod
cache-dependency-path: ./core/go.sum
- name: Build all packages
env:
-66
View File
@@ -1,66 +0,0 @@
name: Prune Void R2 archive
# Keeps the danklinux-void bucket inside the Cloudflare R2 free tier by
# deleting the oldest archive/<repo>/<timestamp>/ snapshots written by
# publish-void-r2.sh. Live repositories (<repo>/current/) are never touched.
on:
schedule:
- cron: "30 6 * * *"
workflow_dispatch:
inputs:
dry_run:
description: 'Report what would be deleted without deleting'
type: boolean
required: false
default: false
max_bucket_gb:
description: 'Total bucket size to prune down to (GB)'
type: string
required: false
default: '8'
keep_days:
description: 'Delete archive snapshots older than this many days'
type: string
required: false
default: '30'
keep_min_snapshots:
description: 'Always keep at least this many snapshots per repo (age rule only)'
type: string
required: false
default: '2'
permissions:
contents: read
# Serialize with the XBPS publisher so a snapshot is never pruned mid-publish.
concurrency:
group: void-xbps-${{ github.ref }}
cancel-in-progress: false
jobs:
prune:
name: Prune archive snapshots
runs-on: ubuntu-latest
if: github.repository == 'AvengeMedia/DankMaterialShell'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: true
R2_BUCKET: danklinux-void
R2_ENDPOINT: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com
ARCHIVE_PREFIX: archive/
MAX_BUCKET_GB: ${{ github.event.inputs.max_bucket_gb || '8' }}
KEEP_DAYS: ${{ github.event.inputs.keep_days || '30' }}
KEEP_MIN_SNAPSHOTS: ${{ github.event.inputs.keep_min_snapshots || '2' }}
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
- name: Prune archive
run: bash .github/scripts/prune-void-r2.sh
-57
View File
@@ -23,26 +23,7 @@ concurrency:
cancel-in-progress: true
jobs:
verify-version:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.tag }}
- name: Verify quickshell/VERSION matches the tag
env:
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
version=$(cat quickshell/VERSION)
if [ "${version#v}" != "${TAG#v}" ]; then
echo "::error::quickshell/VERSION is '${version}' but the tag is '${TAG}'"; exit 1
fi
build-core:
needs: [verify-version]
runs-on: ubuntu-latest
strategy:
matrix:
@@ -516,41 +497,3 @@ jobs:
exit 0
fi
bash scripts/notify-issues.sh "$PREVIOUS_TAG" "$TAG"
# Pin the satellite repos to this release's version. Each dispatch target
# decides for itself whether to tag: X.Y.0 always tags, X.Y.Z skips when the
# repo has no commits of its own since its last tag.
fanout:
name: Pin ${{ matrix.repo }}
runs-on: ubuntu-latest
needs: [release]
if: ${{ !contains(inputs.tag, '-') }}
strategy:
fail-fast: false
matrix:
repo:
- dgop
- dankgo
- dank-qml-common
- dankcalendar
- danksearch
- dank-greeter
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ matrix.repo }}
- name: Dispatch pin-release
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
REPO: ${{ github.repository_owner }}/${{ matrix.repo }}
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
gh workflow run pin-release.yml --repo "$REPO" -f version="$TAG"
echo "dispatched pin-release ${TAG} to ${REPO}" >> "$GITHUB_STEP_SUMMARY"
-1
View File
@@ -117,7 +117,6 @@ core.*
.envrc
.direnv/
quickshell/dms-plugins
quickshell/dms-plugins-external
__pycache__
.vscode/
+1 -1
View File
@@ -26,7 +26,7 @@ repos:
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|Common/SettingsTabs\.qml|Services/SettingsSearchService\.qml|translations/(extract_settings_index\.py|en\.json))$
files: ^quickshell/(Modules/Settings/.*\.qml|Modals/Settings/SettingsSidebar\.qml|translations/extract_settings_index\.py)$
pass_filenames: false
- repo: local
hooks:
-62
View File
@@ -1,62 +0,0 @@
## What is this?
DMS is an open-source, MIT-licensed desktop shell for Wayland compositors on linux. Like KWin, gnome-shell, or cosmic-shell - but designed to work with many different compositors.
## Repo Structure:
There are 2 main apps in the repo, they are designed to operate together. Additionally there is a dank-qml-common submodule which contains common QML widgets, like a shared library, that is leveraged by other quickshell apps in the Dank Desktop suite.
There is a GO module (dankgo) that is used by core for certain common go functionality.
### 1- core:
Contains 2 apps:
1. cmd/dms - The parent process for quickshell (shell runner), and general purpose tools and utilities (such as screenshot, clipboard). It also runs the unix socket server the UI uses for functionality quickshell doesn't provide.
2. cmd/dankinstall - An entirely separate app, a TUI for quickly installing DMS
### 2- quickshell:
A quickshell application, the entirety of the UI for DMS. It uses quickshells own services supplemented by the core unix socket server for additional functionality.
Layout:
- Common/ - app-wide singletons and helpers (Theme, SettingsData, SessionData, I18n, PopoutManager)
- Services/ - headless singletons that talk to the system and to core
- Modules/ - the visible shell: DankBar, ControlCenter, Dock, Lock, Notifications, OSD, and so on
- Modals/ - large standalone surfaces: Settings, DankLauncherV2, Clipboard, Greeter
- Widgets/ - reusable Dank* components (DankListView, DankIcon, DankToggle, ...)
- PLUGINS/ - the plugin system; third-party plugins load from the user's config dir
- translations/ - POEditor-synced catalogs
### Other directories:
- docs/ - IPC reference, custom theme docs
- distro/ - packaging for debian, fedora, nix, opensuse, ubuntu, void
## General Rules:
- Keep it simple. Do not overcomplicate things.
- Follow each apps' own conventions. For example, QML widgets use Theme tokens instead of hardcoding colors, spacing, or other constants.
- Resource usage is extremely important to DMS. The shell runs 24/7 on every machine it's installed on - audit any change for idle CPU cost, extra processes, timers, and retained memory.
- `core` and `quickshell` are tightly coupled through the unix socket protocol. When you are changing one, be mindful of the consequences for the other.
- Use the Dank* wrappers in Widgets/ (DankListView, DankFlickable, and so on) instead of raw ListView/Flickable/ScrollView.
- All user-facing text goes through I18n.tr(). Prefer reusing existing catalog terms over adding new ones. Never edit the translation catalogs by hand, they are synced with POEditor.
- Do not start editing code in response to a question. We'll tell you when to edit code.
- Do not leave paragraphs of comments on top of the code. You should try to avoid them as much as possible with understandable function names and code. If they are necessary even then, make them concise. Remove such comments when you come by them in the codebase. Comments should always move with code, not be left behind.
- Use guard statement patterns in any code you write.
- Do not write any useless tests, tests should cover input output validation - not useless things like "main.go contains func main()"
- Do not edit generated code directly, mocks are generated with mockery. Not edited by hand
- If we are missing a glaring issue when we ask you to do something, do not hesitate to point it out.
- Reinvent the wheel but do not reinvent the car. If you are solving a simple problem do not introduce a library. If you are solving a complex but a common problem, there is likely a modern library for it, if so, use it.
- Never commit or push code unless explicitly asked to do so.
- Never make a PR unless explicitly asked to do so.
## Commit Messages
Commit messages start with the part of the system they touched, followed by a short lowercase explanation of the work:
network: report wifi state from the associated adapter
doctor: flag config files that fail to parse
The title should be concise. Description should explain the work in more detail (only if required) while still being concise. Use simple language, do not try to sound smart.
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
-17
View File
@@ -159,8 +159,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)
Don't re-extract the translations. `en.json` and `template.json` are synced with POEditor by a maintainer script, so running `extract_translations.py` in your PR just makes a diff that fights the next sync. Add your `I18n.tr()` calls and leave the catalogs alone. (`settings_search_index.json` is the exception, the pre-commit hook regenerates that one when you touch settings QML.)
Strings inside `quickshell/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)
@@ -171,21 +169,6 @@ Strings inside `quickshell/DankCommon/` are owned by the dank-qml-common repo bu
4. Run `go mod tidy`
5. Open pull request
golangci-lint runs as a pre-commit hook and covers `go vet`, so there's nothing separate to run. If you run `go vet ./...` yourself you'll see complaints about the generated mocks, ignore them, `core/.golangci.yml` excludes that directory.
#### Mocks
Test mocks under `core/internal/mocks/` are generated with [mockery](https://vektra.github.io/mockery/) v3. Don't edit them by hand, regenerate after changing a mocked interface:
```bash
cd core
go run github.com/vektra/mockery/v3@latest
# or with it installed (go install github.com/vektra/mockery/v3@latest):
mockery
```
`core/.mockery.yml` lists every mocked interface and where its mock goes (e.g. `network.Backend` -> `internal/mocks/network/mock_Backend.go`). To mock a new interface, add it there under its package and regenerate.
## Pull request
Include screenshots/video if applicable in your pull request if applicable, to visualize what your change is affecting.
+13 -4
View File
@@ -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-completions install-systemd install-icon install-desktop uninstall uninstall-bin uninstall-shell uninstall-completions uninstall-systemd uninstall-icon uninstall-desktop help
.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
all: build
@@ -53,6 +53,14 @@ install-bin:
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
@echo "Binary installed"
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)/
@rm -rf $(SHELL_INSTALL_DIR)/.git* $(SHELL_INSTALL_DIR)/.github
@echo "Shell files installed"
install-completions:
@echo "Installing shell completions..."
@mkdir -p $(DATA_DIR)/bash-completion/completions
@@ -90,7 +98,7 @@ install-desktop:
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
@echo "Desktop entries installed"
install: install-bin install-completions install-systemd install-icon install-desktop
install: install-bin install-shell install-completions install-systemd install-icon install-desktop
@echo ""
@echo "Installation complete!"
@echo ""
@@ -103,7 +111,7 @@ uninstall-bin:
@echo "Binary removed"
uninstall-shell:
@echo "Removing pre-1.6 shell files from $(SHELL_INSTALL_DIR)..."
@echo "Removing shell files from $(SHELL_INSTALL_DIR)..."
@rm -rf $(SHELL_INSTALL_DIR)
@echo "Shell files removed"
@@ -151,6 +159,7 @@ help:
@echo "Install:"
@echo " install - Build and install everything (requires sudo)"
@echo " install-bin - Install only the binary"
@echo " install-shell - Install only shell files"
@echo " install-completions - Install only shell completions"
@echo " install-systemd - Install only systemd service"
@echo " install-icon - Install only icon"
@@ -159,7 +168,7 @@ help:
@echo "Uninstall:"
@echo " uninstall - Remove everything (requires sudo)"
@echo " uninstall-bin - Remove only the binary"
@echo " uninstall-shell - Remove shell files left by pre-1.6 installs"
@echo " uninstall-shell - Remove only shell files"
@echo " uninstall-completions - Remove only shell completions"
@echo " uninstall-systemd - Remove only systemd service"
@echo " uninstall-icon - Remove only icon"
+5 -9
View File
@@ -47,7 +47,7 @@ DankMaterialShell/
<div align="center">
https://github.com/user-attachments/assets/d310418b-cc44-4ace-ac0d-2fc0c851291d
https://github.com/user-attachments/assets/1200a739-7770-4601-8b85-695ca527819a
</div>
@@ -83,7 +83,7 @@ One command installs DMS and all dependencies on Arch, Fedora, Debian, Ubuntu, o
Wallpaper-based color schemes that automatically theme GTK, Qt, terminals, editors (vscode, vscodium), and more using [matugen](https://github.com/InioX/matugen) and dank16.
**System Monitoring**
Real-time CPU, RAM, GPU metrics and temperatures built into the dms daemon (powered by [dgop](https://github.com/AvengeMedia/dgop)). Process list with search and management.
Real-time CPU, RAM, GPU metrics and temperatures with [dgop](https://github.com/AvengeMedia/dgop). Process list with search and management.
**Powerful Launcher**
Spotlight-style search for applications, files ([dsearch](https://github.com/AvengeMedia/danksearch)), emojis, running windows, calculator, and commands. Extensible with plugins.
@@ -101,9 +101,7 @@ MPRIS player controls, calendar sync, weather widgets, and clipboard history wit
Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and a settings front-end for [dank-greeter](https://github.com/AvengeMedia/dank-greeter).
**Plugin System**
Extend functionality with the [plugin registry](https://plugins.danklinux.com). DMS keeps
`~/.config/DankMaterialShell/plugins.lock.json` synchronized with managed plugin installs and
their exact Git commits, so the same plugins can be reproduced on another machine.
Extend functionality with the [plugin registry](https://plugins.danklinux.com).
## Supported Compositors
@@ -122,8 +120,6 @@ dms ipc call audio setvolume 50
dms ipc call wallpaper set /path/to/image.jpg
dms brightness list # List available displays
dms plugins search # Browse plugin registry
dms plugins lock # Refresh the portable plugin lockfile
dms plugins restore ~/plugins.lock.json
```
[Full CLI and IPC documentation](https://danklinux.com/docs/dankmaterialshell/keybinds-ipc)
@@ -142,7 +138,7 @@ 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 TUI and Go library; its library powers the process list and dashboard widgets inside the dms daemon.
- [dgop](https://github.com/AvengeMedia/dgop) - System monitoring 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.
@@ -203,7 +199,7 @@ For documentation contributions, see [DankLinux-Docs](https://github.com/AvengeM
## Star History
[![Star History Chart](https://star-history.dera.page/svg?repos=AvengeMedia/DankMaterialShell&type=date&legend=top-left)](https://star-history.dera.page/#AvengeMedia/DankMaterialShell&type=date&legend=top-left)
[![Star History Chart](https://api.star-history.com/svg?repos=AvengeMedia/DankMaterialShell&type=date&legend=top-left)](https://www.star-history.com/#AvengeMedia/DankMaterialShell&type=date&legend=top-left)
## License
+1 -5
View File
@@ -8,13 +8,9 @@ Requisite=graphical-session.target
Type=dbus
BusName=org.freedesktop.Notifications
ExecStart=/usr/bin/dms run --session
ExecReload=/bin/kill -USR1 $MAINPID
LimitNOFILE=16384:infinity
ExecReload=/usr/bin/pkill -USR1 -x dms
Restart=on-failure
RestartForceExitStatus=TEMPFAIL
SuccessExitStatus=TEMPFAIL
RestartSec=1.23
TimeoutStartSec=90s
TimeoutStopSec=10
[Install]
+6 -5
View File
@@ -72,6 +72,7 @@ linters:
- path: _test\.go
linters:
- errcheck
- govet
- unused
- ineffassign
- staticcheck
@@ -92,15 +93,15 @@ linters:
- linters:
- errcheck
text: "Error return value of `.+\\.AddMatchSignal` is not checked"
# Exclude wayland pkg from errcheck and ineffassign (generated code patterns)
- linters:
- errcheck
- ineffassign
path: pkg/go-wayland/
# Exclude proto pkg from ineffassign (generated protocol code)
- linters:
- ineffassign
path: internal/proto/
# Exclude proto pkg SA4006 (generated protocol code has trailing dead offset increments)
- linters:
- staticcheck
text: "SA4006:"
path: internal/proto/
# binary.Write/Read to bytes.Buffer can't fail
- linters:
- errcheck
+1 -1
View File
@@ -52,7 +52,7 @@ packages:
pkgname: mocks_version
interfaces:
VersionFetcher: {}
github.com/AvengeMedia/dankgo/wayland/client:
github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client:
config:
dir: internal/mocks/wlclient
pkgname: mocks_wlclient
+1 -1
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/golangci/golangci-lint
rev: v2.13.1
rev: v2.12.2
hooks:
- id: golangci-lint-fmt
require_serial: true
+1 -2
View File
@@ -135,8 +135,7 @@ fmt:
vet:
@echo "Running go vet..."
$(GO) vet $$($(GO) list ./... | grep -v internal/highlight/lexers)
$(GO) vet -composites=false ./internal/highlight/lexers
$(GO) vet ./...
deps:
@echo "Updating dependencies..."
+9 -19
View File
@@ -74,7 +74,7 @@ Custom IPC via unix socket (JSON API) for shell communication.
- `dms run [-d]` - Start shell (optionally as daemon)
- `dms restart` / `dms kill` - Manage running processes
- `dms ipc <command>` - Send IPC commands (toggle launcher, notifications, etc.)
- `dms plugins [install|browse|search|lock|restore]` - Plugin management and portable exact-revision lockfiles
- `dms plugins [install|browse|search]` - Plugin management
- `dms brightness [list|set]` - Control display/monitor brightness
- `dms color pick` - Native color picker (see below)
- `dms update` - Update DMS and dependencies (disabled in distro packages)
@@ -132,11 +132,9 @@ This runs gofmt, golangci-lint, tests, and builds before each commit when `core/
**Regenerating Wayland Protocol Bindings:**
Protocol XML files live in `internal/proto/xml/`. Bindings are generated with the [dankgo](https://github.com/AvengeMedia/dankgo) `go-wayland-scanner`:
```bash
go run github.com/AvengeMedia/dankgo/cmd/go-wayland-scanner@latest \
-i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
go install github.com/rajveermalviya/go-wayland/cmd/go-wayland-scanner@latest
go-wayland-scanner -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \
-pkg wlr_gamma_control -o internal/proto/wlr_gamma_control/gamma_control.go
```
@@ -157,25 +155,17 @@ curl -fsSL https://install.danklinux.com | sh
**Headless (unattended):**
Headless mode requires cached credentials or a passwordless rule for your privilege escalation tool (sudo, doas, or run0). With sudo, run `sudo -v` first:
Headless mode requires cached sudo credentials. Run `sudo -v` first:
```bash
sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c niri -t ghostty -y
sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c hyprland -t kitty --dms-greeter -y
sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c niri -t ghostty --git-deps niri,quickshell --all-features -y
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\|mango>` | `-c` | Compositor/WM to install (required for headless) |
| `--term <ghostty\|kitty\|alacritty>` | `-t` | Terminal emulator (required for headless) |
| `--privesc <sudo\|doas\|run0>` | | Privilege escalation tool (default: autodetect) |
| `--git` | | Install the git version of every dependency that has one |
| `--git-deps <name,...>` | | Install the git version of specific dependencies (e.g. `niri,quickshell`) |
| `--all-features` | | Enable all optional dependencies (`dms-greeter`, `danksearch`, `dankcalendar`) |
| `--dms-greeter` | | Install dms-greeter |
| `--danksearch` | | Install danksearch and enable its user indexing service |
| `--dankcalendar` | | Install dankcalendar |
| `--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`) |
@@ -184,7 +174,7 @@ sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c niri -t ghostt
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.
Optional packages (`dms-greeter`, `danksearch`, `dankcalendar`) are disabled by default; enable them with their dedicated flags, `--include-deps`, or `--all-features`.
`dms-greeter` is disabled by default; use `--include-deps dms-greeter` to enable it.
When no flags are provided, `dankinstall` launches the interactive TUI.
@@ -193,7 +183,7 @@ When no flags are provided, `dankinstall` launches the interactive TUI.
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 (`--privesc`, `--git`, `--git-deps`, `--all-features`, `--include-deps`, `--exclude-deps`, `--replace-configs`, `--replace-configs-all`, `--yes`, `--danksearch`, `--dankcalendar`, `--dms-greeter`) are rejected in TUI mode.
- Headless-only flags (`--include-deps`, `--exclude-deps`, `--replace-configs`, `--replace-configs-all`, `--yes`) are rejected in TUI mode.
- Positional arguments are not accepted.
### Log file location
+18 -25
View File
@@ -14,13 +14,10 @@ import (
var Version = "dev"
// Flag variables bound via pflag
var (
compositor string
term string
privescTool string
gitAll bool
gitDeps []string
allFeatures bool
includeDeps []string
excludeDeps []string
replaceConfigs []string
@@ -28,7 +25,6 @@ var (
yes bool
danksearch bool
dankcalendar bool
dmsGreeter bool
)
var rootCmd = &cobra.Command{
@@ -39,8 +35,8 @@ var rootCmd = &cobra.Command{
Without flags, it launches an interactive TUI. Providing either --compositor
or --term activates headless (unattended) mode, which requires both flags.
Headless mode requires cached credentials or a passwordless rule for your
privilege escalation tool (sudo, doas, or run0).`,
Headless mode requires cached sudo credentials. Run 'sudo -v' beforehand, or
configure passwordless sudo for your user.`,
Args: cobra.NoArgs,
RunE: runDankinstall,
SilenceErrors: true,
@@ -50,10 +46,6 @@ privilege escalation tool (sudo, doas, or run0).`,
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().StringVar(&privescTool, "privesc", "", "Privilege escalation tool: sudo, doas, or run0 (default: autodetect)")
rootCmd.Flags().BoolVar(&gitAll, "git", false, "Install the git version of every dep that has one")
rootCmd.Flags().StringSliceVar(&gitDeps, "git-deps", []string{}, "Deps to install the git version of (e.g. niri,quickshell)")
rootCmd.Flags().BoolVar(&allFeatures, "all-features", false, "Enable all optional deps (dms-greeter, danksearch, dankcalendar)")
rootCmd.Flags().StringSliceVar(&includeDeps, "include-deps", []string{}, "Optional deps to enable (e.g. dms-greeter)")
rootCmd.Flags().StringSliceVar(&excludeDeps, "exclude-deps", []string{}, "Deps to skip during installation")
rootCmd.Flags().StringSliceVar(&replaceConfigs, "replace-configs", []string{}, "Deploy only named configs (e.g. niri,ghostty)")
@@ -61,7 +53,6 @@ func init() {
rootCmd.Flags().BoolVarP(&yes, "yes", "y", false, "Auto-confirm all prompts")
rootCmd.Flags().BoolVar(&danksearch, "danksearch", false, "Install danksearch and enable its user indexing service")
rootCmd.Flags().BoolVar(&dankcalendar, "dankcalendar", false, "Install dankcalendar")
rootCmd.Flags().BoolVar(&dmsGreeter, "dms-greeter", false, "Install dms-greeter")
}
func main() {
@@ -80,11 +71,8 @@ func runDankinstall(cmd *cobra.Command, args []string) error {
headlessMode := compositor != "" || term != ""
if !headlessMode {
// Reject headless-only flags when running in TUI mode.
headlessOnly := []string{
"privesc",
"git",
"git-deps",
"all-features",
"include-deps",
"exclude-deps",
"replace-configs",
@@ -92,7 +80,6 @@ func runDankinstall(cmd *cobra.Command, args []string) error {
"yes",
"danksearch",
"dankcalendar",
"dms-greeter",
}
var set []string
for _, name := range headlessOnly {
@@ -112,6 +99,7 @@ func runDankinstall(cmd *cobra.Command, args []string) error {
}
func runHeadless() error {
// Validate required flags
if compositor == "" {
return fmt.Errorf("--compositor is required for headless mode (niri, hyprland, or mango)")
}
@@ -122,10 +110,6 @@ func runHeadless() error {
cfg := headless.Config{
Compositor: compositor,
Terminal: term,
PrivescTool: privescTool,
GitAll: gitAll,
GitDeps: gitDeps,
AllFeatures: allFeatures,
IncludeDeps: includeDeps,
ExcludeDeps: excludeDeps,
ReplaceConfigs: replaceConfigs,
@@ -133,11 +117,11 @@ func runHeadless() error {
Yes: yes,
DankSearch: danksearch,
DankCalendar: dankcalendar,
DmsGreeter: dmsGreeter,
}
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)
@@ -152,6 +136,10 @@ func runHeadless() error {
}
}()
} 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())()
}
@@ -191,6 +179,10 @@ 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())()
}
@@ -208,9 +200,10 @@ func runTUI() error {
return nil
}
// drainLogChan discards messages from logCh so blocking sends in downstream
// components cannot deadlock when no file logger is attached; the returned
// cleanup stops the drain without assuming the channel will ever close.
// 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{})
-62
View File
@@ -1,62 +0,0 @@
package main
import (
"fmt"
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/backup"
"github.com/spf13/cobra"
)
var backupOutputPath string
var backupCmd = &cobra.Command{
Use: "backup",
Short: "Backup and restore DMS configuration",
}
var backupCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a tar.gz backup of ~/.config/DankMaterialShell (settings, plugins, themes)",
Args: cobra.NoArgs,
Run: runBackupCreate,
}
var backupRestoreCmd = &cobra.Command{
Use: "restore <archive>",
Short: "Restore a DMS configuration backup; the current configuration is moved aside first",
Args: cobra.ExactArgs(1),
Run: runBackupRestore,
}
func init() {
backupCreateCmd.Flags().StringVarP(&backupOutputPath, "output", "o", "", "Output archive path (default dms-backup-<timestamp>.tar.gz)")
backupCmd.AddCommand(backupCreateCmd, backupRestoreCmd)
rootCmd.AddCommand(backupCmd)
}
func runBackupCreate(cmd *cobra.Command, args []string) {
output := backupOutputPath
if output == "" {
output = backup.DefaultArchiveName()
}
if err := backup.Create(output); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println(output)
}
func runBackupRestore(cmd *cobra.Command, args []string) {
previous, err := backup.Restore(args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if previous != "" {
fmt.Printf("Previous configuration moved to %s\n", previous)
}
fmt.Println("Restore complete. Restart the shell to apply.")
}
+10 -8
View File
@@ -8,13 +8,13 @@ import (
"strings"
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/highlight"
"github.com/AvengeMedia/DankMaterialShell/core/internal/highlight/lexers"
"github.com/AvengeMedia/DankMaterialShell/core/internal/highlight/styles"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/spf13/cobra"
"github.com/yuin/goldmark"
highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
ghtml "github.com/yuin/goldmark/renderer/html"
@@ -116,7 +116,7 @@ func getCachedStyle(name string) *chroma.Style {
style := styles.Get(name)
if style == nil {
fmt.Fprintf(os.Stderr, "Warning: Style '%s' not found, using fallback\n", name)
style = styles.Fallback()
style = styles.Fallback
}
cacheMutex.Lock()
@@ -209,10 +209,12 @@ func runChroma(cmd *cobra.Command, args []string) {
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
highlight.Markdown{
Style: styles.Get(chromaStyle),
Formatter: html.New(html.WithClasses(!chromaInline)),
},
highlighting.NewHighlighting(
highlighting.WithStyle(chromaStyle),
highlighting.WithFormatOptions(
html.WithClasses(!chromaInline),
),
),
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
+9 -10
View File
@@ -31,7 +31,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/dankgo/wlclipboard"
"github.com/spf13/cobra"
)
@@ -284,8 +283,8 @@ func runClipCopy(cmd *cobra.Command, args []string) {
}
}
func parseMultiOffers(data []byte) ([]wlclipboard.Offer, error) {
var offers []wlclipboard.Offer
func parseMultiOffers(data []byte) ([]clipboard.Offer, error) {
var offers []clipboard.Offer
pos := 0
for pos < len(data) {
@@ -312,14 +311,14 @@ func parseMultiOffers(data []byte) ([]wlclipboard.Offer, error) {
offerData := data[pos : pos+dataLen]
pos += dataLen
offers = append(offers, wlclipboard.Offer{MimeType: mimeType, Data: offerData})
offers = append(offers, clipboard.Offer{MimeType: mimeType, Data: offerData})
}
return offers, nil
}
func runClipPaste(cmd *cobra.Command, args []string) {
data, _, err := wlclipboard.Paste()
data, _, err := clipboard.Paste()
if err != nil {
log.Fatalf("paste: %v", err)
}
@@ -345,13 +344,13 @@ func runClipWatch(cmd *cobra.Command, args []string) {
switch {
case len(args) > 0:
if err := wlclipboard.Watch(ctx, func(data []byte, mimeType string) {
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
runCommand(args, data)
}); err != nil && err != context.Canceled {
log.Fatalf("Watch error: %v", err)
}
case clipWatchStore:
if err := wlclipboard.Watch(ctx, func(data []byte, mimeType string) {
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
if err := clipboard.Store(data, mimeType); err != nil {
log.Errorf("store: %v", err)
}
@@ -383,7 +382,7 @@ func runClipWatch(cmd *cobra.Command, args []string) {
log.Fatalf("Watch error: %v", err)
}
case clipJSONOutput:
if err := wlclipboard.Watch(ctx, func(data []byte, mimeType string) {
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
out := map[string]any{
"data": string(data),
"mimeType": mimeType,
@@ -396,7 +395,7 @@ func runClipWatch(cmd *cobra.Command, args []string) {
log.Fatalf("Watch error: %v", err)
}
default:
if err := wlclipboard.Watch(ctx, func(data []byte, mimeType string) {
if err := clipboard.Watch(ctx, func(data []byte, mimeType string) {
os.Stdout.Write(data)
os.Stdout.WriteString("\n")
}); err != nil && err != context.Canceled {
@@ -932,7 +931,7 @@ func downloadToTempFile(rawURL string) (string, error) {
var contentType string
var lastErr error
for attempt := range 3 {
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
}
+10 -19
View File
@@ -3,7 +3,6 @@ package main
import (
"fmt"
"os"
"runtime/debug"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/colorpicker"
@@ -11,11 +10,10 @@ import (
)
var (
colorOutputFmt string
colorAutocopy bool
colorNotify bool
colorLowercase bool
colorAllowMulti bool
colorOutputFmt string
colorAutocopy bool
colorNotify bool
colorLowercase bool
)
var colorCmd = &cobra.Command{
@@ -55,8 +53,7 @@ Examples:
dms color pick --rgb # Output as RGB
dms color pick --json # Output all formats as JSON
dms color pick --hex -l # Output hex in lowercase
dms color pick -a # Auto-copy result to clipboard
dms color pick --allow-multiple # Skip the one-picker-at-a-time guard`,
dms color pick -a # Auto-copy result to clipboard`,
Run: runColorPick,
}
@@ -71,7 +68,6 @@ func init() {
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")
colorPickCmd.Flags().BoolVar(&colorAllowMulti, "allow-multiple", false, "Open a picker even when another selection overlay is already open")
colorPickCmd.MarkFlagsMutuallyExclusive("hex", "rgb", "hsl", "hsv", "cmyk", "json")
@@ -93,18 +89,13 @@ func runColorPick(cmd *cobra.Command, args []string) {
}
config := colorpicker.Config{
Format: format,
CustomFormat: colorOutputFmt,
Lowercase: colorLowercase,
Autocopy: colorAutocopy,
Notify: colorNotify,
AllowMultiple: colorAllowMulti,
Format: format,
CustomFormat: colorOutputFmt,
Lowercase: colorLowercase,
Autocopy: colorAutocopy,
Notify: colorNotify,
}
// One-shot process holding a few screen-sized buffers: skip GC cycles while it runs.
debug.SetGCPercent(-1)
debug.SetMemoryLimit(1 << 30)
picker := colorpicker.New(config)
color, err := picker.Run()
if err != nil {
-67
View File
@@ -51,8 +51,6 @@ func init() {
})
pluginsUpdateCmd.Flags().BoolP("all", "a", false, "Update all installed plugins")
pluginsUpdateCmd.Flags().Bool("check", false, "Check for available updates without applying them")
pluginsLockCmd.Flags().StringP("output", "o", "", "Also write the lockfile to this path")
pluginsRestoreCmd.Flags().Bool("prune", false, "Remove managed plugins that are not in the lockfile")
}
var debugSrvCmd = &cobra.Command{
@@ -180,36 +178,6 @@ var pluginsUpdateCmd = &cobra.Command{
},
}
var pluginsLockCmd = &cobra.Command{
Use: "lock",
Short: "Record installed plugins and their exact revisions",
Long: "Write a portable plugins.lock.json containing every managed user plugin and its current Git commit.",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
if err := lockPluginsCLI(output); err != nil {
log.Fatalf("Error writing plugin lockfile: %v", err)
}
},
}
var pluginsRestoreCmd = &cobra.Command{
Use: "restore [lockfile]",
Short: "Restore plugins from exact revisions in a lockfile",
Long: "Install or reset all plugins in a portable plugins.lock.json. Existing managed plugins are retained unless --prune is specified.",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
path := ""
if len(args) == 1 {
path = args[0]
}
prune, _ := cmd.Flags().GetBool("prune")
if err := restorePluginsCLI(path, prune); err != nil {
log.Fatalf("Error restoring plugins: %v", err)
}
},
}
func runVersion(cmd *cobra.Command, args []string) {
fmt.Printf("%s\n", formatVersion(Version))
}
@@ -465,40 +433,6 @@ func installPluginCLI(idOrName string) error {
return nil
}
func lockPluginsCLI(outputPath string) error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
warnings, err := manager.WriteCurrentLockfile(outputPath)
if err != nil {
return err
}
for _, warning := range warnings {
fmt.Fprintf(os.Stderr, "Warning: %s\n", warning)
}
fmt.Printf("Plugin lockfile written: %s\n", manager.GetLockfilePath())
if outputPath != "" && outputPath != manager.GetLockfilePath() {
fmt.Printf("Plugin lockfile exported: %s\n", outputPath)
}
return nil
}
func restorePluginsCLI(path string, prune bool) error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
if err := manager.RestoreFromLockfile(path, prune); err != nil {
return err
}
if path == "" {
path = manager.GetLockfilePath()
}
fmt.Printf("Plugins restored from lockfile: %s\n", path)
return nil
}
func getAvailablePluginIDs() []string {
registry, err := plugins.NewRegistry()
if err != nil {
@@ -770,7 +704,6 @@ func getCommonCommands() []*cobra.Command {
ipcCmd,
debugSrvCmd,
pluginsCmd,
registryCmd,
dank16Cmd,
brightnessCmd,
dpmsCmd,
+5 -6
View File
@@ -40,7 +40,6 @@ var resolveIncludeCmd = &cobra.Command{
"layout.kdl",
"outputs.kdl",
"binds.kdl",
"input.kdl",
"cursor.conf",
"layout.conf",
"outputs.conf",
@@ -150,9 +149,9 @@ func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]b
}
baseDir := filepath.Dir(absPath)
lines := strings.SplitSeq(string(data), "\n")
lines := strings.Split(string(data), "\n")
for line := range lines {
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
continue
@@ -229,7 +228,7 @@ func niriFindInclude(filePath, target string, processed map[string]bool) bool {
baseDir := filepath.Dir(absPath)
content := string(data)
for line := range strings.SplitSeq(content, "\n") {
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") || trimmed == "" {
continue
@@ -306,9 +305,9 @@ func mangowcFindInclude(filePath, target string, processed map[string]bool) bool
}
baseDir := filepath.Dir(absPath)
lines := strings.SplitSeq(string(data), "\n")
lines := strings.Split(string(data), "\n")
for line := range lines {
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
continue
+101 -486
View File
@@ -2,9 +2,7 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"maps"
"os"
"os/exec"
"path/filepath"
@@ -17,7 +15,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
"github.com/AvengeMedia/DankMaterialShell/core/internal/tui"
@@ -322,16 +319,8 @@ func checkSystemInfo() []checkResult {
func checkEnvironmentVars() []checkResult {
var results []checkResult
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME")...)
results = append(results, checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")...)
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME_QT6")...)
if os.Getenv("QT_QPA_PLATFORMTHEME_QT6") != os.Getenv("QT_QPA_PLATFORMTHEME") {
results = append(results, checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME_QT6")...)
}
results = append(results, checkEnvVar("QS_ICON_THEME")...)
results = append(results, checkXDGMenuPrefix()...)
if matugen.QtengineActive() {
results = append(results, checkQtenginePlugin()...)
}
return results
}
@@ -363,228 +352,6 @@ func checkXDGMenuPrefix() []checkResult {
return nil
}
// qtQueryBinaries are the Qt build tools that can be asked where Qt looks for
// plugins, in probe order. A binary's name does not reliably say which Qt it
// belongs to — plain "qmake" is Qt5 on Arch and Qt6 elsewhere — so each one is
// asked for QT_VERSION and the major is taken from the answer.
var qtQueryBinaries = []struct{ bin, flag string }{
{"qmake6", "-query"},
{"qmake-qt6", "-query"},
{"qtpaths6", "--query"},
{"qtpaths-qt6", "--query"},
{"qmake-qt5", "-query"},
{"qtpaths-qt5", "--query"},
{"qmake", "-query"},
{"qtpaths", "--query"},
}
// checkQtenginePlugin verifies the qtengine platform theme plugin is installed
// where Qt actually searches, for every Qt major that can be interrogated.
//
// Qt is asked where it looks rather than the filesystem searched, because a
// package can install the Qt5 plugin under a prefix Qt5 does not search, after
// which Qt5 apps silently fall back while Qt6 apps look correct. A search would
// find the plugin and report OK on exactly that bug.
func checkQtenginePlugin() []checkResult {
url := doctorDocsURL + "#environment-variables"
pluginRoots := qtPluginRoots()
// qmake/qtpaths ship in dev packages most users lack — never warn on a Qt
// that could not be interrogated, and never claim it is fine either.
if len(pluginRoots) == 0 {
return []checkResult{{
catEnvironment, "qtengine plugin", statusInfo, "Cannot verify (no qmake/qtpaths)",
"Install a Qt development package to let dms doctor check the plugin against the path Qt actually searches.",
url,
}}
}
// Qt also searches QT_PLUGIN_PATH, so a plugin found there is loadable.
envRoots := filepath.SplitList(os.Getenv("QT_PLUGIN_PATH"))
var results []checkResult
for _, major := range slices.Sorted(maps.Keys(pluginRoots)) {
// qtengine only ships Qt5 and Qt6 plugins.
if major != "5" && major != "6" {
continue
}
root := pluginRoots[major]
name := fmt.Sprintf("qtengine plugin (Qt%s)", major)
plugin := qtenginePluginPath(append([]string{root}, envRoots...), major)
if plugin == "" {
expected := qtenginePluginFile(root, major)
results = append(results, checkResult{
catEnvironment, name, statusWarn, fmt.Sprintf("Not found in Qt%s plugin path", major),
fmt.Sprintf("Expected %s — Qt%s apps will silently fall back to an unthemed palette. The plugin is likely installed under a prefix Qt%s does not search.", expected, major, major),
url,
})
continue
}
results = append(results, checkResult{catEnvironment, name, statusOK, "Installed", plugin, url})
}
return results
}
// checkQtPlatformThemePlugin validates one of the QT_QPA_PLATFORMTHEME
// variables beyond mere presence: the value must name a platform theme whose
// plugin Qt can actually load. A wrong value fails silently at run time — Qt
// apps fall back to a built-in theme, icon lookups return nothing, and
// Quickshell tray menus draw a placeholder for every icon an app publishes.
func checkQtPlatformThemePlugin(varName string) []checkResult {
value := os.Getenv(varName)
// Unset is reported by checkEnvVar; qtengine has its own plugin check.
if value == "" || value == "qtengine" {
return nil
}
url := doctorDocsURL + "#environment-variables"
// qt6ct-kde is the AUR package name, not the theme value: with it set,
// Qt finds no platform theme at all and every Qt app falls back silently.
if value == "qt6ct-kde" {
return []checkResult{{
catEnvironment, value, statusWarn, "Package name, not a theme",
fmt.Sprintf("qt6ct-kde is the AUR package name; the theme value Qt expects is qt6ct. Qt apps get no platform theme, so palettes stay unstyled and icon lookups fail. Set %s=qt6ct, then restart the shell.", varName),
url,
}}
}
// DMS themes Qt6 through these variables; qt5ct is the Qt5 counterpart.
major := "6"
if value == "qt5ct" {
major = "5"
}
files := qtPlatformThemePluginFiles(value)
name := fmt.Sprintf("%s plugin (Qt%s)", value, major)
root := qtPluginRoots()[major]
// qmake/qtpaths ship in dev packages most users lack — never warn on a Qt
// that could not be interrogated, and never claim it is fine either.
if root == "" {
return []checkResult{{
catEnvironment, name, statusInfo, "Cannot verify (no qmake/qtpaths)",
"Install a Qt development package to let dms doctor check the plugin against the path Qt actually searches.",
url,
}}
}
// Qt also searches QT_PLUGIN_PATH, so a plugin found there is loadable.
plugin := qtPluginPath(append([]string{root}, filepath.SplitList(os.Getenv("QT_PLUGIN_PATH"))...), files...)
if plugin == "" {
// plasma-integration ships the kde theme under a file name that has
// changed across versions; a miss says nothing reliable about the
// install, so degrade to info instead of warning at a working setup.
if value == "kde" {
return []checkResult{{
catEnvironment, name, statusInfo, "Cannot verify (plasma-integration)",
"The kde theme comes from plasma-integration, whose plugin file name varies across versions. Verify visually that Qt apps follow your theme.",
url,
}}
}
expected := filepath.Join(root, "platformthemes", files[0])
return []checkResult{{
catEnvironment, name, statusWarn, fmt.Sprintf("Not found in Qt%s plugin path", major),
fmt.Sprintf("Expected %s — Qt apps silently fall back to a built-in theme, breaking icon lookups and palettes. Install the package providing it, or correct %s.", expected, varName),
url,
}}
}
return []checkResult{{catEnvironment, name, statusOK, "Installed", plugin, url}}
}
// qtPlatformThemePluginFiles lists the plugin file names that can provide a
// QT_QPA_PLATFORMTHEME value, most conventional first. Qt resolves the value
// through plugin metadata keys rather than file names, so these are the names
// each integration is known to ship; unmapped values fall back to Qt's
// libq<value> naming convention.
func qtPlatformThemePluginFiles(value string) []string {
if files, ok := map[string][]string{
"qt6ct": {"libqt6ct.so"},
"qt5ct": {"libqt5ct.so"},
"gtk3": {"libqgtk3.so"},
"kde": {"libqkdeplatformtheme.so"},
}[value]; ok {
return files
}
return []string{"libq" + value + ".so", "lib" + value + ".so"}
}
// qtPluginRoots asks every available Qt build tool where its Qt looks for
// plugins, keyed by Qt major. A binary's name does not reliably say which Qt
// it belongs to — plain "qmake" is Qt5 on Arch and Qt6 elsewhere — so each one
// is asked for QT_VERSION and the major is taken from the answer.
func qtPluginRoots() map[string]string {
pluginRoots := make(map[string]string)
for _, q := range qtQueryBinaries {
if _, err := exec.LookPath(q.bin); err != nil {
continue
}
major, _, _ := strings.Cut(qtQuery(q.bin, q.flag, "QT_VERSION"), ".")
if major == "" || pluginRoots[major] != "" {
continue
}
if root := qtQuery(q.bin, q.flag, "QT_INSTALL_PLUGINS"); root != "" {
pluginRoots[major] = root
}
}
return pluginRoots
}
// qtPluginPath returns the first of roots that holds any of files under
// platformthemes/, or "" if none does.
func qtPluginPath(roots []string, files ...string) string {
for _, root := range roots {
if root == "" {
continue
}
for _, file := range files {
path := filepath.Join(root, "platformthemes", file)
if _, err := os.Stat(path); err == nil {
return path
}
}
}
return ""
}
// qtenginePluginPath returns the first of roots that holds the qtengine platform
// theme plugin for the given Qt major, or "" if none does.
func qtenginePluginPath(roots []string, major string) string {
return qtPluginPath(roots, fmt.Sprintf("libqt%sengine-plugin.so", major))
}
func qtenginePluginFile(root, major string) string {
return filepath.Join(root, "platformthemes", fmt.Sprintf("libqt%sengine-plugin.so", major))
}
// qtQuery asks a Qt build tool for a single QLibraryInfo property.
func qtQuery(bin, flag, key string) string {
output, err := exec.Command(bin, flag, key).Output()
if err != nil {
return ""
}
return qtQueryValue(string(output), key)
}
// qtQueryValue extracts one property from a Qt query. Most tools print the bare
// value, but some ignore the requested property and dump every one as
// "KEY:value" lines; taking the whole output there would yield a bogus path.
func qtQueryValue(output, key string) string {
lines := strings.Split(strings.TrimSpace(output), "\n")
for _, line := range lines {
if value, found := strings.CutPrefix(strings.TrimSpace(line), key+":"); found {
return strings.TrimSpace(value)
}
}
if len(lines) == 1 {
return strings.TrimSpace(lines[0])
}
return ""
}
func checkXDGMenuFile(prefix string) bool {
menuPath := fmt.Sprintf("/etc/xdg/menus/%sapplications.menu", prefix)
_, err := os.Stat(menuPath)
@@ -616,8 +383,9 @@ func checkVersions(qsMissingFeatures bool) []checkResult {
{catVersions, "DMS CLI", statusOK, formatVersion(Version), dmsCliDetails, doctorDocsURL + "#dms-cli"},
}
qsVersion, qsStatus, qsPath, qsDetails := getQuickshellVersionInfo(qsMissingFeatures)
if doctorVerbose && qsPath != "" && qsDetails == "" {
qsVersion, qsStatus, qsPath := getQuickshellVersionInfo(qsMissingFeatures)
qsDetails := ""
if doctorVerbose && qsPath != "" {
qsDetails = qsPath
}
results = append(results, checkResult{catVersions, "Quickshell", qsStatus, qsVersion, qsDetails, doctorDocsURL + "#quickshell"})
@@ -652,49 +420,41 @@ func getDMSShellVersion() (version, path string) {
return "", ""
}
func getQuickshellVersionInfo(missingFeatures bool) (string, status, string, string) {
func getQuickshellVersionInfo(missingFeatures bool) (string, status, string) {
if !utils.CommandExists("qs") {
return "Not installed", statusError, "", ""
return "Not installed", statusError, ""
}
qsPath, _ := exec.LookPath("qs")
output, err := exec.Command("qs", "--version").Output()
if err != nil {
details := "Run 'qs --version' to inspect the failure."
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
details = quickshellVersionFailureDetails(string(exitErr.Stderr))
}
return "Installed (version check failed)", statusWarn, qsPath, details
return "Installed (version check failed)", statusWarn, qsPath
}
fullVersion := strings.TrimSpace(string(output))
if matches := quickshellVersionRegex.FindStringSubmatch(fullVersion); len(matches) >= 2 {
if version.CompareVersions(matches[1], "0.2.0") < 0 {
return fmt.Sprintf("%s (needs >= 0.2.0)", fullVersion), statusError, qsPath, ""
return fmt.Sprintf("%s (needs >= 0.2.0)", fullVersion), statusError, qsPath
}
if missingFeatures {
return fullVersion, statusWarn, qsPath, ""
return fullVersion, statusWarn, qsPath
}
return fullVersion, statusOK, qsPath, ""
return fullVersion, statusOK, qsPath
}
return fullVersion, statusWarn, qsPath, ""
}
func quickshellVersionFailureDetails(stderr string) string {
if strings.Contains(stderr, "undefined symbol:") && strings.Contains(stderr, "Qt_6_PRIVATE_API") {
return "Quickshell is incompatible with the installed Qt libraries. Rebuild or reinstall Quickshell against the current Qt version."
}
return "Run 'qs --version' to inspect the failure."
return fullVersion, statusWarn, qsPath
}
func checkDMSInstallation() []checkResult {
var results []checkResult
dmsPath := resolveDoctorShellPath()
dmsPath := ""
if err := shellApp.ResolveConfig(nil, nil); err == nil && shellApp.ConfigPath() != "" {
dmsPath = shellApp.ConfigPath()
} else if path, err := config.LocateDMSConfig(); err == nil {
dmsPath = path
}
if dmsPath == "" {
return []checkResult{{catInstallation, "DMS Configuration", statusError, "Not found", "shell.qml not found in any config path", doctorDocsURL + "#dms-configuration"}}
@@ -1034,15 +794,17 @@ func findQtPluginDirs() []string {
}
// Check all paths in QT_PLUGIN_PATH env var (used by NixOS and custom setups)
for _, dir := range filepath.SplitList(os.Getenv("QT_PLUGIN_PATH")) {
addDir(dir)
if envPath := os.Getenv("QT_PLUGIN_PATH"); envPath != "" {
for dir := range strings.SplitSeq(envPath, ":") {
addDir(dir)
}
}
for _, q := range qtQueryBinaries {
if _, err := exec.LookPath(q.bin); err != nil {
continue
// 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)))
}
addDir(qtQuery(q.bin, q.flag, "QT_INSTALL_PLUGINS"))
}
// Fallback: common distro paths
@@ -1146,6 +908,7 @@ func checkOptionalDependencies() []checkResult {
important bool
}{
{"matugen", "matugen", "Dynamic theming", true},
{"dgop", "dgop", "System monitoring", true},
{"cava", "cava", "Audio visualizer", true},
{"khal", "khal", "Calendar events", false},
{"danksearch", "dsearch", "File search", false},
@@ -1166,62 +929,9 @@ func checkOptionalDependencies() []checkResult {
}
}
results = append(results, checkAdwGtk3())
return results
}
// Mirrors the search in quickshell/scripts/gtk.sh: user theme dirs, then XDG_DATA_DIRS.
func checkAdwGtk3() checkResult {
optionalFeaturesURL := doctorDocsURL + "#optional-features"
home, _ := os.UserHomeDir()
dataHome := os.Getenv("XDG_DATA_HOME")
if dataHome == "" {
dataHome = filepath.Join(home, ".local", "share")
}
userRoots := []string{filepath.Join(dataHome, "themes"), filepath.Join(home, ".themes")}
dataDirs := os.Getenv("XDG_DATA_DIRS")
if dataDirs == "" {
dataDirs = "/usr/local/share:/usr/share"
}
var systemRoots []string
for _, d := range strings.Split(dataDirs, ":") {
if d != "" {
systemRoots = append(systemRoots, filepath.Join(d, "themes"))
}
}
locate := func(roots []string, variant string) bool {
for _, root := range roots {
if info, err := os.Stat(filepath.Join(root, "adw-gtk3"+variant, "gtk-3.0")); err == nil && info.IsDir() {
return true
}
}
return false
}
userLight := locate(userRoots, "")
userDark := locate(userRoots, "-dark")
sysLight := locate(systemRoots, "")
sysDark := locate(systemRoots, "-dark")
switch {
case userLight && userDark:
return checkResult{catOptionalFeatures, "adw-gtk3", statusOK, "Installed", "GTK3 dynamic theming (user copy)", optionalFeaturesURL}
case sysLight && sysDark:
return checkResult{catOptionalFeatures, "adw-gtk3", statusOK, "Installed", "GTK3 dynamic theming (system copy, applied on next theme apply)", optionalFeaturesURL}
case userLight || userDark || sysLight || sysDark:
missing := "adw-gtk3-dark"
if userDark || sysDark {
missing = "adw-gtk3"
}
return checkResult{catOptionalFeatures, "adw-gtk3", statusWarn, "Missing " + missing, "GTK3 dynamic theming needs both variants", optionalFeaturesURL}
default:
return checkResult{catOptionalFeatures, "adw-gtk3", statusInfo, "Not installed", "GTK3 dynamic theming; without it the global CSS override applies", optionalFeaturesURL}
}
}
func checkConfigurationFiles() []checkResult {
configDir, _ := os.UserConfigDir()
cacheDir, _ := os.UserCacheDir()
@@ -1249,11 +959,6 @@ func checkConfigurationFiles() []checkResult {
status = statusWarn
message += " (read-only)"
}
// The shell falls back to defaults on a parse failure, so a broken file must not report ok.
if data, readErr := os.ReadFile(cf.path); readErr == nil && len(data) > 0 && !json.Valid(data) {
status = statusError
message = "Invalid JSON - the shell ignores this file and runs on defaults"
}
results = append(results, checkResult{catConfigFiles, cf.name, status, message, cf.path, doctorDocsURL + "#config-files"})
}
return results
@@ -1286,13 +991,6 @@ func checkSystemdServices() []checkResult {
results = append(results, checkResult{catServices, "dms.service", status, message, "", doctorDocsURL + "#services"})
}
if dmsState.exists && dmsState.enabled == "enabled" {
gsState := getServiceState("graphical-session.target", true)
if gsState.exists && gsState.active != "active" {
results = append(results, checkResult{catServices, "graphical-session.target", statusWarn, "Inactive", "Compositor session never activated it, so dms.service cannot autostart. On Hyprland, hyprland-session.target must exist and be started by the compositor.", doctorDocsURL + "#services"})
}
}
greetdState := getServiceState("greetd", false)
switch {
case greetdState.exists:
@@ -1471,182 +1169,99 @@ func formatResultsPlain(results []checkResult) string {
return sb.String()
}
const (
defaultDoctorFontFamily = "Inter Variable"
defaultDoctorMonoFontFamily = "Fira Code"
)
// bundledFontRelPaths maps settings/default family names to font files shipped with
// the shell and loaded via Qt FontLoader (not registered with fontconfig).
var bundledFontRelPaths = map[string][]string{
"inter variable": {
"DankCommon/assets/fonts/inter/InterVariable.ttf",
"assets/fonts/inter/InterVariable.ttf",
},
"fira code": {
"DankCommon/assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
"assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
},
"firacode nerd font": {
"DankCommon/assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
"assets/fonts/nerd-fonts/FiraCodeNerdFont-Regular.ttf",
},
}
func resolveDoctorShellPath() string {
if err := shellApp.ResolveConfig(nil, nil); err == nil && shellApp.ConfigPath() != "" {
return shellApp.ConfigPath()
}
if path, err := config.LocateDMSConfig(); err == nil {
return path
}
return ""
}
func findBundledFontFile(shellPath, family string) string {
if shellPath == "" {
return ""
}
relPaths, ok := bundledFontRelPaths[strings.ToLower(strings.TrimSpace(family))]
if !ok {
return ""
}
for _, rel := range relPaths {
path := filepath.Join(shellPath, rel)
if info, err := os.Stat(path); err == nil && !info.IsDir() {
return path
}
}
return ""
}
func isBundledDefaultFont(family string) bool {
_, ok := bundledFontRelPaths[strings.ToLower(strings.TrimSpace(family))]
return ok
}
func fontInFcList(name, cacheLower string) bool {
target := strings.ToLower(strings.TrimSpace(name))
if target == "" {
return false
}
for line := range strings.SplitSeq(cacheLower, "\n") {
for fam := range strings.SplitSeq(strings.TrimSpace(line), ",") {
if strings.TrimSpace(fam) == target {
return true
}
}
}
return false
}
func checkConfiguredFont(label, family, shellPath, fcCache string, fcListAvailable bool, url string) checkResult {
if bundled := findBundledFontFile(shellPath, family); bundled != "" {
details := "Bundled (Qt FontLoader)"
if doctorVerbose {
details = bundled
}
return checkResult{catFonts, label, statusOK, family, details, url}
}
if isBundledDefaultFont(family) {
if shellPath == "" {
return checkResult{
catFonts, label, statusWarn,
fmt.Sprintf("'%s' not verified", family),
"Could not locate shell config to verify bundled font files.",
url,
}
}
return checkResult{
catFonts, label, statusWarn,
fmt.Sprintf("'%s' bundled file missing", family),
"Expected font file missing from shell install. Reinstall DMS or check DankCommon assets.",
url,
}
}
if !fcListAvailable {
return checkResult{
catFonts, label, statusWarn,
fmt.Sprintf("'%s' not verified", family),
"fc-list not installed; cannot verify custom fonts in fontconfig.",
url,
}
}
if fcCache == "" {
return checkResult{
catFonts, label, statusWarn,
fmt.Sprintf("'%s' not found", family),
"Fontconfig cache is empty or unreadable. Try running 'fc-cache -fv'.",
url,
}
}
if fontInFcList(family, fcCache) {
return checkResult{catFonts, label, statusOK, family, "Available via fontconfig", url}
}
return checkResult{
catFonts, label, statusWarn,
fmt.Sprintf("'%s' not found", family),
"Font is not registered with fontconfig. Try running 'fc-cache -fv' or install the font.",
url,
}
}
func checkFonts() []checkResult {
var results []checkResult
url := doctorDocsURL + "#fonts"
fontFamily := defaultDoctorFontFamily
monoFontFamily := defaultDoctorMonoFontFamily
configDir, err := os.UserConfigDir()
if err != nil {
return nil
}
settingsPath := filepath.Join(configDir, "DankMaterialShell", "settings.json")
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"`
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 err := json.Unmarshal(data, &settings); err == nil {
if settings.FontFamily != "" {
fontFamily = settings.FontFamily
}
if settings.MonoFontFamily != "" {
monoFontFamily = settings.MonoFontFamily
}
if settings.MonoFontFamily != "" {
monoFontFamily = settings.MonoFontFamily
}
}
}
shellPath := resolveDoctorShellPath()
needFontconfig := !isBundledDefaultFont(fontFamily) || !isBundledDefaultFont(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
}
fcListAvailable := utils.CommandExists("fc-list")
fcCache := ""
// 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
}
if needFontconfig {
if !fcListAvailable {
results = append(results, checkResult{catFonts, "Fontconfig Tools", statusWarn, "fc-list not installed", "Cannot verify custom fonts in fontconfig cache.", url})
} else {
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})
} else {
fcCache = strings.ToLower(string(output))
if len(strings.TrimSpace(fcCache)) == 0 {
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Cache is empty", "No fonts found in fontconfig cache. Try running 'fc-cache -fv'.", url})
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
}
results = append(results,
checkConfiguredFont("Normal Font", fontFamily, shellPath, fcCache, fcListAvailable, url),
checkConfiguredFont("Monospace Font", monoFontFamily, shellPath, fcCache, fcListAvailable, url),
)
// 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
}
-237
View File
@@ -1,237 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestQuickshellVersionFailureDetails(t *testing.T) {
tests := []struct {
name string
stderr string
want string
}{
{
name: "Qt private API symbol mismatch",
stderr: "qs: symbol lookup error: qs: undefined symbol: _ZN23QUntypedPropertyBindingC1EP23QPropertyBindingPrivate, version Qt_6_PRIVATE_API",
want: "Quickshell is incompatible with the installed Qt libraries. Rebuild or reinstall Quickshell against the current Qt version.",
},
{
name: "other failure",
stderr: "qs: unsupported option --version",
want: "Run 'qs --version' to inspect the failure.",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := quickshellVersionFailureDetails(tc.stderr); got != tc.want {
t.Fatalf("quickshellVersionFailureDetails(%q) = %q, want %q", tc.stderr, got, tc.want)
}
})
}
}
// Parsing a full property dump as a bare value would yield a bogus plugin root,
// making the qtengine check warn at a user whose install is fine.
func TestQtQueryValue(t *testing.T) {
const dumpAll = `QT_INSTALL_PREFIX:/usr
QT_INSTALL_PLUGINS:/usr/lib/qt/plugins
QT_VERSION:5.15.19`
tests := []struct {
name string
output string
key string
want string
}{
{
name: "bare value as qmake prints it",
output: "/usr/lib/qt6/plugins\n",
key: "QT_INSTALL_PLUGINS",
want: "/usr/lib/qt6/plugins",
},
{
name: "key:value line among a full property dump",
output: dumpAll,
key: "QT_INSTALL_PLUGINS",
want: "/usr/lib/qt/plugins",
},
{
name: "a different key from the same dump",
output: dumpAll,
key: "QT_VERSION",
want: "5.15.19",
},
{
name: "key absent from a multi line dump yields nothing",
output: dumpAll,
key: "QT_INSTALL_LIBEXECS",
want: "",
},
{
name: "empty output yields nothing",
output: "",
key: "QT_INSTALL_PLUGINS",
want: "",
},
{
name: "surrounding whitespace is trimmed",
output: " QT_INSTALL_PLUGINS: /usr/lib/qt/plugins \n",
key: "QT_INSTALL_PLUGINS",
want: "/usr/lib/qt/plugins",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := qtQueryValue(tc.output, tc.key); got != tc.want {
t.Fatalf("qtQueryValue(%q, %q) = %q, want %q", tc.output, tc.key, got, tc.want)
}
})
}
}
// stubQtQueryTool writes a fake Qt build tool answering QT_VERSION and
// QT_INSTALL_PLUGINS from the given values, and points PATH at its directory
// alone so the real toolchain cannot interfere.
func stubQtQueryTool(t *testing.T, bin, version, plugins string) string {
t.Helper()
dir := t.TempDir()
script := "#!/bin/sh\ncase \"$2\" in\n QT_VERSION) echo " + version +
" ;;\n QT_INSTALL_PLUGINS) echo " + plugins + " ;;\nesac\n"
if err := os.WriteFile(filepath.Join(dir, bin), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir)
return dir
}
func writePlugin(t *testing.T, pluginsDir, file string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(pluginsDir, "platformthemes"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginsDir, "platformthemes", file), nil, 0o644); err != nil {
t.Fatal(err)
}
}
// qtenginePluginFile("", major) is a path with a directory component, and
// qtPluginPath joins platformthemes/ again — a regression here silently warns
// at every correctly installed system, so pin the full path down.
func TestQtenginePluginPath(t *testing.T) {
root := t.TempDir()
if got := qtenginePluginPath([]string{root}, "6"); got != "" {
t.Fatalf("missing plugin should yield an empty path, got %q", got)
}
want := filepath.Join(root, "platformthemes", "libqt6engine-plugin.so")
if err := os.MkdirAll(filepath.Join(root, "platformthemes"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(want, nil, 0o644); err != nil {
t.Fatal(err)
}
if got := qtenginePluginPath([]string{root}, "6"); got != want {
t.Fatalf("qtenginePluginPath = %q, want %q", got, want)
}
}
func TestCheckQtPlatformThemePlugin(t *testing.T) {
t.Run("qt6ct-kde is flagged as a package name, not a theme", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "qt6ct-kde")
t.Setenv("PATH", t.TempDir()) // no Qt tools reachable
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 {
t.Fatalf("got %d results, want 1: %+v", len(results), results)
}
if results[0].status != statusWarn {
t.Fatalf("status = %q, want warn", results[0].status)
}
if !strings.Contains(results[0].details, "package name") || !strings.Contains(results[0].details, "qt6ct") {
t.Fatalf("details should name the package/value mixup, got %q", results[0].details)
}
})
t.Run("installed plugin reports OK", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "qt6ct")
plugins := t.TempDir()
writePlugin(t, plugins, "libqt6ct.so")
stubQtQueryTool(t, "qmake6", "6.11.2", plugins)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 || results[0].status != statusOK {
t.Fatalf("got %+v, want one OK result", results)
}
if !strings.Contains(results[0].details, "libqt6ct.so") {
t.Fatalf("details should point at the plugin, got %q", results[0].details)
}
})
t.Run("missing plugin warns with the expected path", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "qt6ct")
plugins := t.TempDir()
stubQtQueryTool(t, "qtpaths6", "6.11.2", plugins)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 || results[0].status != statusWarn {
t.Fatalf("got %+v, want one warn result", results)
}
want := filepath.Join(plugins, "platformthemes", "libqt6ct.so")
if !strings.Contains(results[0].details, want) {
t.Fatalf("details should contain expected path %s, got %q", want, results[0].details)
}
})
t.Run("no Qt build tools degrades to info", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "qt6ct")
t.Setenv("PATH", t.TempDir())
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 || results[0].status != statusInfo {
t.Fatalf("got %+v, want one info result", results)
}
})
t.Run("unset and qtengine are left to their own checks", func(t *testing.T) {
t.Setenv("PATH", t.TempDir())
t.Setenv("QT_QPA_PLATFORMTHEME", "")
if results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME"); results != nil {
t.Fatalf("unset should yield no results, got %+v", results)
}
t.Setenv("QT_QPA_PLATFORMTHEME", "qtengine")
if results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME"); results != nil {
t.Fatalf("qtengine should yield no results, got %+v", results)
}
})
t.Run("qt6 variable is not satisfied by a Qt5 tool alone", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME_QT6", "qt6ct")
plugins := t.TempDir()
stubQtQueryTool(t, "qmake-qt5", "5.15.19", plugins)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME_QT6")
if len(results) != 1 || results[0].status != statusInfo {
t.Fatalf("got %+v, want one info result", results)
}
})
t.Run("kde plugin miss degrades to info", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "kde")
plugins := t.TempDir()
stubQtQueryTool(t, "qmake6", "6.11.2", plugins)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 || results[0].status != statusInfo {
t.Fatalf("got %+v, want one info result", results)
}
})
t.Run("gtk3 resolves through the libq convention", func(t *testing.T) {
t.Setenv("QT_QPA_PLATFORMTHEME", "gtk3")
plugins := t.TempDir()
writePlugin(t, plugins, "libqgtk3.so")
stubQtQueryTool(t, "qmake6", "6.11.2", plugins)
results := checkQtPlatformThemePlugin("QT_QPA_PLATFORMTHEME")
if len(results) != 1 || results[0].status != statusOK {
t.Fatalf("got %+v, want one OK result", results)
}
})
}
+51 -38
View File
@@ -3,19 +3,19 @@ package main
import (
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"strings"
"path/filepath"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/netfetch"
"github.com/spf13/cobra"
)
var dlOutput string
var dlUserAgent string
var dlHeaders []string
var dlTimeout int
var dlConnectTimeout int
var dlIPv4Only bool
var dlCmd = &cobra.Command{
@@ -33,52 +33,65 @@ var dlCmd = &cobra.Command{
func init() {
dlCmd.Flags().StringVarP(&dlOutput, "output", "o", "", "Output file path (default: stdout)")
dlCmd.Flags().StringVar(&dlUserAgent, "user-agent", "", "Custom User-Agent header")
dlCmd.Flags().StringArrayVarP(&dlHeaders, "header", "H", nil, "Extra request header as 'Name: value' (repeatable)")
dlCmd.Flags().IntVar(&dlTimeout, "timeout", 10, "Total request timeout in seconds")
dlCmd.Flags().IntVar(&dlConnectTimeout, "connect-timeout", 5, "Connection timeout in seconds")
dlCmd.Flags().IntVar(&dlTimeout, "timeout", 10, "Request timeout in seconds")
dlCmd.Flags().BoolVarP(&dlIPv4Only, "ipv4", "4", false, "Force IPv4 only")
}
func parseHeaders(raw []string) (map[string]string, error) {
if len(raw) == 0 {
return nil, nil
}
headers := make(map[string]string, len(raw))
for _, entry := range raw {
name, value, found := strings.Cut(entry, ":")
if !found {
return nil, fmt.Errorf("invalid header %q: expected 'Name: value'", entry)
}
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("invalid header %q: empty name", entry)
}
headers[name] = strings.TrimSpace(value)
}
return headers, nil
}
func runDownload(url string) error {
headers, err := parseHeaders(dlHeaders)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(dlTimeout)*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
return fmt.Errorf("invalid request: %w", err)
}
opts := netfetch.Options{
Headers: headers,
UserAgent: dlUserAgent,
Timeout: time.Duration(dlTimeout) * time.Second,
ConnectTimeout: time.Duration(dlConnectTimeout) * time.Second,
IPv4Only: dlIPv4Only,
switch {
case dlUserAgent != "":
req.Header.Set("User-Agent", dlUserAgent)
default:
req.Header.Set("User-Agent", "DankMaterialShell/1.0 (Linux)")
}
dialer := &net.Dialer{Timeout: 5 * time.Second}
transport := &http.Transport{DialContext: dialer.DialContext}
if dlIPv4Only {
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, "tcp4", addr)
}
}
client := &http.Client{Transport: transport}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("download failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
if dlOutput == "" {
return netfetch.ToWriter(context.Background(), url, opts, os.Stdout)
_, err = io.Copy(os.Stdout, resp.Body)
return err
}
if err := netfetch.ToFile(context.Background(), url, opts, dlOutput); err != nil {
return err
if dir := filepath.Dir(dlOutput); dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("mkdir failed: %w", err)
}
}
f, err := os.Create(dlOutput)
if err != nil {
return fmt.Errorf("create failed: %w", err)
}
defer f.Close()
if _, err := io.Copy(f, resp.Body); err != nil {
os.Remove(dlOutput)
return fmt.Errorf("write failed: %w", err)
}
fmt.Println(dlOutput)
+21 -4
View File
@@ -16,7 +16,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/netfetch"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
@@ -413,11 +412,27 @@ func updateDMSBinary() error {
}
fmt.Println("Fetching latest release version...")
version, err := netfetch.LatestReleaseTag(context.Background())
cmd := exec.Command("curl", "-s", "https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest")
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to fetch latest release: %w", err)
}
version := ""
for line := range strings.SplitSeq(string(output), "\n") {
if strings.Contains(line, "\"tag_name\"") {
parts := strings.Split(line, "\"")
if len(parts) >= 4 {
version = parts[3]
break
}
}
}
if version == "" {
return fmt.Errorf("could not determine latest version")
}
fmt.Printf("Latest version: %s\n", version)
tempDir, err := os.MkdirTemp("", "dms-update-*")
@@ -433,12 +448,14 @@ func updateDMSBinary() error {
checksumPath := filepath.Join(tempDir, "dms.gz.sha256")
fmt.Println("Downloading dms binary...")
if err := netfetch.ToFile(context.Background(), binaryURL, netfetch.Options{Timeout: 5 * time.Minute}, binaryPath); err != nil {
downloadCmd := exec.Command("curl", "-L", binaryURL, "-o", binaryPath)
if err := downloadCmd.Run(); err != nil {
return fmt.Errorf("failed to download binary: %w", err)
}
fmt.Println("Downloading checksum...")
if err := netfetch.ToFile(context.Background(), checksumURL, netfetch.Options{Timeout: 30 * time.Second}, checksumPath); err != nil {
downloadCmd = exec.Command("curl", "-L", checksumURL, "-o", checksumPath)
if err := downloadCmd.Run(); err != nil {
return fmt.Errorf("failed to download checksum: %w", err)
}
+1 -1
View File
@@ -62,7 +62,7 @@ func greeterPackageInstallHint() string {
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-bin' or 'yay -S greetd-dms-greeter-bin'"
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)"
default:
+79 -94
View File
@@ -2,7 +2,6 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -47,7 +46,7 @@ var keybindsSetCmd = &cobra.Command{
Short: "Set a keybind override",
Long: "Create or update a keybind override for the specified provider",
Args: cobra.ExactArgs(3),
Run: runKeybindsEdit,
Run: runKeybindsSet,
}
var keybindsRemoveCmd = &cobra.Command{
@@ -55,7 +54,7 @@ var keybindsRemoveCmd = &cobra.Command{
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.",
Args: cobra.ExactArgs(2),
Run: runKeybindsEdit,
Run: runKeybindsRemove,
}
var keybindsResetCmd = &cobra.Command{
@@ -63,14 +62,10 @@ var keybindsResetCmd = &cobra.Command{
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: runKeybindsEdit,
Run: runKeybindsReset,
}
func init() {
keybindsCmd.PersistentFlags().String("expected-generation", "", "Configuration generation observed when editing Aqueous bindings")
for _, command := range []*cobra.Command{keybindsSetCmd, keybindsRemoveCmd, keybindsResetCmd} {
command.Flags().Bool("json", false, "Return structured mutation results, including errors")
}
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay")
@@ -98,9 +93,6 @@ func initializeProviders() {
registry := keybinds.GetDefaultRegistry()
hyprlandProvider := providers.NewHyprlandProvider("")
if err := registry.Register(providers.NewAqueousProvider()); err != nil {
log.Warnf("Failed to register Aqueous provider: %v", err)
}
if err := registry.Register(hyprlandProvider); err != nil {
log.Warnf("Failed to register Hyprland provider: %v", err)
}
@@ -214,92 +206,85 @@ func runKeybindsShow(cmd *cobra.Command, args []string) {
printCheatSheet(provider)
}
func keybindEditFailure(err error) map[string]any {
code := "command_failed"
var helperError *providers.AqueousError
if errors.As(err, &helperError) {
code = helperError.Code
}
return map[string]any{"success": false, "code": code, "message": err.Error()}
}
func runKeybindsEdit(cmd *cobra.Command, args []string) {
result, err := editKeybind(cmd, args)
if err == nil {
_ = json.NewEncoder(cmd.OutOrStdout()).Encode(result)
return
}
structured, _ := cmd.Flags().GetBool("json")
if !structured {
log.Fatalf("Failed to save keybind: %v", err)
return
}
_ = json.NewEncoder(cmd.OutOrStdout()).Encode(keybindEditFailure(err))
os.Exit(1)
}
func editKeybind(cmd *cobra.Command, args []string) (map[string]any, error) {
provider, err := keybinds.GetDefaultRegistry().Get(args[0])
func getWritableProvider(name string) keybinds.WritableProvider {
provider, err := keybinds.GetDefaultRegistry().Get(name)
if err != nil {
return nil, err
}
key := args[1]
if aqueous, ok := provider.(*providers.AqueousProvider); ok {
generation, _ := cmd.Flags().GetString("expected-generation")
edit := providers.AqueousBindEdit{Generation: generation, Key: key, Remove: cmd.Name() != "set"}
if !edit.Remove {
for _, flag := range []string{"desc", "allow-when-locked", "cooldown-ms", "no-repeat", "no-inhibiting", "flags"} {
if cmd.Flags().Changed(flag) {
return nil, fmt.Errorf("aqueous does not support --%s in this provider", flag)
}
}
edit.Action = args[2]
edit.OriginalKey, _ = cmd.Flags().GetString("replace-key")
}
result, err := aqueous.Edit(cmd.Context(), edit)
if err != nil {
return nil, err
}
return map[string]any{"success": true, "code": "applied", "generation": result.String("generation"), "key": key}, nil
log.Fatalf("Error: %v", err)
}
writable, ok := provider.(keybinds.WritableProvider)
if !ok {
return nil, fmt.Errorf("provider %s does not support writing keybinds", args[0])
log.Fatalf("Provider %s does not support writing keybinds", name)
}
result := map[string]any{"success": true, "key": key}
switch cmd.Name() {
case "set":
options := make(map[string]any)
if v, _ := cmd.Flags().GetBool("allow-when-locked"); v {
options["allow-when-locked"] = true
}
if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 {
options["cooldown-ms"] = v
}
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
options["repeat"] = false
}
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
options["allow-inhibiting"] = false
}
if v, _ := cmd.Flags().GetString("flags"); v != "" {
options["flags"] = v
}
if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key {
_ = writable.RemoveBind(replaceKey)
}
desc, _ := cmd.Flags().GetString("desc")
err = writable.SetBind(key, args[2], desc, options)
result["action"] = args[2]
result["path"] = writable.GetOverridePath()
case "remove":
err = writable.RemoveBind(key)
result["removed"] = true
case "reset":
err = writable.ResetBind(key)
result["reset"] = true
default:
return nil, fmt.Errorf("unsupported keybind operation: %s", cmd.Name())
}
return result, err
return writable
}
func runKeybindsSet(cmd *cobra.Command, args []string) {
providerName, key, action := args[0], args[1], args[2]
writable := getWritableProvider(providerName)
if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key {
_ = writable.RemoveBind(replaceKey)
}
options := make(map[string]any)
if v, _ := cmd.Flags().GetBool("allow-when-locked"); v {
options["allow-when-locked"] = true
}
if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 {
options["cooldown-ms"] = v
}
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
options["repeat"] = false
}
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
options["allow-inhibiting"] = false
}
if v, _ := cmd.Flags().GetString("flags"); v != "" {
options["flags"] = v
}
desc, _ := cmd.Flags().GetString("desc")
if err := writable.SetBind(key, action, desc, options); err != nil {
log.Fatalf("Error setting keybind: %v", err)
}
output, _ := json.MarshalIndent(map[string]any{
"success": true,
"key": key,
"action": action,
"path": writable.GetOverridePath(),
}, "", " ")
fmt.Fprintln(os.Stdout, string(output))
}
func runKeybindsRemove(_ *cobra.Command, args []string) {
providerName, key := args[0], args[1]
writable := getWritableProvider(providerName)
if err := writable.RemoveBind(key); err != nil {
log.Fatalf("Error removing keybind: %v", err)
}
output, _ := json.MarshalIndent(map[string]any{
"success": true,
"key": key,
"removed": true,
}, "", " ")
fmt.Fprintln(os.Stdout, string(output))
}
func runKeybindsReset(_ *cobra.Command, args []string) {
providerName, key := args[0], args[1]
writable := getWritableProvider(providerName)
if err := writable.ResetBind(key); err != nil {
log.Fatalf("Error resetting keybind: %v", err)
}
output, _ := json.MarshalIndent(map[string]any{
"success": true,
"key": key,
"reset": true,
}, "", " ")
fmt.Fprintln(os.Stdout, string(output))
}
-25
View File
@@ -1,25 +0,0 @@
package main
import (
"errors"
"fmt"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers"
)
func TestKeybindEditFailure(t *testing.T) {
for _, tc := range []struct {
err error
code string
}{
{&providers.AqueousError{Code: "external_change", Message: "changed"}, "external_change"},
{fmt.Errorf("helper: %w", &providers.AqueousError{Code: "read_only", Message: "denied"}), "read_only"},
{errors.New("transport failed"), "command_failed"},
} {
result := keybindEditFailure(tc.err)
if result["success"] != false || result["code"] != tc.code || result["message"] != tc.err.Error() {
t.Fatalf("unexpected failure result: %#v", result)
}
}
}
+3 -27
View File
@@ -44,18 +44,11 @@ var matugenPreviewCmd = &cobra.Command{
Run: runMatugenPreview,
}
var matugenQtengineCmd = &cobra.Command{
Use: "qtengine",
Short: "Sync qtengine's config with the current DMS theme",
Run: runMatugenQtengine,
}
func init() {
matugenCmd.AddCommand(matugenGenerateCmd)
matugenCmd.AddCommand(matugenQueueCmd)
matugenCmd.AddCommand(matugenCheckCmd)
matugenCmd.AddCommand(matugenPreviewCmd)
matugenCmd.AddCommand(matugenQtengineCmd)
for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} {
cmd.Flags().String("state-dir", "", "State directory for cache files")
@@ -63,25 +56,21 @@ func init() {
cmd.Flags().String("config-dir", "", "User config directory")
cmd.Flags().String("kind", "image", "Source type: image or hex")
cmd.Flags().String("value", "", "Wallpaper path or hex color")
cmd.Flags().String("mode", "dark", "Color mode: dark, light, or smart (smart requires matugen 4.2+ and an image source)")
cmd.Flags().String("mode", "dark", "Color mode: dark or light")
cmd.Flags().String("icon-theme", "System Default", "Icon theme name")
cmd.Flags().String("matugen-type", "scheme-tonal-spot", "Matugen scheme type (supports scheme-smart on matugen 4.2+)")
cmd.Flags().String("matugen-type", "scheme-tonal-spot", "Matugen scheme type")
cmd.Flags().Bool("run-user-templates", true, "Run user matugen templates")
cmd.Flags().String("stock-colors", "", "Stock theme colors JSON")
cmd.Flags().Bool("sync-mode-with-portal", false, "Sync color scheme with GNOME portal")
cmd.Flags().Bool("terminals-always-dark", false, "Force terminal themes to dark variant")
cmd.Flags().String("skip-templates", "", "Comma-separated list of templates to skip")
cmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
cmd.Flags().String("source-mode", "", "Source color selection: dominant, colorful, darkness, lightness, saturation, less-saturation, value")
}
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().String("image", "", "Wallpaper image used to resolve the scheme-smart preview")
matugenPreviewCmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
matugenQtengineCmd.Flags().String("config-dir", "", "User config directory")
matugenQtengineCmd.Flags().String("icon-theme", "", "Icon theme name")
}
func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
@@ -99,7 +88,6 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
terminalsAlwaysDark, _ := cmd.Flags().GetBool("terminals-always-dark")
skipTemplates, _ := cmd.Flags().GetString("skip-templates")
contrast, _ := cmd.Flags().GetFloat64("contrast")
sourceMode, _ := cmd.Flags().GetString("source-mode")
return matugen.Options{
StateDir: stateDir,
@@ -116,7 +104,6 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
SyncModeWithPortal: syncModeWithPortal,
TerminalsAlwaysDark: terminalsAlwaysDark,
SkipTemplates: skipTemplates,
SourceMode: sourceMode,
}
}
@@ -154,7 +141,6 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
"terminalsAlwaysDark": opts.TerminalsAlwaysDark,
"skipTemplates": opts.SkipTemplates,
"contrast": opts.Contrast,
"sourceMode": opts.SourceMode,
"wait": wait,
},
}
@@ -226,9 +212,8 @@ func runMatugenCheck(cmd *cobra.Command, args []string) {
func runMatugenPreview(cmd *cobra.Command, args []string) {
sourceColor, _ := cmd.Flags().GetString("source-color")
imagePath, _ := cmd.Flags().GetString("image")
contrast, _ := cmd.Flags().GetFloat64("contrast")
previews, err := matugen.PreviewSchemes(sourceColor, contrast, imagePath)
previews, err := matugen.PreviewSchemes(sourceColor, contrast)
if err != nil {
log.Fatalf("Failed to generate Matugen previews: %v", err)
}
@@ -238,12 +223,3 @@ func runMatugenPreview(cmd *cobra.Command, args []string) {
}
fmt.Println(string(data))
}
func runMatugenQtengine(cmd *cobra.Command, args []string) {
configDir, _ := cmd.Flags().GetString("config-dir")
iconTheme, _ := cmd.Flags().GetString("icon-theme")
if err := matugen.SyncQtengineConfigAt(configDir, iconTheme); err != nil {
log.Fatalf("Failed to sync qtengine config: %v", err)
}
}
-14
View File
@@ -1,14 +0,0 @@
package main
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMatugenQtengineCommandAcceptsConfigDir(t *testing.T) {
flag := matugenQtengineCmd.Flags().Lookup("config-dir")
require.NotNil(t, flag)
assert.Equal(t, "", flag.DefValue)
}
+1 -21
View File
@@ -5,7 +5,6 @@ import (
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/notify"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/spf13/cobra"
)
@@ -62,27 +61,8 @@ func runNotify(cmd *cobra.Command, args []string) {
Timeout: int32(notifyTimeout),
}
id, err := notify.Send(n)
if err != nil {
if err := notify.Send(n); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if n.FilePath == "" {
return
}
watchNotificationAction(id, n.FilePath)
}
func watchNotificationAction(id uint32, path string) {
if id == 0 {
return
}
resp, ok := tryServerRequest(models.Request{
Method: "notify.watchAction",
Params: map[string]any{"id": id, "path": path},
})
if ok && resp.Error == "" {
return
}
notify.SpawnActionListener(id, path)
}
-67
View File
@@ -1,67 +0,0 @@
package main
import (
"fmt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
var registryCmd = &cobra.Command{
Use: "registry",
Short: "Manage plugin and theme registries",
Long: "Manage the registries DMS fetches plugins and themes from. The official registry is always active; additional registries can be added by name and git URL.",
}
var registryListCmd = &cobra.Command{
Use: "list",
Short: "List configured registries",
Run: func(cmd *cobra.Command, args []string) {
for _, s := range registries.Load(afero.NewOsFs()) {
suffix := ""
if s.Official() {
suffix = " (official)"
}
fmt.Printf("%s%s\n %s\n", s.Name, suffix, s.URL)
}
},
}
var registryAddCmd = &cobra.Command{
Use: "add <name> <url>",
Short: "Add a registry",
Long: "Add a registry by name and git URL. The repository must contain a plugins/ or themes/ directory in the registry format.",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
if err := registries.Add(afero.NewOsFs(), args[0], args[1]); err != nil {
log.Fatalf("Error adding registry: %v", err)
}
fmt.Printf("Registry added: %s\n", args[0])
},
}
var registryRemoveCmd = &cobra.Command{
Use: "remove <name>",
Short: "Remove a registry",
Args: cobra.ExactArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
var names []string
for _, s := range registries.Load(afero.NewOsFs()) {
if !s.Official() {
names = append(names, s.Name)
}
}
return names, cobra.ShellCompDirectiveNoFileComp
},
Run: func(cmd *cobra.Command, args []string) {
if err := registries.Remove(afero.NewOsFs(), args[0]); err != nil {
log.Fatalf("Error removing registry: %v", err)
}
fmt.Printf("Registry removed: %s\n", args[0])
},
}
+35 -95
View File
@@ -2,25 +2,21 @@ package main
import (
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime/debug"
"strconv"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/notify"
"github.com/AvengeMedia/DankMaterialShell/core/internal/screenshot"
"github.com/spf13/cobra"
)
var (
ssOutputName string
ssSeat string
ssCursor string
ssFormat string
ssQuality int
@@ -33,15 +29,11 @@ var (
ssReset bool
ssStdout bool
ssJSON bool
ssGeometry bool
ssAllowMulti bool
)
type screenshotMetadata struct {
Status string `json:"status"`
Path string `json:"path,omitempty"`
X *int `json:"x,omitempty"`
Y *int `json:"y,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Scale float64 `json:"scale,omitempty"`
@@ -59,7 +51,7 @@ 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/Aqueous)
window - Capture the focused window (Hyprland/Mango/niri)
last - Capture the last selected region
scroll - Select a region, then scroll to capture a stitched tall image
@@ -73,7 +65,7 @@ Examples:
dms screenshot full # Full screen of focused output
dms screenshot all # All screens combined
dms screenshot output -o DP-1 # Specific output
dms screenshot window # Focused window
dms screenshot window # Focused window (Hyprland)
dms screenshot last # Last region (pre-selected)
dms screenshot --reset # Reset last region pre-selection
dms screenshot --no-clipboard # Save file only
@@ -82,8 +74,6 @@ Examples:
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 --allow-multiple # Skip the one-selector-at-a-time guard
dms screenshot -g # Print selected region geometry (X,Y WxH) to stdout
dms screenshot scroll # Scroll capture, Enter finishes / Esc cancels
dms screenshot scroll --interval 250`,
}
@@ -123,7 +113,7 @@ 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, niri, and Aqueous. Aqueous requires a running DMS shell and crops the output including borders and overlapping windows.`,
Long: `Capture the currently focused window. Supported on Hyprland, Mango, and niri.`,
Run: runScreenshotWindow,
}
@@ -160,13 +150,12 @@ var notifyActionCmd = &cobra.Command{
Use: "notify-action",
Hidden: true,
Run: func(cmd *cobra.Command, args []string) {
notify.RunActionListener(args)
screenshot.RunNotifyActionListener(args)
},
}
func init() {
screenshotCmd.PersistentFlags().StringVarP(&ssOutputName, "output", "o", "", "Output name for 'output' mode")
screenshotCmd.PersistentFlags().StringVar(&ssSeat, "seat", "", "Seat for Aqueous capture (required with multiple seats)")
screenshotCmd.PersistentFlags().StringVar(&ssCursor, "cursor", "off", "Include cursor in screenshot (on/off)")
screenshotCmd.PersistentFlags().StringVarP(&ssFormat, "format", "f", "png", "Output format (png, jpg, ppm)")
screenshotCmd.PersistentFlags().IntVarP(&ssQuality, "quality", "q", 90, "JPEG quality (1-100)")
@@ -179,8 +168,6 @@ func init() {
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")
screenshotCmd.PersistentFlags().BoolVarP(&ssGeometry, "geometry", "g", false, "Print selected region geometry (X,Y WxH) to stdout without capturing an image")
screenshotCmd.PersistentFlags().BoolVar(&ssAllowMulti, "allow-multiple", false, "Open a selector even when another one is already open")
ssScrollCmd.Flags().IntVar(&ssScrollInterval, "interval", 45, "Capture interval in milliseconds (30-1000)")
@@ -200,7 +187,6 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
config := screenshot.DefaultConfig()
config.Mode = mode
config.OutputName = ssOutputName
config.Seat = ssSeat
if strings.EqualFold(ssCursor, "on") {
config.Cursor = screenshot.CursorOn
}
@@ -210,14 +196,6 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
config.NoConfirm = ssNoConfirm
config.Reset = ssReset
config.Stdout = ssStdout
config.Geometry = ssGeometry
config.AllowMultiple = ssAllowMulti
if ssGeometry {
config.Clipboard = false
config.SaveFile = false
config.Notify = false
}
if ssOutputDir != "" {
config.OutputDir = ssOutputDir
@@ -246,8 +224,7 @@ 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 and not awaited: qs takes ~20ms to come up, longer than the selector needs to show.
// setPopoutScreenshotMode toggles the shell handshake so popouts drop their keyboard grab during region select. Best-effort.
func setPopoutScreenshotMode(begin bool) {
fn := "end"
if begin {
@@ -266,7 +243,7 @@ func setPopoutScreenshotMode(begin bool) {
cmdArgs = append(cmdArgs, "-p", shellApp.ConfigPath())
}
cmdArgs = append(cmdArgs, "call", "screenshot", fn)
_ = exec.Command("qs", cmdArgs...).Start()
_ = exec.Command("qs", cmdArgs...).Run()
}
func writeScreenshotJSON(meta screenshotMetadata) {
@@ -298,24 +275,17 @@ func runScreenshot(config screenshot.Config) {
fmt.Fprintln(os.Stderr, "Error: --json cannot be combined with --stdout")
os.Exit(1)
}
if config.Geometry {
if config.Stdout {
fmt.Fprintln(os.Stderr, "Error: --geometry cannot be combined with --stdout")
os.Exit(1)
}
if config.Mode == screenshot.ModeScroll {
fmt.Fprintln(os.Stderr, "Error: --geometry cannot be combined with scroll mode")
os.Exit(1)
}
}
// Short-lived process over a few tens of MB: let the heap grow instead of paying GC cycles mid-capture.
debug.SetGCPercent(-1)
debug.SetMemoryLimit(1 << 30)
// Region select needs the keyboard; drop popout grabs for its duration.
config.SelectorHook = setPopoutScreenshotMode
result, err := screenshot.New(config).Run()
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()
}()
if err != nil {
exitScreenshotError("", err)
}
@@ -324,39 +294,17 @@ func runScreenshot(config screenshot.Config) {
if ssJSON {
writeScreenshotJSON(screenshotMetadata{Status: "aborted", Error: "User cancelled selection"})
}
if config.Geometry {
os.Exit(1)
}
os.Exit(0)
}
if config.Geometry {
if ssJSON {
x := int(result.Region.X)
y := int(result.Region.Y)
writeScreenshotJSON(screenshotMetadata{
Status: "success",
X: &x,
Y: &y,
Width: int(result.Region.Width),
Height: int(result.Region.Height),
})
} else {
fmt.Println(result.Region.GeometryString())
}
os.Exit(0)
}
defer result.Buffer.Close()
if result.Buffer != nil {
defer result.Buffer.Close()
if result.YInverted {
result.Buffer.FlipVertical()
}
if result.YInverted {
result.Buffer.FlipVertical()
}
if config.Stdout {
if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format, result.CICP); err != nil {
if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
exitScreenshotError(" writing to stdout", err)
}
return
@@ -376,7 +324,7 @@ func runScreenshot(config screenshot.Config) {
}
filePath = filepath.Join(outputDir, filename)
if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format, result.CICP); err != nil {
if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format); err != nil {
exitScreenshotError(" writing file", err)
}
if !ssJSON {
@@ -385,7 +333,7 @@ func runScreenshot(config screenshot.Config) {
}
if config.Clipboard {
if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format, result.CICP); err != nil {
if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
exitScreenshotError(" copying to clipboard", err)
}
if !ssJSON && !config.SaveFile {
@@ -410,30 +358,31 @@ func runScreenshot(config screenshot.Config) {
if config.Notify {
thumbData, thumbW, thumbH := bufferToRGBThumbnail(result.Buffer, 256, result.Format)
id := screenshot.SendNotification(screenshot.NotifyResult{
screenshot.SendNotification(screenshot.NotifyResult{
FilePath: filePath,
Clipboard: config.Clipboard,
ImageData: thumbData,
Width: thumbW,
Height: thumbH,
})
watchNotificationAction(id, filePath)
}
}
func copyImageToClipboard(buf *screenshot.ShmBuffer, format screenshot.Format, quality int, pixelFormat uint32, cicp *screenshot.CICP) error {
func copyImageToClipboard(buf *screenshot.ShmBuffer, format screenshot.Format, quality int, pixelFormat uint32) error {
var mimeType string
var data bytes.Buffer
img := screenshot.BufferToImageWithFormat(buf, pixelFormat)
switch format {
case screenshot.FormatJPEG:
mimeType = "image/jpeg"
if err := screenshot.EncodeBufferJPEG(&data, buf, pixelFormat, quality); err != nil {
if err := screenshot.EncodeJPEG(&data, img, quality); err != nil {
return err
}
default:
mimeType = "image/png"
if err := screenshot.EncodeBufferPNG(&data, buf, pixelFormat, cicp); err != nil {
if err := screenshot.EncodePNG(&data, img); err != nil {
return err
}
}
@@ -441,12 +390,14 @@ func copyImageToClipboard(buf *screenshot.ShmBuffer, format screenshot.Format, q
return clipboard.Copy(data.Bytes(), mimeType)
}
func writeImageToStdout(buf *screenshot.ShmBuffer, format screenshot.Format, quality int, pixelFormat uint32, cicp *screenshot.CICP) error {
func writeImageToStdout(buf *screenshot.ShmBuffer, format screenshot.Format, quality int, pixelFormat uint32) error {
img := screenshot.BufferToImageWithFormat(buf, pixelFormat)
switch format {
case screenshot.FormatJPEG:
return screenshot.EncodeBufferJPEG(os.Stdout, buf, pixelFormat, quality)
return screenshot.EncodeJPEG(os.Stdout, img, quality)
default:
return screenshot.EncodeBufferPNG(os.Stdout, buf, pixelFormat, cicp)
return screenshot.EncodePNG(os.Stdout, img)
}
}
@@ -473,12 +424,9 @@ func bufferToRGBThumbnail(buf *screenshot.ShmBuffer, maxSize int, pixelFormat ui
data := buf.Data()
rgb := make([]byte, dstW*dstH*3)
is10Bit := screenshot.PixelFormat(pixelFormat).Is10Bit()
var swapRB bool
switch pixelFormat {
case uint32(screenshot.FormatABGR8888), uint32(screenshot.FormatXBGR8888),
uint32(screenshot.FormatABGR2101010), uint32(screenshot.FormatXBGR2101010):
case uint32(screenshot.FormatABGR8888), uint32(screenshot.FormatXBGR8888):
swapRB = false
default:
swapRB = true
@@ -499,19 +447,11 @@ func bufferToRGBThumbnail(buf *screenshot.ShmBuffer, maxSize int, pixelFormat ui
if si+3 >= len(data) {
continue
}
switch {
case is10Bit:
v := binary.LittleEndian.Uint32(data[si:])
c0, c1, c2 := uint8(v>>2), uint8(v>>12), uint8(v>>22)
if swapRB {
c0, c2 = c2, c0
}
rgb[di+0], rgb[di+1], rgb[di+2] = c0, c1, c2
case swapRB:
if swapRB {
rgb[di+0] = data[si+2]
rgb[di+1] = data[si+1]
rgb[di+2] = data[si+0]
default:
} else {
rgb[di+0] = data[si+0]
rgb[di+1] = data[si+1]
rgb[di+2] = data[si+2]
+1 -1
View File
@@ -120,7 +120,7 @@ func showSession(id string) (sessionInfo, error) {
return sessionInfo{}, err
}
fields := map[string]string{}
for line := range strings.SplitSeq(string(out), "\n") {
for _, line := range strings.Split(string(out), "\n") {
idx := strings.IndexByte(line, '=')
if idx <= 0 {
continue
+53 -58
View File
@@ -143,10 +143,6 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
niriFile: "alttab.kdl",
niriContent: func(_ string) string { return config.NiriAlttabConfig },
},
"input": {
niriFile: "input.kdl",
niriContent: func(_ string) string { return config.NiriInputConfig },
},
"outputs": {
niriFile: "outputs.kdl",
hyprFile: "outputs.lua",
@@ -356,18 +352,22 @@ func runSetup() error {
}
}()
ctx := context.Background()
var results []config.DeploymentResult
var err error
if wmSelected {
var result config.DeploymentResult
result, err = deployer.DeployCompositor(wm, terminal, useSystemd)
results = append(results, result)
}
if err == nil && terminalSelected {
var terminalResults []config.DeploymentResult
terminalResults, err = deployer.DeployTerminal(terminal)
results = append(results, terminalResults...)
if wmSelected && terminalSelected {
results, err = deployer.DeployConfigurationsWithSystemd(ctx, wm, terminal, useSystemd)
} else if wmSelected {
results, err = deployer.DeployConfigurationsWithSystemd(ctx, wm, deps.TerminalGhostty, useSystemd)
if len(results) > 1 {
results = results[:1]
}
} else if terminalSelected {
results, err = deployer.DeployConfigurationsWithSystemd(ctx, deps.WindowManagerNiri, terminal, useSystemd)
if len(results) > 0 && results[0].ConfigType == "Niri" {
results = results[1:]
}
}
close(logChan)
@@ -491,54 +491,49 @@ func promptSystemd() bool {
}
func checkExistingConfigs(wm deps.WindowManager, wmSelected bool, terminal deps.Terminal, terminalSelected bool) bool {
if wmSelected && firstExistingPath(compositorPrimaryPaths(wm)) != "" {
return true
}
return terminalSelected && firstExistingPath([]string{terminalPrimaryPath(terminal)}) != ""
}
func compositorPrimaryPaths(wm deps.WindowManager) []string {
homeDir := os.Getenv("HOME")
switch wm {
case deps.WindowManagerNiri:
return []string{filepath.Join(homeDir, ".config", "niri", "config.kdl")}
case deps.WindowManagerHyprland:
return []string{
filepath.Join(homeDir, ".config", "hypr", "hyprland.lua"),
filepath.Join(homeDir, ".config", "hypr", "hyprland.conf"),
}
case deps.WindowManagerMango:
return []string{
filepath.Join(homeDir, ".config", "mango", "config.conf"),
filepath.Join(homeDir, ".config", "mango", "mango.conf"),
}
default:
return nil
}
}
willBackup := false
func terminalPrimaryPath(terminal deps.Terminal) string {
homeDir := os.Getenv("HOME")
switch terminal {
case deps.TerminalGhostty:
return filepath.Join(homeDir, ".config", "ghostty", "config")
case deps.TerminalKitty:
return filepath.Join(homeDir, ".config", "kitty", "kitty.conf")
case deps.TerminalAlacritty:
return filepath.Join(homeDir, ".config", "alacritty", "alacritty.toml")
default:
return ""
}
}
if wmSelected {
var configPaths []string
switch wm {
case deps.WindowManagerNiri:
configPaths = []string{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"),
}
}
func firstExistingPath(paths []string) string {
for _, path := range paths {
if path == "" {
continue
}
if _, err := os.Stat(path); err == nil {
return path
for _, configPath := range configPaths {
if _, err := os.Stat(configPath); err == nil {
willBackup = true
break
}
}
}
return ""
if terminalSelected {
var configPath string
switch terminal {
case deps.TerminalGhostty:
configPath = filepath.Join(homeDir, ".config", "ghostty", "config")
case deps.TerminalKitty:
configPath = filepath.Join(homeDir, ".config", "kitty", "kitty.conf")
case deps.TerminalAlacritty:
configPath = filepath.Join(homeDir, ".config", "alacritty", "alacritty.toml")
}
if _, err := os.Stat(configPath); err == nil {
willBackup = true
}
}
return willBackup
}
-148
View File
@@ -1,148 +0,0 @@
package main
import (
"fmt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/spf13/cobra"
)
var setupHeadlessCmd = &cobra.Command{
Use: "headless",
Short: "Deploy DMS configurations without prompts",
Long: "Deploy compositor and terminal configurations non-interactively, for installers and scripts",
PersistentPreRunE: requireMutableSystemCommand,
RunE: func(cmd *cobra.Command, args []string) error {
compositor, _ := cmd.Flags().GetString("compositor")
terminal, _ := cmd.Flags().GetString("terminal")
noSystemd, _ := cmd.Flags().GetBool("no-systemd")
force, _ := cmd.Flags().GetBool("force")
skipExisting, _ := cmd.Flags().GetBool("skip-existing")
cmd.SilenceUsage = true
return runSetupHeadless(compositor, terminal, noSystemd, force, skipExisting)
},
}
func init() {
setupHeadlessCmd.Flags().String("compositor", "", "Compositor to configure: niri, hyprland, or mango")
setupHeadlessCmd.Flags().String("terminal", "", "Also deploy a terminal config: ghostty, kitty, or alacritty")
setupHeadlessCmd.Flags().Bool("no-systemd", false, "Deploy session config without systemd integration")
setupHeadlessCmd.Flags().Bool("force", false, "Overwrite existing configs (timestamped backups are created)")
setupHeadlessCmd.Flags().Bool("skip-existing", false, "Warn and skip instead of failing when a config already exists")
_ = setupHeadlessCmd.MarkFlagRequired("compositor")
setupHeadlessCmd.MarkFlagsMutuallyExclusive("force", "skip-existing")
}
func runSetupHeadless(compositor, terminal string, noSystemd, force, skipExisting bool) error {
wm, err := parseHeadlessCompositor(compositor)
if err != nil {
return err
}
term, termSelected, err := parseHeadlessTerminal(terminal)
if err != nil {
return err
}
deployCompositor := true
deployTerminal := termSelected
if !force {
if existing := firstExistingPath(compositorPrimaryPaths(wm)); existing != "" {
if !skipExisting {
return fmt.Errorf("%s config already exists: %s (use --force to overwrite with backup, or --skip-existing)", compositor, existing)
}
fmt.Printf("⚠ Skipping %s config, already exists: %s\n", compositor, existing)
deployCompositor = false
}
if deployTerminal {
if existing := firstExistingPath([]string{terminalPrimaryPath(term)}); existing != "" {
if !skipExisting {
return fmt.Errorf("%s config already exists: %s (use --force to overwrite with backup, or --skip-existing)", terminal, existing)
}
fmt.Printf("⚠ Skipping %s config, already exists: %s\n", terminal, existing)
deployTerminal = false
}
}
}
if !deployCompositor && !deployTerminal {
return nil
}
logChan := make(chan string, 100)
logDone := make(chan struct{})
go func() {
for msg := range logChan {
fmt.Println(" " + msg)
}
close(logDone)
}()
deployer := config.NewConfigDeployer(logChan)
var results []config.DeploymentResult
var deployErr error
if deployCompositor {
result, err := deployer.DeployCompositor(wm, term, headlessUseSystemd(wm, noSystemd))
results = append(results, result)
deployErr = err
}
if deployErr == nil && deployTerminal {
terminalResults, err := deployer.DeployTerminal(term)
results = append(results, terminalResults...)
deployErr = err
}
close(logChan)
<-logDone
for _, result := range results {
if !result.Deployed {
continue
}
fmt.Printf("✓ %s: %s\n", result.ConfigType, result.Path)
if result.BackupPath != "" {
fmt.Printf(" Backup: %s\n", result.BackupPath)
}
}
if deployErr != nil {
return fmt.Errorf("deployment failed: %w", deployErr)
}
return nil
}
func parseHeadlessCompositor(name string) (deps.WindowManager, error) {
switch name {
case "niri":
return deps.WindowManagerNiri, nil
case "hyprland":
return deps.WindowManagerHyprland, nil
case "mango":
return deps.WindowManagerMango, nil
default:
return 0, fmt.Errorf("unknown compositor %q (expected niri, hyprland, or mango)", name)
}
}
func parseHeadlessTerminal(name string) (deps.Terminal, bool, error) {
switch name {
case "":
return deps.TerminalGhostty, false, nil
case "ghostty":
return deps.TerminalGhostty, true, nil
case "kitty":
return deps.TerminalKitty, true, nil
case "alacritty":
return deps.TerminalAlacritty, true, nil
default:
return 0, false, fmt.Errorf("unknown terminal %q (expected ghostty, kitty, or alacritty)", name)
}
}
func headlessUseSystemd(wm deps.WindowManager, noSystemd bool) bool {
if noSystemd || wm == deps.WindowManagerMango {
return false
}
return !isVoidSetup()
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_power"
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
type cmd struct {
+2 -4
View File
@@ -13,10 +13,9 @@ var Version = "dev"
func init() {
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
setupCmd.AddCommand(setupHeadlessCmd, setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
updateCmd.AddCommand(updateCheckCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd, pluginsLockCmd, pluginsRestoreCmd)
registryCmd.AddCommand(registryListCmd, registryAddCmd, registryRemoveCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
rootCmd.AddCommand(getCommonCommands()...)
rootCmd.AddCommand(authCmd)
@@ -26,7 +25,6 @@ func init() {
}
func main() {
disableMemProfilingUnlessRequested()
clipboard.MaybeServeAndExit()
if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) {
+2 -3
View File
@@ -13,8 +13,8 @@ var Version = "dev"
func init() {
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
setupCmd.AddCommand(setupHeadlessCmd, setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd, pluginsLockCmd, pluginsRestoreCmd)
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
rootCmd.AddCommand(getCommonCommands()...)
rootCmd.AddCommand(authCmd)
@@ -22,7 +22,6 @@ func init() {
}
func main() {
disableMemProfilingUnlessRequested()
clipboard.MaybeServeAndExit()
if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management"
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
type randrOutput struct {
+4 -2
View File
@@ -10,7 +10,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
// maxIPCMessageSize allows room for a 50 MB clipboard entry plus JSON/base64
@@ -97,7 +96,10 @@ func tryServerRequest(req models.Request) (*models.Response[any], bool) {
}
func getServerSocketPath() string {
runtimeDir := utils.RuntimeDir()
runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
if runtimeDir == "" {
runtimeDir = os.TempDir()
}
if sessionSock, ok := shellApp.SessionSocketPath(); ok {
return sessionSock
+2 -27
View File
@@ -14,7 +14,6 @@ import (
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/qsipc"
)
type ipcTargets map[string]map[string][]string
@@ -138,9 +137,6 @@ func runShellIPCCommand(args []string) {
if args[0] != "call" {
args = append([]string{"call"}, args...)
}
if tryDirectIPCCall(args) {
return
}
baseArgs, err := buildQsIPCBaseArgs()
if err != nil {
@@ -157,27 +153,6 @@ func runShellIPCCommand(args []string) {
}
}
// tryDirectIPCCall invokes the shell over its IPC socket without spawning qs.
// Any failure falls back to the qs child process path, which owns error output.
func tryDirectIPCCall(args []string) bool {
if len(args) < 3 {
return false
}
pid, ok := shellApp.SessionPID()
if !ok {
return false
}
result, isVoid, err := qsipc.Call(qsipc.SocketPathForPID(pid), args[1], args[2], args[3:])
if err != nil {
log.Debugf("Direct Quickshell IPC failed, falling back to qs: %v", err)
return false
}
if !isVoid {
fmt.Println(result)
}
return true
}
func printIPCHelp() {
fmt.Println("Usage: dms ipc call <target> <function> [args...]")
fmt.Println()
@@ -286,8 +261,8 @@ func ensureFontCache() {
}
func fontInCache(target, cache string) bool {
for line := range strings.SplitSeq(cache, "\n") {
for fam := range strings.SplitSeq(strings.TrimSpace(line), ",") {
for _, line := range strings.Split(cache, "\n") {
for _, fam := range strings.Split(strings.TrimSpace(line), ",") {
if strings.TrimSpace(fam) == target {
return true
}
+9 -16
View File
@@ -6,7 +6,6 @@ import (
"os"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/gpu"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
"github.com/AvengeMedia/DankMaterialShell/core/internal/shellembed"
@@ -14,17 +13,15 @@ import (
)
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,
SessionRestartExitCode: dmsSessionRestartExitCode,
TryManagedRestart: trySystemdRestart,
ID: "danklinux",
EnvPrefix: "DMS",
QSAppID: "com.danklinux.dms",
Version: Version,
Embedded: embeddedShell{},
Boot: bootBackend,
PreLaunch: preLaunch,
ExtraEnv: dmsExtraEnv,
OnUIExit: logStartupFailure,
})
type embeddedShell struct{}
@@ -81,9 +78,5 @@ func dmsExtraEnv(string) []string {
if os.Getenv("QSG_USE_SIMPLE_ANIMATION_DRIVER") == "" {
env = append(env, "QSG_USE_SIMPLE_ANIMATION_DRIVER=1")
}
if _, set := os.LookupEnv("MALLOC_CONF"); !set {
env = append(env, "MALLOC_CONF=thp:never,narenas:4,dirty_decay_ms:3000")
}
env = append(env, gpu.EGLVendorEnv()...)
return env
}
-32
View File
@@ -1,32 +0,0 @@
package main
import (
"fmt"
"os/exec"
"strings"
)
const dmsSystemdUnit = "dms.service"
const dmsSessionRestartExitCode = 75 // EX_TEMPFAIL
var runSystemctl = func(args ...string) error {
output, err := exec.Command("systemctl", args...).CombinedOutput()
if err == nil {
return nil
}
if detail := strings.TrimSpace(string(output)); detail != "" {
return fmt.Errorf("%s: %w", detail, err)
}
return err
}
func trySystemdRestart() (bool, error) {
if err := runSystemctl("--user", "is-active", "--quiet", dmsSystemdUnit); err != nil {
return false, nil
}
if err := runSystemctl("--user", "restart", dmsSystemdUnit); err != nil {
return true, fmt.Errorf("restarting %s: %w", dmsSystemdUnit, err)
}
return true, nil
}
+6 -6
View File
@@ -15,12 +15,12 @@ func getThemedASCII() string {
theme := tui.TerminalTheme()
logo := `
██████╗ ███╗ ███╗███████╗
██╔══██╗████╗ ████║██╔════╝
██║ ██║██╔████╔██║███████╗
██║ ██║██║╚██╔╝██║╚════██║
██████╔╝██║ ╚═╝ ██║███████║
╚═════╝ ╚═╝ ╚═╝╚══════╝`
██████╗ █████╗ ███╗ ██╗██╗ ██╗
██╔══██╗██╔══██╗████╗ ██║██║ ██╔╝
██║ ██║███████║██╔██╗ ██║█████╔╝
██║ ██║██╔══██║██║╚██╗██║██╔═██╗
██████╔╝██║ ██║██║ ╚████║██║ ██╗
╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝`
style := lipgloss.NewStyle().
Foreground(lipgloss.Color(theme.Primary)).
-9
View File
@@ -1,19 +1,10 @@
package main
import (
"os"
"os/exec"
"runtime"
"strings"
)
func disableMemProfilingUnlessRequested() {
if os.Getenv("DMS_PPROF") != "" {
return
}
runtime.MemProfileRate = 0
}
// 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 {
+11 -21
View File
@@ -1,20 +1,16 @@
module github.com/AvengeMedia/DankMaterialShell/core
go 1.26.5
go 1.26.4
require (
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577
github.com/Nadim147c/material/v3 v3.1.1
github.com/Wifx/gonetworkmanager/v2 v2.2.0
github.com/alecthomas/chroma/v2 v2.27.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/dlclark/regexp2/v2 v2.5.1
github.com/fsnotify/fsnotify v1.10.1
github.com/godbus/dbus/v5 v5.2.2
github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847
github.com/klauspost/compress v1.19.2
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
@@ -22,10 +18,11 @@ require (
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-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.102.0
tailscale.com v1.100.0
)
require (
@@ -38,9 +35,8 @@ require (
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/danielgtaylor/huma/v2 v2.39.1 // indirect
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca // indirect
github.com/ebitengine/purego v0.10.2 // indirect
github.com/dlclark/regexp2/v2 v2.5.1 // 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
@@ -48,42 +44,36 @@ require (
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-logfmt/logfmt v0.6.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // 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/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/shirou/gopsutil/v4 v4.26.7 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // 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-20260727155853-b88d891fe743 // 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
)
require (
github.com/AvengeMedia/dankgo v0.0.0-20260902003743-aeb6467c214e
github.com/AvengeMedia/dankgo v0.0.0-20260721162324-38d48943054d
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/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
@@ -91,10 +81,10 @@ require (
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-git/go-git/v6 v6.0.0-alpha.4
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1
github.com/mattn/go-isatty v0.0.24
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 // indirect
github.com/mattn/go-runewidth v0.0.28 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0
github.com/mattn/go-isatty v0.0.23
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.24 // 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
+25 -48
View File
@@ -1,13 +1,9 @@
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-20260902003743-aeb6467c214e h1:AUCBM5EF+hxCG55uJl30waRQHhR5+Wij9u8AfoTkQvs=
github.com/AvengeMedia/dankgo v0.0.0-20260902003743-aeb6467c214e/go.mod h1:w1W/vDrWJCLdjVI4E/mURiKUQLMCmiGiPLxZEeP5kp8=
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577 h1:z7daEy56wLH6R7PGS3MKIVoDHNdVoDptY/f2i61EDCQ=
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577/go.mod h1:2kt1u9YgHwi2npqB5ago8kwZdFrHlPGXv3PM/vwRHfY=
github.com/AvengeMedia/dankgo v0.0.0-20260721162324-38d48943054d h1:EZN2x2uAX975Uvw4MByqWaaVTp2fOJrNTl4V4EQbSn8=
github.com/AvengeMedia/dankgo v0.0.0-20260721162324-38d48943054d/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/Nadim147c/material/v3 v3.1.1 h1:3ZuTPWBJWUpzzWW1rtqe28RMZXhA4f0/mg/Jk3JG94Y=
github.com/Nadim147c/material/v3 v3.1.1/go.mod h1:ThN+dRdC+CDN2qXSGMn32KqtcXwYtb/trDM82cdZI6g=
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/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
@@ -16,8 +12,10 @@ 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/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=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
@@ -34,6 +32,8 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv
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/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=
@@ -57,18 +57,16 @@ github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6p
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/danielgtaylor/huma/v2 v2.39.1 h1:0kwF4ltQoYZ+IU55VPy+BcGekzgF44R64daTGde1H+g=
github.com/danielgtaylor/huma/v2 v2.39.1/go.mod h1:zcnQ38duIJ3VUHwFaBoZ6x8T+KN/mr33oyqxcj0HTug=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca h1:h1Awca4lQOspNR/2eeo04Ricn5NixDX9mb17WSAgLhQ=
github.com/dblohm7/wingoes v0.0.0-20260526185140-fb298caac7ca/go.mod h1:2TGl1jRJrRpbzykmg7asHm3h08TqutUgQqY5v9k/g3c=
github.com/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/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
@@ -85,8 +83,6 @@ github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
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=
@@ -99,9 +95,6 @@ github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSy
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
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=
@@ -112,8 +105,6 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUv
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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.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=
@@ -126,8 +117,6 @@ github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5G
github.com/jsimonetti/rtnetlink v1.4.2/go.mod h1:92s6LJdE+1iOrw+F2/RO7LYI2Qd8pPpFNNUYW06gcoM=
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -137,16 +126,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.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-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.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU=
github.com/mattn/go-runewidth v0.0.28/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
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=
@@ -168,8 +155,6 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
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/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
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=
@@ -179,8 +164,6 @@ github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 h1:JsjzqC6ymELkN4Xl
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
@@ -192,16 +175,13 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
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-20260715223240-2e01ba5b00f0 h1:CnIEL2n7Xql6Ux1k+Vu5S5ubDHCT/kxFgkKCY8FjefU=
github.com/tailscale/wireguard-go v0.0.0-20260715223240-2e01ba5b00f0/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI=
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
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=
@@ -212,10 +192,11 @@ github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx
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/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
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.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
@@ -225,8 +206,8 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBs
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-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM=
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
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=
@@ -235,18 +216,13 @@ 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/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
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=
@@ -256,7 +232,8 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogR
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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.102.0 h1:yAYrykMZWlgp7Qq2ECDrDnxeySWBhNXNMkXdDmVMyrE=
tailscale.com v1.102.0/go.mod h1:NdndXfkSklVPbRUZMAzzRuDNaR5B+YWGbAyE1HpSQZg=
tailscale.com v1.100.0 h1:nm/M/dEaW9RaRsGUjW2HsSDpsZ60Jwd9k4gNW9tTFiE=
tailscale.com v1.100.0/go.mod h1:DQ9YBy85DpNlSyeU2XRIWzbAu3RsGp/frv+Khg57meE=
+7 -2
View File
@@ -47,7 +47,6 @@ printf "%bInstalling Dankinstall %s for %s...%b\n" "$GREEN" "$LATEST_VERSION" "$
# Download and install
TEMP_DIR=$(mktemp -d)
trap 'cd / && rm -rf "$TEMP_DIR"' EXIT
cd "$TEMP_DIR" || exit 1
# Download the gzipped binary and its checksum
@@ -68,6 +67,8 @@ if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then
printf "Expected: %s\n" "$EXPECTED_CHECKSUM"
printf "Got: %s\n" "$ACTUAL_CHECKSUM"
printf "The downloaded file may be corrupted or tampered with\n"
cd - >/dev/null
rm -rf "$TEMP_DIR"
exit 1
fi
@@ -78,4 +79,8 @@ chmod +x installer
# Execute the installer
printf "%bRunning installer...%b\n" "$GREEN" "$NC"
./installer "$@"
./installer
# Cleanup
cd - >/dev/null
rm -rf "$TEMP_DIR"
-198
View File
@@ -1,198 +0,0 @@
package backup
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
const dirName = "DankMaterialShell"
func ConfigDir() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(configDir, dirName), nil
}
func DefaultArchiveName() string {
return fmt.Sprintf("dms-backup-%s.tar.gz", time.Now().Format("20060102-150405"))
}
func Create(outputPath string) error {
srcDir, err := ConfigDir()
if err != nil {
return err
}
if _, err := os.Stat(srcDir); err != nil {
return fmt.Errorf("no DMS configuration found at %s: %w", srcDir, err)
}
out, err := os.Create(outputPath)
if err != nil {
return err
}
defer out.Close()
gz := gzip.NewWriter(out)
defer gz.Close()
tw := tar.NewWriter(gz)
defer tw.Close()
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(srcDir, path)
if err != nil {
return err
}
if rel == "." {
return nil
}
var link string
if info.Mode()&os.ModeSymlink != 0 {
if link, err = os.Readlink(path); err != nil {
return err
}
}
header, err := tar.FileInfoHeader(info, link)
if err != nil {
return err
}
header.Name = filepath.ToSlash(filepath.Join(dirName, rel))
if err := tw.WriteHeader(header); err != nil {
return err
}
if !info.Mode().IsRegular() {
return nil
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(tw, f)
return err
})
}
func Restore(archivePath string) (string, error) {
dstDir, err := ConfigDir()
if err != nil {
return "", err
}
if err := validateArchive(archivePath); err != nil {
return "", err
}
previous := ""
if _, err := os.Stat(dstDir); err == nil {
previous = dstDir + ".pre-restore-" + time.Now().Format("20060102-150405")
if err := os.Rename(dstDir, previous); err != nil {
return "", fmt.Errorf("failed to move existing configuration aside: %w", err)
}
}
if err := extract(archivePath, filepath.Dir(dstDir)); err != nil {
if previous != "" {
os.RemoveAll(dstDir)
os.Rename(previous, dstDir)
}
return "", err
}
return previous, nil
}
func validateArchive(archivePath string) error {
hasSettings := false
err := walkArchive(archivePath, func(header *tar.Header, _ *tar.Reader) error {
name := filepath.ToSlash(filepath.Clean(header.Name))
if strings.HasPrefix(name, "..") || filepath.IsAbs(header.Name) {
return fmt.Errorf("unsafe path in archive: %s", header.Name)
}
if !strings.HasPrefix(name, dirName+"/") && name != dirName {
return fmt.Errorf("not a DMS backup: unexpected entry %s", header.Name)
}
if name == dirName+"/settings.json" {
hasSettings = true
}
return nil
})
if err != nil {
return err
}
if !hasSettings {
return fmt.Errorf("not a DMS backup: settings.json missing from archive")
}
return nil
}
func extract(archivePath, destParent string) error {
return walkArchive(archivePath, func(header *tar.Header, tr *tar.Reader) error {
target := filepath.Join(destParent, filepath.Clean(header.Name))
switch header.Typeflag {
case tar.TypeDir:
return os.MkdirAll(target, os.FileMode(header.Mode))
case tar.TypeSymlink:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
return os.Symlink(header.Linkname, target)
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, tr)
return err
default:
return nil
}
})
}
func walkArchive(archivePath string, visit func(*tar.Header, *tar.Reader) error) error {
f, err := os.Open(archivePath)
if err != nil {
return err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return fmt.Errorf("not a gzip archive: %w", err)
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
header, err := tr.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
if err := visit(header, tr); err != nil {
return err
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ package blur
import (
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
client "github.com/AvengeMedia/dankgo/wayland/client"
client "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
const extBackgroundEffectInterface = "ext_background_effect_manager_v1"
+143 -8
View File
@@ -8,8 +8,7 @@ import (
"path/filepath"
"syscall"
"github.com/AvengeMedia/dankgo/wayland/ext_data_control"
"github.com/AvengeMedia/dankgo/wlclipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
)
const envServe = "_DMS_CLIPBOARD_SERVE"
@@ -17,6 +16,37 @@ 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() {
@@ -44,7 +74,7 @@ func MaybeServeAndExit() {
os.Exit(1)
}
if err := serveOffers(wlclipboard.ExpandOffers(data, mimeType), pasteOnce); err != nil {
if err := serveOffers(ExpandOffers(data, mimeType), pasteOnce); err != nil {
fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err)
os.Exit(1)
}
@@ -61,7 +91,7 @@ func CopyText(text string) error {
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
if foreground {
return serveOffers(wlclipboard.ExpandOffers(data, mimeType), pasteOnce)
return serveOffers(ExpandOffers(data, mimeType), pasteOnce)
}
return copyForkCached(data, mimeType, pasteOnce)
}
@@ -74,10 +104,10 @@ func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) err
if err != nil {
return fmt.Errorf("read source: %w", err)
}
return serveOffers(wlclipboard.ExpandOffers(buf, mimeType), pasteOnce)
return serveOffers(ExpandOffers(buf, mimeType), pasteOnce)
}
func CopyMulti(offers []wlclipboard.Offer, foreground, pasteOnce bool) error {
func CopyMulti(offers []Offer, foreground, pasteOnce bool) error {
if foreground {
return serveOffers(offers, pasteOnce)
}
@@ -176,7 +206,7 @@ func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
return nil
}
func copyMultiFork(offers []wlclipboard.Offer, pasteOnce bool) error {
func copyMultiFork(offers []Offer, pasteOnce bool) error {
args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"}
if pasteOnce {
args = append(args, "--paste-once")
@@ -238,7 +268,7 @@ func createClipboardCacheFile() (*os.File, error) {
// 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 []wlclipboard.Offer, pasteOnce bool) error {
func serveOffers(offers []Offer, pasteOnce bool) error {
if len(offers) == 0 {
return fmt.Errorf("no offers to serve")
}
@@ -318,6 +348,111 @@ func serveOffers(offers []wlclipboard.Offer, pasteOnce bool) error {
}
}
func Paste() ([]byte, string, error) {
s, err := connectSession()
if err != nil {
return nil, "", err
}
defer s.Close()
dataControlMgr, err := s.requireDataControl()
if err != nil {
return nil, "", err
}
device, err := dataControlMgr.GetDataDevice(s.seat)
if err != nil {
return nil, "", fmt.Errorf("get data device: %w", err)
}
defer device.Destroy()
offerMimeTypes := make(map[*ext_data_control.ExtDataControlOfferV1][]string)
device.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) {
if e.Id == nil {
return
}
offerMimeTypes[e.Id] = nil
e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) {
offerMimeTypes[e.Id] = append(offerMimeTypes[e.Id], me.MimeType)
})
})
var selectionOffer *ext_data_control.ExtDataControlOfferV1
gotSelection := false
device.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) {
selectionOffer = e.Id
gotSelection = true
})
s.display.Roundtrip()
s.display.Roundtrip()
if !gotSelection || selectionOffer == nil {
return nil, "", fmt.Errorf("no clipboard data")
}
selectedMime := selectPreferredMimeType(offerMimeTypes[selectionOffer])
if selectedMime == "" {
return nil, "", fmt.Errorf("no supported mime type")
}
r, w, err := os.Pipe()
if err != nil {
return nil, "", fmt.Errorf("create pipe: %w", err)
}
defer r.Close()
if err := selectionOffer.Receive(selectedMime, int(w.Fd())); err != nil {
w.Close()
return nil, "", fmt.Errorf("receive: %w", err)
}
w.Close()
s.display.Roundtrip()
data, err := io.ReadAll(r)
if err != nil {
return nil, "", fmt.Errorf("read: %w", err)
}
return data, selectedMime, nil
}
func PasteText() (string, error) {
data, _, err := Paste()
if err != nil {
return "", err
}
return string(data), nil
}
func selectPreferredMimeType(mimes []string) string {
preferred := []string{
"text/plain;charset=utf-8",
"text/plain",
"UTF8_STRING",
"STRING",
"TEXT",
"image/png",
"image/jpeg",
}
for _, pref := range preferred {
for _, mime := range mimes {
if mime == pref {
return mime
}
}
}
if len(mimes) > 0 {
return mimes[0]
}
return ""
}
func IsImageMimeType(mime string) bool {
return len(mime) > 6 && mime[:6] == "image/"
}
+124 -37
View File
@@ -2,26 +2,35 @@ package clipboard
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
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) (err error) {
func SendPasteKeystroke(withShift bool) error {
s, err := connectSession()
if err != nil {
return err
@@ -41,25 +50,25 @@ func SendPasteKeystroke(withShift bool) (err error) {
}
defer keyboard.Release()
var seatKeymap *wlclient.KeyboardKeymapEvent
var keymap *wlclient.KeyboardKeymapEvent
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
if seatKeymap == nil {
seatKeymap = &e
if keymap == nil {
keymap = &e
}
})
s.display.Roundtrip()
if seatKeymap == nil || seatKeymap.Format != keymap.FormatXkbV1 {
if keymap == nil || keymap.Format != xkbKeymapFormatV1 {
return fmt.Errorf("no xkb keymap from seat")
}
defer unix.Close(seatKeymap.Fd)
defer unix.Close(keymap.Fd)
keymapText, err := keymap.Read(seatKeymap.Fd, seatKeymap.Size)
keymapText, err := readKeymap(keymap.Fd, keymap.Size)
if err != nil {
return fmt.Errorf("read keymap: %w", err)
}
keys := keymap.Parse(keymapText)
keys := resolveKeycodes(keymapText)
vk, err := s.virtualKeyboardMgr.CreateVirtualKeyboard(s.seat)
if err != nil {
@@ -67,53 +76,131 @@ func SendPasteKeystroke(withShift bool) (err error) {
}
defer vk.Destroy()
if err := vk.Keymap(keymap.FormatXkbV1, seatKeymap.Fd, seatKeymap.Size); err != nil {
if err := vk.Keymap(xkbKeymapFormatV1, keymap.Fd, keymap.Size); err != nil {
return fmt.Errorf("set keymap: %w", err)
}
mods := uint32(ctrlModMask)
held := []uint32{keys.Keycode("Control_L")}
held := []uint32{keys.ctrl}
if withShift {
mods |= shiftModMask
held = append(held, keys.Keycode("Shift_L"))
held = append(held, keys.shift)
}
t := uint32(0)
var pressed []uint32
press := func(key uint32) error {
press := func(key, state uint32) error {
t++
if err := vk.Key(t, key, keyStatePressed); err != nil {
return err
}
pressed = append(pressed, key)
return nil
return vk.Key(t, key, state)
}
// all releases happen here so error paths can't leave keys held or
// modifiers latched for the compositor to interpret on destroy
defer func() {
for i := len(pressed) - 1; i >= 0; i-- {
t++
if e := vk.Key(t, pressed[i], keyStateReleased); e != nil && err == nil {
err = fmt.Errorf("key release: %w", e)
}
}
if e := vk.Modifiers(0, 0, 0, 0); e != nil && err == nil {
err = fmt.Errorf("clear modifiers: %w", e)
}
s.display.Roundtrip()
}()
for _, key := range held {
if err := press(key); err != nil {
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.Keycode("v")); err != nil {
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
}
+13 -11
View File
@@ -4,8 +4,7 @@ import (
"os"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
"golang.org/x/sys/unix"
)
@@ -33,20 +32,20 @@ func TestLiveSeatKeymapResolution(t *testing.T) {
}
defer keyboard.Release()
var seatKeymap *wlclient.KeyboardKeymapEvent
var keymap *wlclient.KeyboardKeymapEvent
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
if seatKeymap == nil {
seatKeymap = &e
if keymap == nil {
keymap = &e
}
})
s.display.Roundtrip()
if seatKeymap == nil {
if keymap == nil {
t.Fatal("no keymap event")
}
defer unix.Close(seatKeymap.Fd)
defer unix.Close(keymap.Fd)
text, err := keymap.Read(seatKeymap.Fd, seatKeymap.Size)
text, err := readKeymap(keymap.Fd, keymap.Size)
if err != nil {
t.Fatalf("read keymap: %v", err)
}
@@ -57,7 +56,10 @@ func TestLiveSeatKeymapResolution(t *testing.T) {
}
}
keys := keymap.Parse(text)
t.Logf("keymap size=%d resolved ctrl=%d shift=%d v=%d", seatKeymap.Size,
keys.Keycode("Control_L"), keys.Keycode("Shift_L"), keys.Keycode("v"))
keys := resolveKeycodes(text)
t.Logf("keymap size=%d resolved ctrl=%d shift=%d v=%d", keymap.Size, keys.ctrl, keys.shift, keys.v)
if keys.ctrl == fallbackCtrlKey && keys.shift == fallbackShiftKey && keys.v == fallbackVKey {
t.Log("all keycodes are fallbacks - parsing may not have matched the live keymap")
}
}
+105
View File
@@ -0,0 +1,105 @@
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)
}
}
+1 -9
View File
@@ -47,15 +47,7 @@ func Store(data []byte, mimeType string) error {
return StoreWithConfig(data, mimeType, DefaultStoreConfig())
}
func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) (err error) {
defer func() {
r := recover()
if r == nil {
return
}
err = fmt.Errorf("clipboard db panic: %v", r)
}()
func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error {
if len(data) == 0 {
return nil
}
+202 -38
View File
@@ -8,24 +8,191 @@ import (
"os"
"time"
"github.com/AvengeMedia/dankgo/wayland/ext_data_control"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
// WatchAll subscribes to clipboard selection changes like wlclipboard.Watch,
// additionally reporting every mime type each selection offers.
type ClipboardChange struct {
Data []byte
MimeType string
MimeTypes []string
}
func Watch(ctx context.Context, callback func(data []byte, mimeType string)) error {
display, err := wlclient.Connect("")
if err != nil {
return fmt.Errorf("wayland connect: %w", err)
}
defer display.Destroy()
wlCtx := 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(wlCtx)
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(wlCtx)
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()
offerMimeTypes := make(map[*ext_data_control.ExtDataControlOfferV1][]string)
device.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) {
if e.Id == nil {
return
}
offerMimeTypes[e.Id] = nil
e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) {
offerMimeTypes[e.Id] = append(offerMimeTypes[e.Id], me.MimeType)
})
})
device.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) {
if e.Id == nil {
return
}
mimes := offerMimeTypes[e.Id]
selectedMime := selectPreferredMimeType(mimes)
if selectedMime == "" {
return
}
r, w, err := os.Pipe()
if err != nil {
return
}
if err := e.Id.Receive(selectedMime, int(w.Fd())); err != nil {
w.Close()
r.Close()
return
}
w.Close()
go func() {
defer r.Close()
data, err := io.ReadAll(r)
if err != nil || len(data) == 0 {
return
}
callback(data, selectedMime)
}()
})
display.Roundtrip()
display.Roundtrip()
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
if err := wlCtx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
return fmt.Errorf("set read deadline: %w", err)
}
if err := wlCtx.Dispatch(); err != nil {
if isTimeoutError(err) {
continue
}
return fmt.Errorf("dispatch: %w", err)
}
}
}
}
func WatchAll(ctx context.Context, callback func(data []byte, mimeType string, allMimeTypes []string)) error {
s, err := connectSession()
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()
wlCtx := 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(wlCtx)
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(wlCtx)
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
bindErr = err
}
}
})
display.Roundtrip()
display.Roundtrip()
if bindErr != nil {
return fmt.Errorf("registry bind: %w", bindErr)
}
if dataControlMgr == nil {
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
}
defer dataControlMgr.Destroy()
if seat == nil {
return fmt.Errorf("no seat available")
}
device, err := dataControlMgr.GetDataDevice(s.seat)
device, err := dataControlMgr.GetDataDevice(seat)
if err != nil {
return fmt.Errorf("get data device: %w", err)
}
@@ -79,18 +246,18 @@ func WatchAll(ctx context.Context, callback func(data []byte, mimeType string, a
}()
})
s.display.Roundtrip()
s.display.Roundtrip()
display.Roundtrip()
display.Roundtrip()
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
if err := s.ctx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
if err := wlCtx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
return fmt.Errorf("set read deadline: %w", err)
}
if err := s.ctx.Dispatch(); err != nil {
if err := wlCtx.Dispatch(); err != nil {
if isTimeoutError(err) {
continue
}
@@ -100,31 +267,6 @@ func WatchAll(ctx context.Context, callback func(data []byte, mimeType string, a
}
}
func selectPreferredMimeType(mimes []string) string {
preferred := []string{
"text/plain;charset=utf-8",
"text/plain",
"UTF8_STRING",
"STRING",
"TEXT",
"image/png",
"image/jpeg",
}
for _, pref := range preferred {
for _, mime := range mimes {
if mime == pref {
return mime
}
}
}
if len(mimes) > 0 {
return mimes[0]
}
return ""
}
func isTimeoutError(err error) bool {
if err == nil {
return false
@@ -137,3 +279,25 @@ func isTimeoutError(err error) bool {
}
return false
}
func WatchChan(ctx context.Context) (<-chan ClipboardChange, <-chan error) {
ch := make(chan ClipboardChange, 16)
errCh := make(chan error, 1)
go func() {
defer close(ch)
err := Watch(ctx, func(data []byte, mimeType string) {
select {
case ch <- ClipboardChange{Data: data, MimeType: mimeType}:
default:
}
})
if err != nil && err != context.Canceled {
errCh <- err
}
close(errCh)
}()
time.Sleep(50 * time.Millisecond)
return ch, errCh
}
+2 -2
View File
@@ -3,9 +3,9 @@ package clipboard
import (
"fmt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/virtual_keyboard"
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
"github.com/AvengeMedia/dankgo/wayland/ext_data_control"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
type session struct {
+24 -99
View File
@@ -8,21 +8,17 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/keyboard_shortcuts_inhibit"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_layer_shell"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_color_management"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_viewporter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
"github.com/AvengeMedia/dankgo/wayland/client"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
type Config struct {
Format OutputFormat
CustomFormat string
Lowercase bool
Autocopy bool
Notify bool
AllowMultiple bool
Format OutputFormat
CustomFormat string
Lowercase bool
Autocopy bool
Notify bool
}
type Output struct {
@@ -38,19 +34,18 @@ type Output struct {
}
type LayerSurface struct {
output *Output
state *SurfaceState
wlSurface *client.Surface
layerSurf *wlr_layer_shell.ZwlrLayerSurfaceV1
viewport *wp_viewporter.WpViewport
wlPools [2]*client.ShmPool
wlBuffers [2]*client.Buffer
slotBusy [2]bool
needsRedraw bool
framePending bool
scopyBuffer *client.Buffer
configured bool
hidden bool
output *Output
state *SurfaceState
wlSurface *client.Surface
layerSurf *wlr_layer_shell.ZwlrLayerSurfaceV1
viewport *wp_viewporter.WpViewport
wlPools [2]*client.ShmPool
wlBuffers [2]*client.Buffer
slotBusy [2]bool
needsRedraw bool
scopyBuffer *client.Buffer
configured bool
hidden bool
}
type Picker struct {
@@ -65,13 +60,9 @@ type Picker struct {
seat *client.Seat
pointer *client.Pointer
keyboard *client.Keyboard
keymap *keymap.Keymap
layerShell *wlr_layer_shell.ZwlrLayerShellV1
screencopy *wlr_screencopy.ZwlrScreencopyManagerV1
viewporter *wp_viewporter.WpViewporter
colorMgr *wp_color_management.WpColorManagerV1
compositorVersion uint32
shortcutsInhibitMgr *keyboard_shortcuts_inhibit.ZwpKeyboardShortcutsInhibitManagerV1
shortcutsInhibitor *keyboard_shortcuts_inhibit.ZwpKeyboardShortcutsInhibitorV1
@@ -95,14 +86,6 @@ func New(config Config) *Picker {
}
func (p *Picker) Run() (*Color, error) {
if !p.config.AllowMultiple {
lock, err := utils.LockSelectionOverlay()
if err != nil {
return nil, err
}
defer lock.Release()
}
if err := p.connect(); err != nil {
return nil, fmt.Errorf("wayland connect: %w", err)
}
@@ -181,10 +164,9 @@ func (p *Picker) checkDone() {
}
}
// flushRedraws paints queued surfaces, at most once per compositor frame each.
func (p *Picker) flushRedraws() {
for _, ls := range p.surfaces {
if !ls.needsRedraw || ls.framePending {
if !ls.needsRedraw {
continue
}
p.redrawSurface(ls)
@@ -231,7 +213,6 @@ func (p *Picker) handleGlobal(e client.RegistryGlobalEvent) {
compositor := client.NewCompositor(p.ctx)
if err := p.registry.Bind(e.Name, e.Interface, e.Version, compositor); err == nil {
p.compositor = compositor
p.compositorVersion = e.Version
}
case client.ShmInterfaceName:
@@ -282,12 +263,6 @@ func (p *Picker) handleGlobal(e client.RegistryGlobalEvent) {
p.viewporter = viewporter
}
case wp_color_management.WpColorManagerV1InterfaceName:
mgr := wp_color_management.NewWpColorManagerV1(p.ctx)
if err := p.registry.Bind(e.Name, e.Interface, 1, mgr); err == nil {
p.colorMgr = mgr
}
case keyboard_shortcuts_inhibit.ZwpKeyboardShortcutsInhibitManagerV1InterfaceName:
mgr := keyboard_shortcuts_inhibit.NewZwpKeyboardShortcutsInhibitManagerV1(p.ctx)
if err := p.registry.Bind(e.Name, e.Interface, e.Version, mgr); err == nil {
@@ -346,38 +321,16 @@ func (p *Picker) createSurfaces() error {
p.outputsMu.Unlock()
for _, output := range outputs {
refLum, pq := p.outputPQEncoding(output)
ls, err := p.createLayerSurface(output)
if err != nil {
return fmt.Errorf("output %s: %w", output.name, err)
}
if pq {
ls.state.SetPQEncoding(refLum)
}
p.surfaces = append(p.surfaces, ls)
}
return nil
}
func (p *Picker) outputPQEncoding(output *Output) (float64, bool) {
if p.colorMgr == nil {
return 0, false
}
desc, ok := wp_color_management.QueryOutputDescription(p.colorMgr, output.wlOutput, p.roundtrip)
if !ok {
return 0, false
}
pq := desc.TF == uint32(wp_color_management.WpColorManagerV1TransferFunctionSt2084Pq) &&
desc.Primaries == uint32(wp_color_management.WpColorManagerV1PrimariesBt2020)
if !pq {
return 0, false
}
return float64(desc.ReferenceLum), true
}
func (p *Picker) createLayerSurface(output *Output) (*LayerSurface, error) {
surface, err := p.compositor.CreateSurface()
if err != nil {
@@ -564,18 +517,17 @@ func (p *Picker) captureForSurface(ls *LayerSurface) {
func (p *Picker) redrawSurface(ls *LayerSurface) {
slot := ls.state.FrontIndex()
if ls.slotBusy[slot] || ls.framePending {
if ls.slotBusy[slot] {
ls.needsRedraw = true
return
}
var renderBuf *ShmBuffer
var damage []rect
switch {
case ls.hidden:
renderBuf, damage = ls.state.RedrawScreenOnly()
renderBuf = ls.state.RedrawScreenOnly()
default:
renderBuf, damage = ls.state.Redraw()
renderBuf = ls.state.Redraw()
}
if renderBuf == nil {
return
@@ -622,25 +574,7 @@ func (p *Picker) redrawSurface(ls *LayerSurface) {
_ = ls.wlSurface.SetBufferScale(bufferScale)
}
_ = ls.wlSurface.Attach(ls.wlBuffers[slot], 0, 0)
switch {
case p.compositorVersion < 4:
_ = ls.wlSurface.Damage(0, 0, int32(logicalW), int32(logicalH))
default:
for _, r := range damage {
r = r.clip(renderBuf.Width, renderBuf.Height)
if r.w <= 0 || r.h <= 0 {
continue
}
_ = ls.wlSurface.DamageBuffer(int32(r.x), int32(r.y), int32(r.w), int32(r.h))
}
}
if cb, err := ls.wlSurface.Frame(); err == nil {
ls.framePending = true
cb.SetDoneHandler(func(client.CallbackDoneEvent) {
_ = cb.Destroy()
ls.framePending = false
})
}
_ = ls.wlSurface.Damage(0, 0, int32(logicalW), int32(logicalH))
_ = ls.wlSurface.Commit()
ls.state.SwapBuffers()
@@ -740,14 +674,9 @@ func (p *Picker) setupPointerHandlers() {
}
func (p *Picker) setupKeyboardHandlers() {
p.keyboard.SetKeymapHandler(func(e client.KeyboardKeymapEvent) {
p.keymap = keymap.FromEvent(e)
})
p.keyboard.SetKeyHandler(func(e client.KeyboardKeyEvent) {
sym := p.keymap.Keysym(e.Key)
for _, ls := range p.surfaces {
ls.state.OnKey(sym, e.State)
ls.state.OnKey(e.Key, e.State)
}
})
}
@@ -797,10 +726,6 @@ func (p *Picker) cleanup() {
p.viewporter.Destroy()
}
if p.colorMgr != nil {
p.colorMgr.Destroy()
}
if p.screencopy != nil {
p.screencopy.Destroy()
}
+39 -126
View File
@@ -25,8 +25,6 @@ type SurfaceState struct {
screenBuf *ShmBuffer
screenFormat PixelFormat
pqEncoded bool
refLum float64
yInverted bool
logicalW int
@@ -34,10 +32,6 @@ type SurfaceState struct {
renderBufs [2]*ShmBuffer
front int
primed [2]bool
drawn [2][]rect
shown []rect
fullDamage bool
scale int32
scaleX float64
@@ -62,13 +56,6 @@ func NewSurfaceState(format OutputFormat, lowercase bool) *SurfaceState {
}
}
func (s *SurfaceState) SetPQEncoding(refLum float64) {
s.mu.Lock()
defer s.mu.Unlock()
s.pqEncoded = true
s.refLum = refLum
}
func (s *SurfaceState) SetScale(scale int32) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -165,17 +152,6 @@ func (s *SurfaceState) OnScreencopyReady() {
}
}
// Magnifier rendering and pixel picking work on 8-bit pixels only
if s.screenFormat.Is10Bit() {
switch {
case s.pqEncoded:
s.screenBuf.ConvertPQ2020ToSRGB(s.refLum)
default:
s.screenBuf.Convert10To8()
}
s.screenFormat = s.screenBuf.Format
}
s.recomputeScale()
s.ensureRenderBuffers()
s.readyForDisplay = true
@@ -216,10 +192,6 @@ func (s *SurfaceState) ensureRenderBuffers() {
if s.screenBuf == nil {
return
}
s.primed = [2]bool{}
s.drawn = [2][]rect{}
s.shown = nil
s.fullDamage = true
width := s.screenBuf.Width
height := s.screenBuf.Height
@@ -266,7 +238,7 @@ func (s *SurfaceState) OnPointerButton(button, state uint32) {
}
}
func (s *SurfaceState) OnKey(sym string, state uint32) {
func (s *SurfaceState) OnKey(key, state uint32) {
if state != 1 {
return
}
@@ -274,10 +246,10 @@ func (s *SurfaceState) OnKey(sym string, state uint32) {
s.mu.Lock()
defer s.mu.Unlock()
switch sym {
case "Escape":
switch key {
case 1: // KEY_ESC
s.cancelled = true
case "Return", "KP_Enter":
case 28: // KEY_ENTER
if s.readyForDisplay && s.screenBuf != nil {
s.colorPicked = true
}
@@ -314,70 +286,21 @@ func (s *SurfaceState) SwapBuffers() {
s.mu.Unlock()
}
// rect is a buffer-pixel area, clipped on use.
type rect struct{ x, y, w, h int }
func (r rect) clip(w, h int) rect {
x1, y1 := max(r.x, 0), max(r.y, 0)
x2, y2 := min(r.x+r.w, w), min(r.y+r.h, h)
return rect{x1, y1, max(x2-x1, 0), max(y2-y1, 0)}
}
func magnifierRect(cx, cy int) rect {
const reach = magnifierRadius + 2
return rect{cx - reach, cy - reach, 2*reach + 1, 2*reach + 1}
}
// restore puts screen pixels back under the front buffer's previous overlay and
// records cur as its new footprint. It returns what differs from the frame on screen.
func (s *SurfaceState) restore(dst *ShmBuffer, cur []rect) []rect {
i := s.front
switch {
case !s.primed[i]:
dst.CopyFrom(s.screenBuf)
s.primed[i] = true
default:
for _, r := range s.drawn[i] {
s.copyRect(dst, r)
}
}
s.drawn[i] = cur
damage := append(append([]rect(nil), s.shown...), cur...)
if s.fullDamage {
s.fullDamage = false
damage = []rect{{0, 0, dst.Width, dst.Height}}
}
s.shown = cur
return damage
}
func (s *SurfaceState) copyRect(dst *ShmBuffer, r rect) {
r = r.clip(min(dst.Width, s.screenBuf.Width), min(dst.Height, s.screenBuf.Height))
if r.w <= 0 || r.h <= 0 {
return
}
src := s.screenBuf
for y := r.y; y < r.y+r.h; y++ {
copy(dst.Data()[y*dst.Stride+r.x*4:][:r.w*4], src.Data()[y*src.Stride+r.x*4:][:r.w*4])
}
}
// Redraw draws the magnifier and preview at the pointer into the front buffer,
// touching only the previous and new overlay areas; the rects are the damage.
func (s *SurfaceState) Redraw() (*ShmBuffer, []rect) {
func (s *SurfaceState) Redraw() *ShmBuffer {
s.mu.Lock()
defer s.mu.Unlock()
if !s.readyForDisplay || s.screenBuf == nil {
return nil, nil
return nil
}
dst := s.renderBufs[s.front]
if dst == nil {
return nil, nil
return nil
}
dst.CopyFrom(s.screenBuf)
px := int(math.Round(float64(s.pointerX) * s.scaleX))
py := int(math.Round(float64(s.pointerY) * s.scaleY))
@@ -390,8 +313,6 @@ func (s *SurfaceState) Redraw() (*ShmBuffer, []rect) {
}
picked := GetPixelColorWithFormat(s.screenBuf, px, sampleY, s.screenFormat)
text := formatColorForPreview(picked, s.displayFormat, s.lowercase)
damage := s.restore(dst, []rect{magnifierRect(px, py), previewRect(dst.Width, dst.Height, px, py, text)})
drawMagnifierWithInversion(
dst.Data(), dst.Stride, dst.Width, dst.Height,
@@ -401,25 +322,26 @@ func (s *SurfaceState) Redraw() (*ShmBuffer, []rect) {
drawColorPreview(dst.Data(), dst.Stride, dst.Width, dst.Height, px, py, picked, s.displayFormat, s.lowercase, s.screenFormat)
return dst, damage
return dst
}
// RedrawScreenOnly renders just the screenshot without any overlay (magnifier, preview).
// Used for when pointer leaves the surface.
func (s *SurfaceState) RedrawScreenOnly() (*ShmBuffer, []rect) {
func (s *SurfaceState) RedrawScreenOnly() *ShmBuffer {
s.mu.Lock()
defer s.mu.Unlock()
if !s.readyForDisplay || s.screenBuf == nil {
return nil, nil
return nil
}
dst := s.renderBufs[s.front]
if dst == nil {
return nil, nil
return nil
}
return dst, s.restore(dst, nil)
dst.CopyFrom(s.screenBuf)
return dst
}
func (s *SurfaceState) PickColor() (Color, bool) {
@@ -500,8 +422,6 @@ func blendColors(bg, fg Color, alpha float64) Color {
}
}
const magnifierRadius = 80
func drawMagnifierWithInversion(
dst []byte, dstStride, dstW, dstH int,
src []byte, srcStride, srcW, srcH int,
@@ -515,7 +435,7 @@ func drawMagnifierWithInversion(
}
const (
outerRadius = magnifierRadius
outerRadius = 80
borderThickness = 4
aaWidth = 1.5
zoom = 8.0
@@ -610,7 +530,7 @@ func drawMagnifierWithInversion(
}
srcOff := sy*srcStride + sx*4
if srcOff+4 <= len(src) {
finalColor = Color{R: src[srcOff+rOff], G: src[srcOff+1], B: src[srcOff+bOff]}
finalColor = Color{R: src[srcOff+rOff], G: src[srcOff+1], B: src[srcOff+bOff], A: 255}
} else {
continue
}
@@ -623,11 +543,12 @@ func drawMagnifierWithInversion(
}
}
drawMagnifierCrosshair(dst, dstStride, dstW, dstH, cx, cy, int(innerRadius), crossThickness, crossInnerRadius)
drawMagnifierCrosshair(dst, dstStride, dstW, dstH, cx, cy, int(innerRadius), crossThickness, crossInnerRadius, format)
}
func drawMagnifierCrosshair(
data []byte, stride, width, height, cx, cy, radius, thickness, innerRadius int,
format PixelFormat,
) {
if width <= 0 || height <= 0 {
return
@@ -1125,28 +1046,30 @@ var fontGlyphs = map[rune][fontH]uint8{
},
}
const (
previewPaddingX = 8
previewPaddingY = 4
previewSpace = 2
previewOffset = 88
)
// previewRect places the color label beside the magnifier, flipping to the left
// and clamping so it stays on the buffer.
func previewRect(width, height, cx, cy int, text string) rect {
func drawColorPreview(data []byte, stride, width, height int, cx, cy int, c Color, format OutputFormat, lowercase bool, pixelFormat PixelFormat) {
text := formatColorForPreview(c, format, lowercase)
if len(text) == 0 {
return rect{}
return
}
textW := len(text)*(fontW+previewSpace) - previewSpace
boxW := textW + previewPaddingX*2
boxH := fontH + previewPaddingY*2
x := cx + previewOffset
const (
paddingX = 8
paddingY = 4
space = 2
offset = 88
)
textW := len(text)*(fontW+space) - space
textH := fontH
boxW := textW + paddingX*2
boxH := textH + paddingY*2
x := cx + offset
y := cy - boxH/2
if x+boxW >= width {
x = cx - boxW - previewOffset
x = cx - boxW - offset
}
if x < 0 {
x = 0
@@ -1157,18 +1080,8 @@ func previewRect(width, height, cx, cy int, text string) rect {
if y+boxH >= height {
y = height - boxH
}
return rect{x, y, boxW, boxH}
}
func drawColorPreview(data []byte, stride, width, height int, cx, cy int, c Color, format OutputFormat, lowercase bool, pixelFormat PixelFormat) {
text := formatColorForPreview(c, format, lowercase)
if len(text) == 0 {
return
}
box := previewRect(width, height, cx, cy, text)
x, y := box.x, box.y
drawFilledRect(data, stride, width, height, x, y, box.w, box.h, c, pixelFormat)
drawFilledRect(data, stride, width, height, x, y, boxW, boxH, c, pixelFormat)
lum := 0.299*float64(c.R) + 0.587*float64(c.G) + 0.114*float64(c.B)
var fg Color
@@ -1177,7 +1090,7 @@ func drawColorPreview(data []byte, stride, width, height int, cx, cy int, c Colo
} else {
fg = Color{R: 255, G: 255, B: 255, A: 255}
}
drawText(data, stride, width, height, x+previewPaddingX, y+previewPaddingY, text, fg, pixelFormat)
drawText(data, stride, width, height, x+paddingX, y+paddingY, text, fg, pixelFormat)
}
func formatColorForPreview(c Color, format OutputFormat, lowercase bool) string {
+31 -113
View File
@@ -45,12 +45,14 @@ func TestSurfaceState_ConcurrentScaleAccess(t *testing.T) {
}
for range goroutines / 2 {
wg.Go(func() {
wg.Add(1)
go func() {
defer wg.Done()
for range iterations {
scale := s.Scale()
assert.GreaterOrEqual(t, scale, int32(1))
}
})
}()
}
wg.Wait()
@@ -74,13 +76,15 @@ func TestSurfaceState_ConcurrentLogicalSize(t *testing.T) {
}
for range goroutines / 2 {
wg.Go(func() {
wg.Add(1)
go func() {
defer wg.Done()
for range iterations {
w, h := s.LogicalSize()
_ = w
_ = h
}
})
}()
}
wg.Wait()
@@ -94,29 +98,35 @@ func TestSurfaceState_ConcurrentIsDone(t *testing.T) {
const iterations = 100
for range goroutines / 3 {
wg.Go(func() {
wg.Add(1)
go func() {
defer wg.Done()
for range iterations {
s.OnPointerButton(0x110, 1)
}
})
}()
}
for range goroutines / 3 {
wg.Go(func() {
wg.Add(1)
go func() {
defer wg.Done()
for range iterations {
s.OnKey("Escape", 1)
s.OnKey(1, 1)
}
})
}()
}
for range goroutines / 3 {
wg.Go(func() {
wg.Add(1)
go func() {
defer wg.Done()
for range iterations {
picked, cancelled := s.IsDone()
_ = picked
_ = cancelled
}
})
}()
}
wg.Wait()
@@ -130,11 +140,13 @@ func TestSurfaceState_ConcurrentIsReady(t *testing.T) {
const iterations = 100
for range goroutines {
wg.Go(func() {
wg.Add(1)
go func() {
defer wg.Done()
for range iterations {
_ = s.IsReady()
}
})
}()
}
wg.Wait()
@@ -148,11 +160,13 @@ func TestSurfaceState_ConcurrentSwapBuffers(t *testing.T) {
const iterations = 100
for range goroutines {
wg.Go(func() {
wg.Add(1)
go func() {
defer wg.Done()
for range iterations {
s.SwapBuffers()
}
})
}()
}
wg.Wait()
@@ -196,13 +210,13 @@ func TestSurfaceState_PickColorNilBuffer(t *testing.T) {
func TestSurfaceState_RedrawNilBuffer(t *testing.T) {
s := NewSurfaceState(FormatHex, false)
buf, _ := s.Redraw()
buf := s.Redraw()
assert.Nil(t, buf)
}
func TestSurfaceState_RedrawScreenOnlyNilBuffer(t *testing.T) {
s := NewSurfaceState(FormatHex, false)
buf, _ := s.RedrawScreenOnly()
buf := s.RedrawScreenOnly()
assert.Nil(t, buf)
}
@@ -298,99 +312,3 @@ func TestBlendColors(t *testing.T) {
result = blendColors(bg, fg, 2.0)
assert.Equal(t, fg.R, result.R)
}
func newReadyState(t *testing.T, w, h int) *SurfaceState {
t.Helper()
s := NewSurfaceState(FormatHex, false)
if err := s.OnScreencopyBuffer(FormatXRGB8888, w, h, w*4+16); err != nil {
t.Fatal(err)
}
t.Cleanup(s.Destroy)
data := s.ScreenBuffer().Data()
for i := range data {
data[i] = byte(i*31 + i/7)
}
if err := s.OnLayerConfigure(w, h); err != nil {
t.Fatal(err)
}
s.OnScreencopyReady()
return s
}
func TestSurfaceState_RedrawIncremental(t *testing.T) {
const w, h = 400, 300
s := newReadyState(t, w, h)
screen := s.ScreenBuffer()
reference := func(px, py int, overlay bool) []byte {
ref, err := CreateShmBuffer(w, h, screen.Stride)
if err != nil {
t.Fatal(err)
}
defer ref.Close()
ref.CopyFrom(screen)
if overlay {
c := GetPixelColorWithFormat(screen, px, py, s.screenFormat)
drawMagnifierWithInversion(ref.Data(), ref.Stride, w, h, screen.Data(), screen.Stride, w, h, px, py, c, false, s.screenFormat)
drawColorPreview(ref.Data(), ref.Stride, w, h, px, py, c, FormatHex, false, s.screenFormat)
}
return append([]byte(nil), ref.Data()...)
}
inDamage := func(x, y int, damage []rect) bool {
for _, r := range damage {
if x >= r.x && x < r.x+r.w && y >= r.y && y < r.y+r.h {
return true
}
}
return false
}
var onScreen []byte
positions := [][2]int{{10, 10}, {200, 150}, {205, 152}, {399, 299}, {0, 299}, {-1, -1}, {120, 40}, {125, 40}, {300, 290}}
for i, pos := range positions {
hidden := pos[0] < 0
var buf *ShmBuffer
var damage []rect
px, py := pos[0], pos[1]
switch {
case hidden:
buf, damage = s.RedrawScreenOnly()
default:
s.OnPointerMotion(float64(px), float64(py))
buf, damage = s.Redraw()
}
if buf == nil {
t.Fatalf("step %d: no buffer", i)
}
want := reference(px, py, !hidden)
if !bytesEqual(buf.Data(), want) {
t.Fatalf("step %d: incremental frame differs from full render", i)
}
if onScreen != nil {
for y := range h {
for x := range w {
off := y*buf.Stride + x*4
if bytesEqual(want[off:off+4], onScreen[off:off+4]) || inDamage(x, y, damage) {
continue
}
t.Fatalf("step %d: pixel %d,%d changed outside damage %v", i, x, y, damage)
}
}
}
onScreen = want
s.SwapBuffers()
}
}
func bytesEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+22 -44
View File
@@ -38,6 +38,25 @@ func (cd *ConfigDeployer) log(message string) {
}
}
// DeployConfigurations deploys all necessary configurations based on the chosen window manager
func (cd *ConfigDeployer) DeployConfigurations(ctx context.Context, wm deps.WindowManager) ([]DeploymentResult, error) {
return cd.DeployConfigurationsWithTerminal(ctx, wm, deps.TerminalGhostty)
}
// DeployConfigurationsWithTerminal deploys all necessary configurations based on chosen window manager and terminal
func (cd *ConfigDeployer) DeployConfigurationsWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]DeploymentResult, error) {
return cd.DeployConfigurationsSelective(ctx, wm, terminal, nil, nil)
}
// DeployConfigurationsWithSystemd deploys configurations with systemd option
func (cd *ConfigDeployer) DeployConfigurationsWithSystemd(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, useSystemd bool) ([]DeploymentResult, error) {
return cd.deployConfigurationsInternal(ctx, wm, terminal, nil, nil, nil, useSystemd)
}
func (cd *ConfigDeployer) DeployConfigurationsSelective(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool) ([]DeploymentResult, error) {
return cd.DeployConfigurationsSelectiveWithReinstalls(ctx, wm, terminal, installedDeps, replaceConfigs, nil)
}
func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstalls(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool) ([]DeploymentResult, error) {
return cd.deployConfigurationsInternal(ctx, wm, terminal, installedDeps, replaceConfigs, reinstallItems, true)
}
@@ -46,7 +65,7 @@ func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstallsAndSystemd(
return cd.deployConfigurationsInternal(ctx, wm, terminal, installedDeps, replaceConfigs, reinstallItems, useSystemd)
}
func (cd *ConfigDeployer) deployConfigurationsInternal(_ context.Context, wm deps.WindowManager, terminal deps.Terminal, _ []deps.Dependency, replaceConfigs map[string]bool, _ map[string]bool, useSystemd bool) ([]DeploymentResult, error) {
func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) {
var results []DeploymentResult
// Primary config file paths used to detect fresh installs.
@@ -155,32 +174,6 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(_ context.Context, wm dep
return results, nil
}
func (cd *ConfigDeployer) DeployCompositor(wm deps.WindowManager, terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) {
switch wm {
case deps.WindowManagerNiri:
return cd.deployNiriConfig(terminal, useSystemd)
case deps.WindowManagerHyprland:
return cd.deployHyprlandConfig(terminal, useSystemd)
case deps.WindowManagerMango:
return cd.deployMangoConfig(terminal, useSystemd)
default:
return DeploymentResult{}, fmt.Errorf("unsupported window manager")
}
}
func (cd *ConfigDeployer) DeployTerminal(terminal deps.Terminal) ([]DeploymentResult, error) {
switch terminal {
case deps.TerminalGhostty:
return cd.deployGhosttyConfig()
case deps.TerminalKitty:
return cd.deployKittyConfig()
case deps.TerminalAlacritty:
return cd.deployAlacrittyConfig()
default:
return nil, fmt.Errorf("unsupported terminal")
}
}
func (cd *ConfigDeployer) deployNiriConfig(terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) {
result := DeploymentResult{
ConfigType: "Niri",
@@ -271,7 +264,6 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
{"layout.kdl", NiriLayoutConfig},
{"alttab.kdl", NiriAlttabConfig},
{"binds.kdl", strings.ReplaceAll(NiriBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
{"input.kdl", NiriInputConfig},
{"outputs.kdl", ""},
{"cursor.kdl", ""},
{"windowrules.kdl", ""},
@@ -293,7 +285,7 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
return nil
}
func (cd *ConfigDeployer) deployMangoConfig(terminal deps.Terminal, _ bool) (DeploymentResult, error) {
func (cd *ConfigDeployer) deployMangoConfig(terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) {
result := DeploymentResult{
ConfigType: "Mango",
Path: filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf"),
@@ -579,13 +571,7 @@ func (cd *ConfigDeployer) deployAlacrittyConfig() ([]DeploymentResult, error) {
func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig, dmsDir string) (string, error) {
outputRegex := regexp.MustCompile(`(?m)^(/-)?\s*output\s+"[^"]+"\s*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
var existingOutputs []string
for _, output := range outputRegex.FindAllString(existingConfig, -1) {
if strings.Contains(NiriConfig, output) {
continue
}
existingOutputs = append(existingOutputs, output)
}
existingOutputs := outputRegex.FindAllString(existingConfig, -1)
if len(existingOutputs) == 0 {
return newConfig, nil
@@ -703,14 +689,6 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, result.Error
}
if useSystemd {
if targetPath, err := EnsureHyprlandSessionTarget(); err != nil {
cd.log(fmt.Sprintf("Warning: failed to write hyprland-session.target: %v", err))
} else {
cd.log(fmt.Sprintf("Ensured hyprland session target at %s", targetPath))
}
}
movedLegacy, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
if err != nil {
result.Error = fmt.Errorf("failed to back up legacy hyprlang configs: %w", err)
+6 -6
View File
@@ -212,12 +212,12 @@ binds {
Mod+Shift+Equal { set-window-height "+10%"; }
// === Screenshots ===
XF86Launch1 hotkey-overlay-title="Screenshot: Region" { spawn "dms" "screenshot"; }
Ctrl+XF86Launch1 hotkey-overlay-title="Screenshot: Full Screen" { spawn "dms" "screenshot" "full"; }
Alt+XF86Launch1 hotkey-overlay-title="Screenshot: Window" { spawn "dms" "screenshot" "window"; }
Print hotkey-overlay-title="Screenshot: Region" { spawn "dms" "screenshot"; }
Ctrl+Print hotkey-overlay-title="Screenshot: Full Screen" { spawn "dms" "screenshot" "full"; }
Alt+Print hotkey-overlay-title="Screenshot: Window" { spawn "dms" "screenshot" "window"; }
XF86Launch1 { screenshot; }
Ctrl+XF86Launch1 { screenshot-screen; }
Alt+XF86Launch1 { screenshot-window; }
Print { screenshot; }
Ctrl+Print { screenshot-screen; }
Alt+Print { screenshot-window; }
// === Display Profiles ===
Mod+P hotkey-overlay-title="Cycle Display Profile" {
spawn "dms" "ipc" "outputs" "cycleProfile";
@@ -1,15 +0,0 @@
// ! DO NOT EDIT !
// ! AUTO-GENERATED BY DMS !
// ! CHANGES WILL BE OVERWRITTEN !
// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
input {
mouse {
accel-speed 0.0
}
touchpad {
tap
accel-speed 0.0
natural-scroll
}
}
+25 -2
View File
@@ -34,6 +34,31 @@ input {
numlock
}
// Next sections include libinput settings.
// Omitting settings disables them, or leaves them at their default values.
// All commented-out settings here are examples, not defaults.
touchpad {
// off
tap
// dwt
// dwtp
// drag false
// drag-lock
natural-scroll
// accel-speed 0.2
// accel-profile "flat"
// scroll-method "two-finger"
// disabled-on-external-mouse
}
mouse {
// off
// natural-scroll
// accel-speed 0.2
// accel-profile "flat"
// scroll-method "no-scroll"
}
trackpoint {
// off
// natural-scroll
@@ -52,7 +77,6 @@ input {
// Setting max-scroll-amount="0%" makes it work only on windows already fully on screen.
// focus-follows-mouse max-scroll-amount="0%"
}
// You can configure outputs by their name, which you can find
// by running `niri msg outputs` while inside a niri instance.
// The built-in laptop monitor is usually called "eDP-1".
@@ -247,4 +271,3 @@ include optional=true "dms/alttab.kdl"
include optional=true "dms/binds.kdl"
include optional=true "dms/outputs.kdl"
include optional=true "dms/cursor.kdl"
include optional=true "dms/input.kdl"
@@ -1,48 +0,0 @@
package config
import (
"os"
"path/filepath"
)
const hyprlandSessionTargetUnit = `[Unit]
Description=Hyprland Session Target
BindsTo=graphical-session.target
Before=graphical-session.target
Wants=graphical-session-pre.target
After=graphical-session-pre.target
`
const legacyHyprlandSessionTargetUnit = `[Unit]
Description=Hyprland Session Target
Requires=graphical-session.target
After=graphical-session.target
`
// Only a missing or dankinstall-written unit is replaced; hand-written units are left alone.
func EnsureHyprlandSessionTarget() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
targetPath := filepath.Join(homeDir, ".config", "systemd", "user", "hyprland-session.target")
existing, err := os.ReadFile(targetPath)
if err == nil {
content := string(existing)
if content == hyprlandSessionTargetUnit {
return targetPath, nil
}
if content != legacyHyprlandSessionTargetUnit {
return targetPath, nil
}
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return "", err
}
if err := os.WriteFile(targetPath, []byte(hyprlandSessionTargetUnit), 0o644); err != nil {
return "", err
}
return targetPath, nil
}
@@ -1,73 +0,0 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func targetPathFor(t *testing.T, home string) string {
t.Helper()
return filepath.Join(home, ".config", "systemd", "user", "hyprland-session.target")
}
func TestEnsureHyprlandSessionTargetWritesMissing(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
path, err := EnsureHyprlandSessionTarget()
if err != nil {
t.Fatal(err)
}
if path != targetPathFor(t, home) {
t.Fatalf("unexpected path %s", path)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(data) != hyprlandSessionTargetUnit {
t.Fatalf("unexpected content:\n%s", data)
}
}
func TestEnsureHyprlandSessionTargetUpgradesLegacy(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
path := targetPathFor(t, home)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(legacyHyprlandSessionTargetUnit), 0o644); err != nil {
t.Fatal(err)
}
if _, err := EnsureHyprlandSessionTarget(); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(path)
if string(data) != hyprlandSessionTargetUnit {
t.Fatalf("legacy unit not upgraded:\n%s", data)
}
}
func TestEnsureHyprlandSessionTargetKeepsCustom(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
path := targetPathFor(t, home)
custom := "[Unit]\nDescription=mine\n"
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(custom), 0o644); err != nil {
t.Fatal(err)
}
if _, err := EnsureHyprlandSessionTarget(); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(path)
if string(data) != custom {
t.Fatalf("custom unit was overwritten:\n%s", data)
}
}
-3
View File
@@ -16,6 +16,3 @@ var NiriAlttabConfig string
//go:embed embedded/niri-binds.kdl
var NiriBindsConfig string
//go:embed embedded/niri-input.kdl
var NiriInputConfig string
+32 -110
View File
@@ -3,7 +3,6 @@ package dank16
import (
"fmt"
"math"
"strconv"
"github.com/lucasb-eyer/go-colorful"
)
@@ -12,38 +11,27 @@ type RGB struct {
R, G, B float64
}
type HSL struct {
H, S, L float64
}
type HSV struct {
H, S, V float64
}
type ColorInfo struct {
Hex string `json:"hex"`
HexStripped string `json:"hex_stripped"`
HexAlpha string `json:"hex_alpha"`
HexAlphaStripped string `json:"hex_alpha_stripped"`
AlphaHex string `json:"alpha_hex"`
AlphaHexStripped string `json:"alpha_hex_stripped"`
RGB string `json:"rgb"`
RGBA string `json:"rgba"`
HSL string `json:"hsl"`
HSLA string `json:"hsla"`
R string `json:"red"`
G string `json:"green"`
B string `json:"blue"`
A string `json:"alpha"`
H string `json:"hue"`
S string `json:"saturation"`
L string `json:"lightness"`
Hex string `json:"hex"`
HexStripped string `json:"hex_stripped"`
R int `json:"r"`
G int `json:"g"`
B int `json:"b"`
}
type VariantColorValue struct {
Hex string `json:"hex"`
HexStripped string `json:"hex_stripped"`
}
type VariantColorInfo struct {
Dark ColorInfo `json:"dark"`
Light ColorInfo `json:"light"`
Default ColorInfo `json:"default"`
Dark VariantColorValue `json:"dark"`
Light VariantColorValue `json:"light"`
Default VariantColorValue `json:"default"`
}
type Palette struct {
@@ -86,41 +74,16 @@ type VariantPalette struct {
func NewColorInfo(hex string) ColorInfo {
rgb := HexToRGB(hex)
hsl := RGBToHSL(rgb)
var r, g, b, a, h, s, l string
r = strconv.FormatInt(int64(math.Round(rgb.R*255)), 10)
g = strconv.FormatInt(int64(math.Round(rgb.G*255)), 10)
b = strconv.FormatInt(int64(math.Round(rgb.B*255)), 10)
a = "1.0"
h = strconv.FormatFloat(math.Round(hsl.H*3600)/10, 'f', 1, 64)
s = fmt.Sprintf("%.1f%%", math.Round(hsl.S*1000.0)/10.0)
l = fmt.Sprintf("%.1f%%", math.Round(hsl.L*1000.0)/10.0)
stripped := hex
if len(hex) > 0 && hex[0] == '#' {
stripped = hex[1:]
}
return ColorInfo{
Hex: hex,
HexStripped: stripped,
HexAlpha: hex + "ff",
HexAlphaStripped: stripped + "ff",
AlphaHex: "#ff" + stripped,
AlphaHexStripped: "ff" + stripped,
RGB: fmt.Sprintf("rgb(%s, %s, %s)", r, g, b),
RGBA: fmt.Sprintf("rgba(%s, %s, %s, %s)", r, g, b, a),
HSL: fmt.Sprintf("hsl(%s, %s, %s)", h, s, l),
HSLA: fmt.Sprintf("hsla(%s, %s, %s, %s)", h, s, l, a),
R: r,
G: g,
B: b,
A: a,
H: h,
S: s,
L: l,
Hex: hex,
HexStripped: stripped,
R: int(math.Round(rgb.R * 255)),
G: int(math.Round(rgb.G * 255)),
B: int(math.Round(rgb.B * 255)),
}
}
@@ -175,35 +138,6 @@ func RGBToHSV(rgb RGB) HSV {
return HSV{H: h, S: s, V: max}
}
func RGBToHSL(rgb RGB) HSL {
max := math.Max(math.Max(rgb.R, rgb.G), rgb.B)
min := math.Min(math.Min(rgb.R, rgb.G), rgb.B)
delta := max - min
var h, s, l float64
l = (max + min) / 2
if delta == 0 {
return HSL{H: 0, S: 0, L: l}
}
switch max {
case rgb.R:
h = math.Mod((rgb.G-rgb.B)/delta, 6.0) / 6.0
case rgb.G:
h = ((rgb.B-rgb.R)/delta + 2.0) / 6.0
default:
h = ((rgb.R-rgb.G)/delta + 4.0) / 6.0
}
if h < 0 {
h += 1.0
}
s = delta / (1 - math.Abs((2*l)-1))
return HSL{H: h, S: s, L: l}
}
func HSVToRGB(hsv HSV) RGB {
h := hsv.H * 6.0
c := hsv.V * hsv.S
@@ -379,7 +313,6 @@ func EnsureContrastDPSLstar(hexColor, hexBg string, minLc float64, isLightMode b
fg := HexToRGB(hexColor)
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
Lf, af, bf := cf.Lab()
Lf *= 100.0
dir := 1.0
if isLightMode {
@@ -408,7 +341,6 @@ func EnsureContrastDPSBidirectional(hexColor, hexBg string, minLc float64, isLig
fg := HexToRGB(hexColor)
cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B}
origL, af, bf := cf.Lab()
origL *= 100.0
var darkerResult, lighterResult string
darkerL, lighterL := origL, origL
@@ -488,24 +420,6 @@ func blendHue(base, target, factor float64) float64 {
return result
}
// color8 sits a fixed L* offset from the background so it keeps its dim role
// regardless of primary brightness (conventional palettes put ANSI bright
// black ~2-2.5:1 from the background, e.g. catppuccin-mocha #585b70 on #1e1e2e)
func DeriveDim(bgHex string, hue, sat float64, isLight bool) string {
offset := 22.0
if isLight {
offset = -offset
}
bgL := getLstar(bgHex)
targetL := math.Max(0, math.Min(100, bgL+offset))
tint := HSVToRGB(HSV{H: hue, S: sat, V: 0.5})
c := colorful.Color{R: tint.R, G: tint.G, B: tint.B}
_, af, bf := c.Lab()
return labToHex(targetL, af, bf)
}
func DeriveContainer(primary string, isLight bool) string {
rgb := HexToRGB(primary)
hsv := RGBToHSV(rgb)
@@ -586,7 +500,10 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
gray7V := baseVal * 0.28
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
palette.Color8 = NewColorInfo(DeriveDim(bgColor, hsv.H, baseSat*0.05, true))
gray8S := baseSat * 0.05
gray8V := baseVal * 0.85
dimTarget := secondaryTarget * 0.5
palette.Color8 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, dimTarget, opts))
brightRedS := math.Min(baseSat*1.0, 1.0)
brightRedV := math.Min(baseVal*1.2, 1.0)
@@ -642,7 +559,9 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
gray7V := math.Min(baseVal*1.05, 1.0)
palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts))
palette.Color8 = NewColorInfo(DeriveDim(bgColor, hsv.H, baseSat*0.15, false))
gray8S := baseSat * 0.15
gray8V := baseVal * 0.65
palette.Color8 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, secondaryTarget, opts))
brightRedS := math.Min(baseSat*0.75, 1.0)
brightRedV := math.Min(baseVal*1.35, 1.0)
@@ -689,14 +608,17 @@ type VariantOptions struct {
}
func mergeColorInfo(dark, light ColorInfo, isLightMode bool) VariantColorInfo {
defaultVal := dark
darkVal := VariantColorValue{Hex: dark.Hex, HexStripped: dark.HexStripped}
lightVal := VariantColorValue{Hex: light.Hex, HexStripped: light.HexStripped}
defaultVal := darkVal
if isLightMode {
defaultVal = light
defaultVal = lightVal
}
return VariantColorInfo{
Dark: dark,
Light: light,
Dark: darkVal,
Light: lightVal,
Default: defaultVal,
}
}
+19 -202
View File
@@ -1,8 +1,6 @@
package dank16
import (
"bytes"
"encoding/json"
"math"
"testing"
)
@@ -93,54 +91,6 @@ func TestRGBToHex(t *testing.T) {
}
}
func TestRGBToHSL(t *testing.T) {
tests := []struct {
name string
input RGB
expected HSL
}{
{
name: "black",
input: RGB{R: 0.0, G: 0.0, B: 0.0},
expected: HSL{H: 0.0, S: 0.0, L: 0.0},
},
{
name: "white",
input: RGB{R: 1.0, G: 1.0, B: 1.0},
expected: HSL{H: 0.0, S: 0.0, L: 1.0},
},
{
name: "red",
input: RGB{R: 1.0, G: 0.0, B: 0.0},
expected: HSL{H: 0.0, S: 1.0, L: 0.5},
},
{
name: "green",
input: RGB{R: 0.0, G: 1.0, B: 0.0},
expected: HSL{H: 120.0 / 360.0, S: 1.0, L: 0.5},
},
{
name: "blue",
input: RGB{R: 0.0, G: 0.0, B: 1.0},
expected: HSL{H: 240.0 / 360.0, S: 1.0, L: 0.5},
},
{
name: "magenta",
input: RGB{R: 1.0, G: 0.0, B: 1.0},
expected: HSL{H: 300 / 360.0, S: 1.0, L: 0.5},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := RGBToHSL(tt.input)
if !floatEqual(result.H, tt.expected.H) || !floatEqual(result.S, tt.expected.S) || !floatEqual(result.L, tt.expected.L) {
t.Errorf("RGBToHSL(%v) = %v, expected %v", tt.input, result, tt.expected)
}
})
}
}
func TestRGBToHSV(t *testing.T) {
tests := []struct {
name string
@@ -359,41 +309,31 @@ func TestEnsureContrast(t *testing.T) {
func TestGeneratePalette(t *testing.T) {
tests := []struct {
name string
base string
backgroundHex string
backgroundHSL string
opts PaletteOptions
name string
base string
opts PaletteOptions
}{
{
name: "dark theme default",
base: "#625690",
backgroundHex: "#1a1a1a",
backgroundHSL: "hsl(0.0, 0.0%, 10.2%)",
opts: PaletteOptions{IsLight: false},
name: "dark theme default",
base: "#625690",
opts: PaletteOptions{IsLight: false},
},
{
name: "light theme default",
base: "#625690",
backgroundHex: "#f8f8f8",
backgroundHSL: "hsl(0.0, 0.0%, 97.3%)",
opts: PaletteOptions{IsLight: true},
name: "light theme default",
base: "#625690",
opts: PaletteOptions{IsLight: true},
},
{
name: "light theme with custom background",
base: "#625690",
backgroundHex: "#fafafa",
backgroundHSL: "hsl(0.0, 0.0%, 98.0%)",
name: "light theme with custom background",
base: "#625690",
opts: PaletteOptions{
IsLight: true,
Background: "#fafafa",
},
},
{
name: "dark theme with custom background",
base: "#625690",
backgroundHex: "#0a0a0a",
backgroundHSL: "hsl(0.0, 0.0%, 3.9%)",
name: "dark theme with custom background",
base: "#625690",
opts: PaletteOptions{
IsLight: false,
Background: "#0a0a0a",
@@ -418,12 +358,12 @@ func TestGeneratePalette(t *testing.T) {
}
}
if result.Color0.Hex != tt.backgroundHex {
t.Errorf("Background color hex = %s, expected %s", result.Color0.Hex, tt.backgroundHex)
}
if result.Color0.HSL != tt.backgroundHSL {
t.Errorf("Background color hsl = %s, expected %s", result.Color0.HSL, tt.backgroundHSL)
if tt.opts.Background != "" && result.Color0.Hex != tt.opts.Background {
t.Errorf("Background color = %s, expected %s", result.Color0.Hex, tt.opts.Background)
} else if !tt.opts.IsLight && tt.opts.Background == "" && result.Color0.Hex != "#1a1a1a" {
t.Errorf("Dark mode background = %s, expected #1a1a1a", result.Color0.Hex)
} else if tt.opts.IsLight && tt.opts.Background == "" && result.Color0.Hex != "#f8f8f8" {
t.Errorf("Light mode background = %s, expected #f8f8f8", result.Color0.Hex)
}
// Color15 is now derived from primary, so just verify it's a valid color
@@ -444,59 +384,6 @@ func TestGeneratePalette(t *testing.T) {
}
}
func TestFormatOutput(t *testing.T) {
tests := []struct {
in string
out []byte
}{
{
in: "#000000",
out: []byte(`{"hex":"#000000","hex_stripped":"000000","hex_alpha":"#000000ff","hex_alpha_stripped":"000000ff","alpha_hex":"#ff000000","alpha_hex_stripped":"ff000000","rgb":"rgb(0, 0, 0)","rgba":"rgba(0, 0, 0, 1.0)","hsl":"hsl(0.0, 0.0%, 0.0%)","hsla":"hsla(0.0, 0.0%, 0.0%, 1.0)","red":"0","green":"0","blue":"0","alpha":"1.0","hue":"0.0","saturation":"0.0%","lightness":"0.0%"}`),
},
{
in: "#ffffff",
out: []byte(`{"hex":"#ffffff","hex_stripped":"ffffff","hex_alpha":"#ffffffff","hex_alpha_stripped":"ffffffff","alpha_hex":"#ffffffff","alpha_hex_stripped":"ffffffff","rgb":"rgb(255, 255, 255)","rgba":"rgba(255, 255, 255, 1.0)","hsl":"hsl(0.0, 0.0%, 100.0%)","hsla":"hsla(0.0, 0.0%, 100.0%, 1.0)","red":"255","green":"255","blue":"255","alpha":"1.0","hue":"0.0","saturation":"0.0%","lightness":"100.0%"}`),
},
{
in: "#ff0000",
out: []byte(`{"hex":"#ff0000","hex_stripped":"ff0000","hex_alpha":"#ff0000ff","hex_alpha_stripped":"ff0000ff","alpha_hex":"#ffff0000","alpha_hex_stripped":"ffff0000","rgb":"rgb(255, 0, 0)","rgba":"rgba(255, 0, 0, 1.0)","hsl":"hsl(0.0, 100.0%, 50.0%)","hsla":"hsla(0.0, 100.0%, 50.0%, 1.0)","red":"255","green":"0","blue":"0","alpha":"1.0","hue":"0.0","saturation":"100.0%","lightness":"50.0%"}`),
},
{
in: "#00ff00",
out: []byte(`{"hex":"#00ff00","hex_stripped":"00ff00","hex_alpha":"#00ff00ff","hex_alpha_stripped":"00ff00ff","alpha_hex":"#ff00ff00","alpha_hex_stripped":"ff00ff00","rgb":"rgb(0, 255, 0)","rgba":"rgba(0, 255, 0, 1.0)","hsl":"hsl(120.0, 100.0%, 50.0%)","hsla":"hsla(120.0, 100.0%, 50.0%, 1.0)","red":"0","green":"255","blue":"0","alpha":"1.0","hue":"120.0","saturation":"100.0%","lightness":"50.0%"}`),
},
{
in: "#0000ff",
out: []byte(`{"hex":"#0000ff","hex_stripped":"0000ff","hex_alpha":"#0000ffff","hex_alpha_stripped":"0000ffff","alpha_hex":"#ff0000ff","alpha_hex_stripped":"ff0000ff","rgb":"rgb(0, 0, 255)","rgba":"rgba(0, 0, 255, 1.0)","hsl":"hsl(240.0, 100.0%, 50.0%)","hsla":"hsla(240.0, 100.0%, 50.0%, 1.0)","red":"0","green":"0","blue":"255","alpha":"1.0","hue":"240.0","saturation":"100.0%","lightness":"50.0%"}`),
},
{
in: "#625690",
out: []byte(`{"hex":"#625690","hex_stripped":"625690","hex_alpha":"#625690ff","hex_alpha_stripped":"625690ff","alpha_hex":"#ff625690","alpha_hex_stripped":"ff625690","rgb":"rgb(98, 86, 144)","rgba":"rgba(98, 86, 144, 1.0)","hsl":"hsl(252.4, 25.2%, 45.1%)","hsla":"hsla(252.4, 25.2%, 45.1%, 1.0)","red":"98","green":"86","blue":"144","alpha":"1.0","hue":"252.4","saturation":"25.2%","lightness":"45.1%"}`),
},
{
in: "#ff00ff",
out: []byte(`{"hex":"#ff00ff","hex_stripped":"ff00ff","hex_alpha":"#ff00ffff","hex_alpha_stripped":"ff00ffff","alpha_hex":"#ffff00ff","alpha_hex_stripped":"ffff00ff","rgb":"rgb(255, 0, 255)","rgba":"rgba(255, 0, 255, 1.0)","hsl":"hsl(300.0, 100.0%, 50.0%)","hsla":"hsla(300.0, 100.0%, 50.0%, 1.0)","red":"255","green":"0","blue":"255","alpha":"1.0","hue":"300.0","saturation":"100.0%","lightness":"50.0%"}`),
},
{
in: "#888888",
out: []byte(`{"hex":"#888888","hex_stripped":"888888","hex_alpha":"#888888ff","hex_alpha_stripped":"888888ff","alpha_hex":"#ff888888","alpha_hex_stripped":"ff888888","rgb":"rgb(136, 136, 136)","rgba":"rgba(136, 136, 136, 1.0)","hsl":"hsl(0.0, 0.0%, 53.3%)","hsla":"hsla(0.0, 0.0%, 53.3%, 1.0)","red":"136","green":"136","blue":"136","alpha":"1.0","hue":"0.0","saturation":"0.0%","lightness":"53.3%"}`),
},
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
ci := NewColorInfo(tt.in)
out, err := json.Marshal(ci)
if err != nil {
t.Error("Failed encoding", err)
}
if !bytes.Equal(out, tt.out) {
t.Errorf("Color output mismatch, got = %s, expected %s", out, tt.out)
}
})
}
}
func TestRoundTripConversion(t *testing.T) {
testColors := []string{"#000000", "#ffffff", "#ff0000", "#00ff00", "#0000ff", "#625690", "#808080"}
@@ -792,73 +679,3 @@ func TestContrastAlgorithmComparison(t *testing.T) {
t.Logf("WCAG and DPS palettes differ in %d/16 colors", differentCount)
}
func TestEnsureContrastDPSLightModeStaysLight(t *testing.T) {
tests := []struct {
name string
result string
bg string
minLstar float64
}{
{
name: "bidirectional adjustment",
result: EnsureContrastDPSBidirectional("#d0ccc6", "#f8f8f8", 17.5, true),
bg: "#f8f8f8",
minLstar: 20.0,
},
{
name: "lstar adjustment",
result: EnsureContrastDPSLstar("#c0c0c0", "#f8f8f8", 30.0, true),
bg: "#f8f8f8",
minLstar: 20.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lstar := getLstar(tt.result)
if lstar < tt.minLstar {
t.Errorf("result %s has L* %.2f on light bg %s, expected >= %.2f (collapsed to near-black)",
tt.result, lstar, tt.bg, tt.minLstar)
}
})
}
}
func TestGeneratePaletteColor8Dim(t *testing.T) {
hues := []string{"#e91e63", "#f59e0b", "#22c55e", "#06b6d4", "#8b5cf6", "#ef4444"}
for _, base := range hues {
t.Run(base, func(t *testing.T) {
palette := GeneratePalette(base, PaletteOptions{IsLight: false, UseDPS: true})
bgRatio := ContrastRatio(palette.Color8.Hex, palette.Color0.Hex)
if bgRatio < 1.5 || bgRatio > 3.0 {
t.Errorf("Color8 %s vs bg %s ratio %.2f, expected 1.5-3.0 (bright black stays near bg)",
palette.Color8.Hex, palette.Color0.Hex, bgRatio)
}
sepRatio := ContrastRatio(palette.Color4.Hex, palette.Color8.Hex)
if sepRatio < 2.0 {
t.Errorf("Color4 %s vs Color8 %s ratio %.2f, expected >= 2.0 (blue must not collide with bright black)",
palette.Color4.Hex, palette.Color8.Hex, sepRatio)
}
})
}
}
func TestGeneratePaletteLightColor8StaysLight(t *testing.T) {
palette := GeneratePalette("#f59e0b", PaletteOptions{IsLight: true, UseDPS: true})
lstar := getLstar(palette.Color8.Hex)
if lstar < 60.0 {
t.Errorf("light mode Color8 %s has L* %.2f, expected >= 60 (dim grey, not near-black)",
palette.Color8.Hex, lstar)
}
bgRatio := ContrastRatio(palette.Color8.Hex, palette.Color0.Hex)
if bgRatio > 3.0 {
t.Errorf("light mode Color8 %s vs bg %s ratio %.2f, expected <= 3.0",
palette.Color8.Hex, palette.Color0.Hex, bgRatio)
}
}
-16
View File
@@ -302,22 +302,6 @@ func AppsForMime(mimeType string) []string {
return out
}
// HandlersForMime lists launchable handler entries for a mime type,
// excluding Terminal=true and NoDisplay=true desktop entries so pickers
// only offer graphical applications.
func HandlersForMime(mimeType string) []*Entry {
ids := AppsForMime(mimeType)
out := make([]*Entry, 0, len(ids))
for _, id := range ids {
entry := EntryByID(id)
if entry == nil || entry.Hidden || entry.NoDisplay || entry.Terminal {
continue
}
out = append(out, entry)
}
return out
}
func QueryDefaults(mimeTypes []string) map[string]string {
out := make(map[string]string, len(mimeTypes))
for _, m := range mimeTypes {
+37 -173
View File
@@ -4,7 +4,6 @@ import (
"bufio"
"bytes"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
@@ -82,10 +81,7 @@ func readAssociations(path string) (*MimeAssociations, error) {
if err != nil {
return nil, err
}
return associationsFromData(data), nil
}
func associationsFromData(data []byte) *MimeAssociations {
groups := parseGroups(data)
assoc := newAssociations()
@@ -110,7 +106,7 @@ func associationsFromData(data []byte) *MimeAssociations {
}
}
return assoc
return assoc, nil
}
func mergedAssociations() *MimeAssociations {
@@ -148,22 +144,40 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
path := mimeappsWritePath()
original, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return err
assoc, err := readAssociations(path)
if err != nil {
if !os.IsNotExist(err) {
return err
}
assoc = newAssociations()
}
assoc := associationsFromData(original)
update(assoc)
rendered, err := renderMimeapps(original, assoc)
if err != nil {
return err
var buf bytes.Buffer
w := bufio.NewWriter(&buf)
var writeErr error
writeSection := func(name string, entries map[string]string) {
fmt.Fprintf(w, "[%s]\n", name)
keys := make([]string, 0, len(entries))
for k := range entries {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
v := entries[k]
if !isSafeIniField(k) || !isSafeIniField(v) {
if writeErr == nil {
writeErr = fmt.Errorf("invalid mimeapps.list field %q=%q", k, v)
}
continue
}
fmt.Fprintf(w, "%s=%s\n", k, v)
}
fmt.Fprintln(w)
}
return atomicWriteFile(path, rendered)
}
func managedGroups(assoc *MimeAssociations) (map[string]map[string]string, error) {
flatten := func(m map[string][]string) map[string]string {
out := make(map[string]string, len(m))
for k, list := range m {
@@ -172,172 +186,22 @@ func managedGroups(assoc *MimeAssociations) (map[string]map[string]string, error
return out
}
groups := map[string]map[string]string{
groupDefaults: maps.Clone(assoc.Defaults),
groupAdded: flatten(assoc.Added),
groupRemoved: flatten(assoc.Removed),
}
for name, entries := range groups {
for k, v := range entries {
if isSafeIniField(k) && isSafeIniField(v) {
continue
}
return nil, fmt.Errorf("invalid mimeapps.list field in [%s]: %q=%q", name, k, v)
}
}
return groups, nil
}
writeSection(groupDefaults, assoc.Defaults)
writeSection(groupAdded, flatten(assoc.Added))
writeSection(groupRemoved, flatten(assoc.Removed))
// renderMimeapps rewrites only the three association groups defined by the
// mime-apps spec (https://specifications.freedesktop.org/mime-apps-spec/),
// keeping comments, blank lines, and unrelated groups verbatim.
func renderMimeapps(original []byte, assoc *MimeAssociations) ([]byte, error) {
managed, err := managedGroups(assoc)
if err != nil {
return nil, err
if writeErr != nil {
return writeErr
}
written := map[string]map[string]bool{
groupDefaults: {},
groupAdded: {},
groupRemoved: {},
}
var out []string
flushMissing := func(group string) {
entries, ok := managed[group]
if !ok {
return
}
keys := make([]string, 0, len(entries))
for k := range entries {
if !written[group][k] {
keys = append(keys, k)
}
}
if len(keys) == 0 {
return
}
sort.Strings(keys)
insertAt := len(out)
for insertAt > 0 && strings.TrimSpace(out[insertAt-1]) == "" {
insertAt--
}
lines := make([]string, 0, len(keys))
for _, k := range keys {
written[group][k] = true
lines = append(lines, k+"="+entries[k])
}
tail := append([]string(nil), out[insertAt:]...)
out = append(out[:insertAt], append(lines, tail...)...)
}
current := ""
scanner := bufio.NewScanner(bytes.NewReader(original))
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
flushMissing(current)
current = trimmed[1 : len(trimmed)-1]
out = append(out, line)
continue
}
entries, isManaged := managed[current]
if !isManaged {
out = append(out, line)
continue
}
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
out = append(out, line)
continue
}
eq := strings.IndexByte(trimmed, '=')
if eq <= 0 {
out = append(out, line)
continue
}
key := strings.TrimSpace(trimmed[:eq])
val, keep := entries[key]
if !keep || written[current][key] {
continue
}
written[current][key] = true
out = append(out, key+"="+val)
}
if err := scanner.Err(); err != nil {
return nil, err
}
flushMissing(current)
for _, group := range []string{groupDefaults, groupAdded, groupRemoved} {
entries := managed[group]
pending := make([]string, 0, len(entries))
for k := range entries {
if !written[group][k] {
pending = append(pending, k)
}
}
if len(pending) == 0 {
continue
}
sort.Strings(pending)
if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" {
out = append(out, "")
}
out = append(out, "["+group+"]")
for _, k := range pending {
written[group][k] = true
out = append(out, k+"="+entries[k])
}
}
if len(out) == 0 {
return nil, nil
}
return []byte(strings.Join(out, "\n") + "\n"), nil
}
func atomicWriteFile(path string, data []byte) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
if err := w.Flush(); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".mimeapps-*.tmp")
if err != nil {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmpPath := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := tmp.Chmod(0o644); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return err
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return err
}
return nil
return os.WriteFile(path, buf.Bytes(), 0o644)
}
func setDefaultAssociation(mimeType, desktopID string) error {
-210
View File
@@ -1,210 +0,0 @@
package desktop
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSetDefaultsPreservesUnrelatedContent(t *testing.T) {
configHome, _ := setupFakeXDG(t)
path := filepath.Join(configHome, "mimeapps.list")
writeFile(t, path, `# managed by hand
[Default Applications]
text/html=firefox.desktop
# pinned image viewer
image/png=eog.desktop
[Added Associations]
text/html=firefox.desktop;
[Custom Section]
foo=bar
`)
if err := SetDefaults([]string{"inode/directory", "x-scheme-handler/sftp"}, "org.kde.dolphin.desktop"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
got := string(data)
preserved := []string{
"# managed by hand",
"# pinned image viewer",
"text/html=firefox.desktop",
"image/png=eog.desktop",
"[Custom Section]",
"foo=bar",
}
for _, want := range preserved {
if !strings.Contains(got, want) {
t.Errorf("rewrite lost %q:\n%s", want, got)
}
}
written := []string{
"inode/directory=org.kde.dolphin.desktop",
"x-scheme-handler/sftp=org.kde.dolphin.desktop",
}
for _, want := range written {
if !strings.Contains(got, want) {
t.Errorf("rewrite missing %q:\n%s", want, got)
}
}
if got := GetDefault("inode/directory"); got != "org.kde.dolphin.desktop" {
t.Errorf("GetDefault(inode/directory) = %q", got)
}
if got := GetDefault("x-scheme-handler/sftp"); got != "org.kde.dolphin.desktop" {
t.Errorf("GetDefault(x-scheme-handler/sftp) = %q", got)
}
}
func TestSetDefaultReplacesKeyInPlace(t *testing.T) {
configHome, _ := setupFakeXDG(t)
path := filepath.Join(configHome, "mimeapps.list")
writeFile(t, path, `[Default Applications]
inode/directory=foot.desktop
text/html=firefox.desktop
`)
if err := SetDefault("inode/directory", "org.gnome.Nautilus.desktop"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
got := string(data)
if n := strings.Count(got, "inode/directory=org.gnome.Nautilus.desktop\n"); n != 1 {
t.Errorf("expected exactly one replaced default line, got %d:\n%s", n, got)
}
if strings.Contains(got, "foot.desktop") {
t.Errorf("old default still present:\n%s", got)
}
if !strings.Contains(got, "text/html=firefox.desktop") {
t.Errorf("unrelated default lost:\n%s", got)
}
}
func TestSetDefaultsCreatesFileWhenAbsent(t *testing.T) {
configHome, _ := setupFakeXDG(t)
path := filepath.Join(configHome, "mimeapps.list")
if err := SetDefaults([]string{"inode/directory", "x-scheme-handler/sftp"}, "org.kde.dolphin"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
got := string(data)
expected := []string{
"[Default Applications]",
"inode/directory=org.kde.dolphin.desktop",
"x-scheme-handler/sftp=org.kde.dolphin.desktop",
"[Added Associations]",
}
for _, want := range expected {
if !strings.Contains(got, want) {
t.Errorf("new file missing %q:\n%s", want, got)
}
}
entries, err := os.ReadDir(configHome)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".tmp") {
t.Errorf("leftover temp file %q", e.Name())
}
}
}
func TestSetDefaultClearsRemovedAssociation(t *testing.T) {
configHome, _ := setupFakeXDG(t)
path := filepath.Join(configHome, "mimeapps.list")
writeFile(t, path, `[Removed Associations]
inode/directory=org.kde.dolphin.desktop;other.desktop;
`)
if err := SetDefault("inode/directory", "org.kde.dolphin.desktop"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
got := string(data)
if !strings.Contains(got, "inode/directory=other.desktop;") {
t.Errorf("unrelated removed entry lost:\n%s", got)
}
if strings.Contains(got, "org.kde.dolphin.desktop;other.desktop") {
t.Errorf("chosen app still in removed list:\n%s", got)
}
if got := GetDefault("inode/directory"); got != "org.kde.dolphin.desktop" {
t.Errorf("GetDefault = %q", got)
}
}
func TestHandlersForMimeExcludesTerminalAndNoDisplay(t *testing.T) {
_, dataHome := setupFakeXDG(t)
apps := filepath.Join(dataHome, "applications")
writeFile(t, filepath.Join(apps, "org.kde.dolphin.desktop"), `[Desktop Entry]
Type=Application
Name=Dolphin
Icon=system-file-manager
MimeType=inode/directory;
`)
writeFile(t, filepath.Join(apps, "ghost-term.desktop"), `[Desktop Entry]
Type=Application
Name=Ghost Terminal
Terminal=true
MimeType=inode/directory;
`)
writeFile(t, filepath.Join(apps, "hidden-fm.desktop"), `[Desktop Entry]
Type=Application
Name=Hidden FM
NoDisplay=true
MimeType=inode/directory;
`)
writeFile(t, filepath.Join(apps, "mimeinfo.cache"), `[MIME Cache]
inode/directory=ghost-term.desktop;org.kde.dolphin.desktop;hidden-fm.desktop;stale.desktop;
`)
handlers := HandlersForMime("inode/directory")
byID := make(map[string]*Entry, len(handlers))
for _, h := range handlers {
byID[h.ID] = h
}
dolphin := byID["org.kde.dolphin.desktop"]
if dolphin == nil {
t.Fatal("org.kde.dolphin.desktop missing from handlers")
}
if dolphin.Name != "Dolphin" {
t.Errorf("Name = %q", dolphin.Name)
}
if dolphin.Icon != "system-file-manager" {
t.Errorf("Icon = %q", dolphin.Icon)
}
excluded := []string{"ghost-term.desktop", "hidden-fm.desktop", "stale.desktop"}
for _, id := range excluded {
if byID[id] != nil {
t.Errorf("%s should be excluded from handlers", id)
}
}
}
+4 -2
View File
@@ -118,6 +118,7 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
}
dependencies = append(dependencies, a.detectMatugen())
dependencies = append(dependencies, a.detectDgop())
dependencies = append(dependencies, a.detectDanksearch())
dependencies = append(dependencies, a.detectDankCalendar())
@@ -172,7 +173,7 @@ func parseSRCINFODeps(srcinfoPath string) (deps []string, makedeps []string, err
if err != nil {
return nil, nil, err
}
for line := range strings.SplitSeq(string(data), "\n") {
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
var pkg string
var target *[]string
@@ -218,6 +219,7 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
"quickshell": a.getQuickshellMapping(variants["quickshell"]),
"dms-greeter": a.getDMSGreeterMapping(variants["dms-greeter"]),
"matugen": a.getMatugenMapping(variants["matugen"]),
"dgop": {Name: "dgop", Repository: RepoTypeSystem},
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
@@ -657,7 +659,7 @@ func (a *ArchDistribution) installAURPackages(ctx context.Context, packages []st
}
func (a *ArchDistribution) reorderAURPackages(packages []string) []string {
dmsDepencies := []string{"quickshell", "quickshell-git"}
dmsDepencies := []string{"quickshell", "quickshell-git", "dgop"}
var deps []string
var others []string
+31 -5
View File
@@ -13,9 +13,7 @@ import (
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/netfetch"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/version"
)
@@ -105,6 +103,10 @@ func (b *BaseDistribution) detectMatugen() deps.Dependency {
return b.detectCommand("matugen", "Material Design color generation tool")
}
func (b *BaseDistribution) detectDgop() deps.Dependency {
return b.detectCommand("dgop", "Desktop portal management tool")
}
func (b *BaseDistribution) detectDanksearch() deps.Dependency {
return b.detectOptionalPackage("danksearch", "File indexing and search service", b.commandExists("dsearch") || b.commandExists("danksearch"))
}
@@ -617,8 +619,24 @@ func (b *BaseDistribution) WriteWindowManagerConfig(wm deps.WindowManager) error
}
func (b *BaseDistribution) WriteHyprlandSessionTarget() error {
targetPath, err := config.EnsureHyprlandSessionTarget()
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
targetDir := filepath.Join(homeDir, ".config", "systemd", "user")
if err := os.MkdirAll(targetDir, 0o755); err != nil {
return fmt.Errorf("failed to create systemd user directory: %w", err)
}
targetPath := filepath.Join(targetDir, "hyprland-session.target")
content := `[Unit]
Description=Hyprland Session Target
Requires=graphical-session.target
After=graphical-session.target
`
if err := os.WriteFile(targetPath, []byte(content), 0o644); err != nil {
return fmt.Errorf("failed to write hyprland-session.target: %w", err)
}
@@ -647,10 +665,17 @@ func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword st
CommandInfo: fmt.Sprintf("Downloading dms-%s.gz", arch),
}
version, err := netfetch.LatestReleaseTag(ctx)
// Get latest release version
latestVersionCmd := exec.CommandContext(ctx, "bash", "-c",
`curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/'`)
versionOutput, err := latestVersionCmd.Output()
if err != nil {
return fmt.Errorf("failed to get latest DMS version: %w", err)
}
version := strings.TrimSpace(string(versionOutput))
if version == "" {
return fmt.Errorf("could not determine latest DMS version")
}
homeDir, err := os.UserHomeDir()
if err != nil {
@@ -666,7 +691,8 @@ func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword st
downloadURL := fmt.Sprintf("https://github.com/AvengeMedia/DankMaterialShell/releases/download/%s/dms-cli-%s.gz", version, arch)
gzPath := filepath.Join(tmpDir, "dms.gz")
if err := netfetch.ToFile(ctx, downloadURL, netfetch.Options{Timeout: 5 * time.Minute}, gzPath); err != nil {
downloadCmd := exec.CommandContext(ctx, "curl", "-L", downloadURL, "-o", gzPath)
if err := downloadCmd.Run(); err != nil {
return fmt.Errorf("failed to download DMS binary: %w", err)
}
+40 -21
View File
@@ -3,7 +3,6 @@ package distros
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
@@ -71,6 +70,7 @@ func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context,
}
dependencies = append(dependencies, d.detectMatugen())
dependencies = append(dependencies, d.detectDgop())
dependencies = append(dependencies, d.detectDanksearch())
dependencies = append(dependencies, d.detectDankCalendar())
@@ -135,6 +135,7 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"quickshell": d.getQuickshellMapping(variants["quickshell"]),
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
@@ -268,12 +269,12 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
Step: "Installing development dependencies...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install -y curl wget git cmake ninja-build pkg-config libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev",
CommandInfo: "sudo apt-get install -y curl wget git cmake ninja-build pkg-config gnupg libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev",
LogOutput: "Installing additional development tools",
}
devToolsCmd := privesc.ExecCommand(ctx, sudoPassword,
"DEBIAN_FRONTEND=noninteractive apt-get install -y curl wget git cmake ninja-build pkg-config libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev libjpeg-dev libpugixml-dev")
"DEBIAN_FRONTEND=noninteractive apt-get install -y curl wget git cmake ninja-build pkg-config gnupg libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev libjpeg-dev libpugixml-dev")
if err := d.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil {
return fmt.Errorf("failed to install development tools: %w", err)
}
@@ -473,8 +474,13 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
return fmt.Errorf("failed to get OS info: %w", err)
}
obsRepo := debianOBSRepoVersion(osInfo, readDebianAPTSuites())
d.log(fmt.Sprintf("Using OBS Debian repository: %s", obsRepo))
// Determine Debian version for OBS repository URL
debianVersion := "Debian_13"
if osInfo.VersionID == "testing" {
debianVersion = "Debian_Testing"
} else if osInfo.VersionCodename == "sid" || osInfo.VersionID == "sid" || strings.Contains(strings.ToLower(osInfo.PrettyName), "sid") || strings.Contains(strings.ToLower(osInfo.PrettyName), "unstable") {
debianVersion = "Debian_Unstable"
}
for _, pkg := range obsPkgs {
if pkg.RepoURL != "" && !enabledRepos[pkg.RepoURL] {
@@ -483,11 +489,12 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
// RepoURL format: "home:AvengeMedia:danklinux"
repoPath := strings.ReplaceAll(pkg.RepoURL, ":", ":/")
repoName := strings.ReplaceAll(pkg.RepoURL, ":", "-")
baseURL := fmt.Sprintf("https://download.opensuse.org/repositories/%s/%s", repoPath, obsRepo)
baseURL := fmt.Sprintf("https://download.opensuse.org/repositories/%s/%s", repoPath, debianVersion)
// Check if repository already exists
listFile := fmt.Sprintf("/etc/apt/sources.list.d/%s.list", repoName)
if debianOBSListFileMatches(listFile, obsRepo) {
checkCmd := exec.CommandContext(ctx, "test", "-f", listFile)
if checkCmd.Run() == nil {
d.log(fmt.Sprintf("OBS repo %s already exists, skipping", pkg.RepoURL))
enabledRepos[pkg.RepoURL] = true
continue
@@ -506,22 +513,12 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
Progress: 0.18,
Step: fmt.Sprintf("Adding OBS GPG key for %s...", pkg.RepoURL),
NeedsSudo: true,
CommandInfo: fmt.Sprintf("install keyring for %s", pkg.RepoURL),
CommandInfo: fmt.Sprintf("curl & gpg to add key for %s", pkg.RepoURL),
}
keyring, err := fetchDearmoredKey(ctx, baseURL+"/Release.key")
if err != nil {
return fmt.Errorf("failed to add OBS GPG key for %s: %w", pkg.RepoURL, err)
}
tmpKeyring, err := writeTempKeyring(keyring)
if err != nil {
return fmt.Errorf("failed to add OBS GPG key for %s: %w", pkg.RepoURL, err)
}
cmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("install -m 0644 %s %s", tmpKeyring, keyringPath))
err = d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.18, 0.20)
os.Remove(tmpKeyring)
if err != nil {
keyCmd := fmt.Sprintf("bash -c 'rm -f %s && curl -fsSL %s/Release.key | gpg --batch --dearmor -o %s'", keyringPath, baseURL, keyringPath)
cmd := privesc.ExecCommand(ctx, sudoPassword, keyCmd)
if err := d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.18, 0.20); err != nil {
return fmt.Errorf("failed to add OBS GPG key for %s: %w", pkg.RepoURL, err)
}
@@ -671,6 +668,10 @@ func (d *DebianDistribution) installBuildDependencies(ctx context.Context, manua
if err := d.installRust(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Rust: %w", err)
}
case "dgop":
if err := d.installGo(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Go: %w", err)
}
}
}
@@ -729,6 +730,24 @@ func (d *DebianDistribution) installRust(ctx context.Context, sudoPassword strin
return nil
}
func (d *DebianDistribution) installGo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if d.commandExists("go") {
return nil
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.87,
Step: "Installing Go...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install golang-go",
}
installCmd := privesc.ExecCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y golang-go")
return d.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.87, 0.90)
}
func (d *DebianDistribution) InstallManualPackages(ctx context.Context, packages []string, variantMap map[string]deps.PackageVariant, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
-172
View File
@@ -1,172 +0,0 @@
package distros
import (
"os"
"path/filepath"
"strings"
)
const (
obsRepoDebian13 = "Debian_13"
obsRepoDebianTesting = "Debian_Testing"
obsRepoDebianUnstable = "Debian_Unstable"
)
func debianOBSRepoVersion(info *OSInfo, aptSuites []string) string {
hasSuite := make(map[string]bool, len(aptSuites))
for _, suite := range aptSuites {
hasSuite[normalizeDebianSuite(suite)] = true
}
// APT suites distinguish testing from sid. os-release cannot: both currently
// report VERSION_CODENAME=forky and PRETTY_NAME="Debian GNU/Linux forky/sid"
if hasSuite["sid"] || hasSuite["unstable"] {
return obsRepoDebianUnstable
}
if hasSuite["testing"] || hasSuite["forky"] {
return obsRepoDebianTesting
}
if hasSuite["trixie"] || hasSuite["stable"] {
return obsRepoDebian13
}
if info == nil {
return obsRepoDebian13
}
id := strings.ToLower(strings.TrimSpace(info.VersionID))
code := strings.ToLower(strings.TrimSpace(info.VersionCodename))
if code == "sid" || id == "sid" {
return obsRepoDebianUnstable
}
if id == "13" || code == "trixie" {
return obsRepoDebian13
}
if id != "" && id != "testing" {
return obsRepoDebian13
}
// Prefer Testing when os-release is the shared forky/sid string. Unstable
// OBS packages can require a newer glibc than Testing ships.
return obsRepoDebianTesting
}
func debianOBSListFileMatches(path, debianVersion string) bool {
data, err := os.ReadFile(path)
if err != nil {
return false
}
return debianOBSListPointsAt(string(data), debianVersion)
}
func debianOBSListPointsAt(contents, debianVersion string) bool {
return strings.Contains(contents, "/"+debianVersion+"/")
}
func readDebianAPTSuites() []string {
var chunks []string
if data, err := os.ReadFile("/etc/apt/sources.list"); err == nil {
chunks = append(chunks, string(data))
}
matches, _ := filepath.Glob("/etc/apt/sources.list.d/*")
for _, path := range matches {
if !strings.HasSuffix(path, ".list") && !strings.HasSuffix(path, ".sources") {
continue
}
data, err := os.ReadFile(path)
if err != nil {
continue
}
chunks = append(chunks, string(data))
}
return debianAPTSuites(strings.Join(chunks, "\n"))
}
func debianAPTSuites(sources string) []string {
seen := make(map[string]bool)
var suites []string
add := func(raw string) {
suite := normalizeDebianSuite(raw)
if suite == "" || seen[suite] {
return
}
seen[suite] = true
suites = append(suites, suite)
}
var stanza []string
flushStanza := func() {
if len(stanza) == 0 {
return
}
enabled := true
var stanzaSuites []string
for _, line := range stanza {
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
switch strings.ToLower(strings.TrimSpace(key)) {
case "enabled":
switch strings.ToLower(strings.TrimSpace(val)) {
case "no", "false", "0":
enabled = false
}
case "suites":
stanzaSuites = strings.Fields(val)
}
}
if enabled {
for _, suite := range stanzaSuites {
add(suite)
}
}
stanza = nil
}
for raw := range strings.SplitSeq(sources, "\n") {
line := strings.TrimSpace(raw)
if line == "" {
flushStanza()
continue
}
if strings.HasPrefix(line, "#") {
continue
}
lower := strings.ToLower(line)
if strings.HasPrefix(lower, "deb ") || strings.HasPrefix(lower, "deb-src ") {
flushStanza()
if suite := suiteFromDebLine(line); suite != "" {
add(suite)
}
continue
}
stanza = append(stanza, line)
}
flushStanza()
return suites
}
func suiteFromDebLine(line string) string {
if i := strings.Index(line, "["); i != -1 {
if j := strings.Index(line, "]"); j > i {
line = strings.TrimSpace(line[:i] + " " + line[j+1:])
}
}
fields := strings.Fields(line)
if len(fields) < 3 {
return ""
}
return fields[2]
}
func normalizeDebianSuite(suite string) string {
suite = strings.ToLower(strings.TrimSpace(suite))
suite = strings.Trim(suite, "/")
for _, suffix := range []string{"-security", "-updates", "-backports", "-proposed-updates"} {
if before, ok := strings.CutSuffix(suite, suffix); ok {
return before
}
}
return suite
}
+3 -1
View File
@@ -103,6 +103,7 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
}
dependencies = append(dependencies, f.detectMatugen())
dependencies = append(dependencies, f.detectDgop())
dependencies = append(dependencies, f.detectDanksearch())
dependencies = append(dependencies, f.detectDankCalendar())
@@ -138,6 +139,7 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]),
"dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"danksearch": {Name: "danksearch", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
}
@@ -457,7 +459,7 @@ func (f *FedoraDistribution) extractPackageNames(packages []PackageMapping) []st
// enableTerraRepo registers the persistent Terra repo (via terra-release) so
// `mangowm` resolves in the DNF phase. $releasever is single-quoted so dnf, not
// the shell, expands it.
func (f *FedoraDistribution) enableTerraRepo(ctx context.Context, sudoPassword string, _ chan<- InstallProgressMsg) error {
func (f *FedoraDistribution) enableTerraRepo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
// Skip if Terra is already configured
if exec.CommandContext(ctx, "sh", "-c",
"rpm -q terra-release >/dev/null 2>&1 || test -f /etc/yum.repos.d/terra.repo").Run() == nil {
+9 -9
View File
@@ -53,11 +53,6 @@ func NewGentooDistribution(config DistroConfig, logChan chan<- string) *GentooDi
}
}
func emergeInstallArgs(packages []string) []string {
args := []string{"emerge", "--ask=n", "--quiet", "--autounmask-continue=y", "--autounmask-keep-keywords=y", "--autounmask-keep-masks=y"}
return append(args, packages...)
}
func (g *GentooDistribution) getArchKeyword() string {
arch := runtime.GOARCH
switch arch {
@@ -117,6 +112,7 @@ func (g *GentooDistribution) DetectDependenciesWithTerminal(ctx context.Context,
}
dependencies = append(dependencies, g.detectMatugen())
dependencies = append(dependencies, g.detectDgop())
dependencies = append(dependencies, g.detectDanksearch())
return dependencies, nil
@@ -175,6 +171,7 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"quickshell": g.getQuickshellMapping(variants["quickshell"]),
"matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
"dms (DankMaterialShell)": g.getDmsMapping(),
"dgop": {Name: "gui-apps/dgop", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
"danksearch": {Name: "gui-apps/danksearch", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
}
@@ -322,7 +319,6 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
}
g.log("Portage tree synced successfully")
args := emergeInstallArgs(missingPkgs)
g.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")))
progressChan <- InstallProgressMsg{
Phase: PhasePrerequisites,
@@ -330,10 +326,12 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw
Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)),
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
CommandInfo: fmt.Sprintf("sudo emerge --ask=n %s", strings.Join(missingPkgs, " ")),
LogOutput: fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")),
}
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, missingPkgs...)
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
@@ -523,7 +521,8 @@ func (g *GentooDistribution) installPortagePackages(ctx context.Context, package
}
}
args := emergeInstallArgs(packageNames)
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, packageNames...)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
@@ -714,7 +713,8 @@ func (g *GentooDistribution) installGURUPackages(ctx context.Context, packages [
guruPackages[i] = pkg + "::guru"
}
args := emergeInstallArgs(guruPackages)
args := []string{"emerge", "--ask=n", "--quiet"}
args = append(args, guruPackages...)
progressChan <- InstallProgressMsg{
Phase: PhaseAURPackages,
-1
View File
@@ -101,7 +101,6 @@ type Distribution interface {
// Package Mapping
GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping
GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping
// Prerequisites
InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error
+64
View File
@@ -59,6 +59,10 @@ func (m *ManualPackageInstaller) InstallManualPackages(ctx context.Context, pack
if err := m.installDankMaterialShell(ctx, variant, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install DankMaterialShell: %w", err)
}
case "dgop":
if err := m.installDgop(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install dgop: %w", err)
}
case "niri":
if err := m.installNiri(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install niri: %w", err)
@@ -91,6 +95,66 @@ func (m *ManualPackageInstaller) InstallManualPackages(ctx context.Context, pack
return nil
}
func (m *ManualPackageInstaller) installDgop(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing dgop from source...")
homeDir := os.Getenv("HOME")
if homeDir == "" {
return fmt.Errorf("HOME environment variable not set")
}
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
tmpDir := filepath.Join(cacheDir, "dgop-build")
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.1,
Step: "Cloning dgop repository...",
IsComplete: false,
CommandInfo: "git clone https://github.com/AvengeMedia/dgop.git",
}
cloneCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/AvengeMedia/dgop.git", tmpDir)
if err := cloneCmd.Run(); err != nil {
m.logError("failed to clone dgop repository", err)
return fmt.Errorf("failed to clone dgop repository: %w", err)
}
buildCmd := exec.CommandContext(ctx, "make")
buildCmd.Dir = tmpDir
buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir)
if err := m.runWithProgressStep(buildCmd, progressChan, PhaseSystemPackages, 0.4, 0.7, "Building dgop..."); err != nil {
return fmt.Errorf("failed to build dgop: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.7,
Step: "Installing dgop...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo make install",
}
installCmd := privesc.ExecCommand(ctx, sudoPassword, "make install")
installCmd.Dir = tmpDir
if err := installCmd.Run(); err != nil {
m.logError("failed to install dgop", err)
return fmt.Errorf("failed to install dgop: %w", err)
}
m.log("dgop installed successfully from source")
return nil
}
func (m *ManualPackageInstaller) installNiri(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing niri from source...")
+3 -1
View File
@@ -90,6 +90,7 @@ func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Contex
}
dependencies = append(dependencies, o.detectMatugen())
dependencies = append(dependencies, o.detectDgop())
dependencies = append(dependencies, o.detectDanksearch())
dependencies = append(dependencies, o.detectDankCalendar())
@@ -129,6 +130,7 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
}
@@ -531,7 +533,7 @@ func isOpenSUSEInstallMediaURI(uri string) bool {
func parseZypperInstallMediaAliases(output string) []string {
var aliases []string
for line := range strings.SplitSeq(output, "\n") {
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" || !strings.Contains(line, "|") {
continue
+1 -1
View File
@@ -104,7 +104,7 @@ func IsUnsupportedDistro(distroID, versionID string) bool {
if distroID == "debian" {
// unstable/sid support
if versionID == "sid" || versionID == "testing" {
if versionID == "sid" {
return false
}
if versionID == "" {
-131
View File
@@ -1,131 +0,0 @@
package distros
import (
"context"
"encoding/base64"
"fmt"
"os"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/netfetch"
)
const (
crc24Init = 0xB704CE
crc24Poly = 0x1864CFB
)
func crc24(data []byte) uint32 {
crc := uint32(crc24Init)
for _, b := range data {
crc ^= uint32(b) << 16
for range 8 {
crc <<= 1
if crc&0x1000000 != 0 {
crc ^= crc24Poly
}
}
}
return crc & 0xFFFFFF
}
func dearmorPGP(armored []byte) ([]byte, error) {
lines := strings.Split(strings.ReplaceAll(string(armored), "\r\n", "\n"), "\n")
start := -1
for i, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "-----BEGIN PGP ") {
start = i + 1
break
}
}
if start < 0 {
return nil, fmt.Errorf("no PGP armor header found")
}
// Armor headers run until the first blank line; a headerless block has none.
for start < len(lines) && strings.Contains(lines[start], ": ") {
start++
}
for start < len(lines) && strings.TrimSpace(lines[start]) == "" {
start++
}
var payload strings.Builder
checksum := ""
ended := false
for _, line := range lines[start:] {
trimmed := strings.TrimSpace(line)
switch {
case trimmed == "":
continue
case strings.HasPrefix(trimmed, "-----END PGP "):
ended = true
case strings.HasPrefix(trimmed, "="):
checksum = trimmed[1:]
default:
payload.WriteString(trimmed)
}
if ended {
break
}
}
if !ended {
return nil, fmt.Errorf("unterminated PGP armor block")
}
decoded, err := base64.StdEncoding.DecodeString(payload.String())
if err != nil {
return nil, fmt.Errorf("invalid base64 in PGP armor: %w", err)
}
if len(decoded) == 0 {
return nil, fmt.Errorf("PGP armor block is empty")
}
if checksum == "" {
return decoded, nil
}
want, err := base64.StdEncoding.DecodeString(checksum)
if err != nil || len(want) != 3 {
return nil, fmt.Errorf("invalid PGP armor checksum %q", checksum)
}
got := crc24(decoded)
if uint32(want[0])<<16|uint32(want[1])<<8|uint32(want[2]) != got {
return nil, fmt.Errorf("PGP armor checksum mismatch")
}
return decoded, nil
}
func fetchDearmoredKey(ctx context.Context, url string) ([]byte, error) {
armored, err := netfetch.Bytes(ctx, url, netfetch.Options{Timeout: 30 * time.Second})
if err != nil {
return nil, fmt.Errorf("failed to fetch %s: %w", url, err)
}
keyring, err := dearmorPGP(armored)
if err != nil {
return nil, fmt.Errorf("failed to dearmor key from %s: %w", url, err)
}
return keyring, nil
}
// privesc.ExecCommand owns stdin for the sudo password, so the key cannot be piped in.
func writeTempKeyring(keyring []byte) (string, error) {
f, err := os.CreateTemp("", "dms-repo-key-*.gpg")
if err != nil {
return "", fmt.Errorf("failed to create temp keyring: %w", err)
}
defer f.Close()
if _, err := f.Write(keyring); err != nil {
os.Remove(f.Name())
return "", fmt.Errorf("failed to write temp keyring: %w", err)
}
if err := f.Chmod(0o644); err != nil {
os.Remove(f.Name())
return "", fmt.Errorf("failed to chmod temp keyring: %w", err)
}
return f.Name(), nil
}
-66
View File
@@ -1,66 +0,0 @@
package distros
import (
"bytes"
"os"
"strings"
"testing"
)
// Golden is `gpg --batch --dearmor` output for the fixture.
func TestDearmorPGPMatchesGPGGolden(t *testing.T) {
armored, err := os.ReadFile("testdata/obs-release.asc")
if err != nil {
t.Fatalf("read fixture: %v", err)
}
want, err := os.ReadFile("testdata/obs-release.gpg")
if err != nil {
t.Fatalf("read golden: %v", err)
}
got, err := dearmorPGP(armored)
if err != nil {
t.Fatalf("dearmorPGP: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatalf("got %d bytes, want %d", len(got), len(want))
}
}
func TestDearmorPGPAcceptsCRLF(t *testing.T) {
armored, err := os.ReadFile("testdata/obs-release.asc")
if err != nil {
t.Fatalf("read fixture: %v", err)
}
want, err := dearmorPGP(armored)
if err != nil {
t.Fatalf("dearmorPGP: %v", err)
}
crlf := []byte(strings.ReplaceAll(string(armored), "\n", "\r\n"))
got, err := dearmorPGP(crlf)
if err != nil {
t.Fatalf("dearmorPGP on CRLF input: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatal("CRLF input decoded differently")
}
}
func TestDearmorPGPRejects(t *testing.T) {
tests := map[string]string{
"no header": "mQINBAAA\n",
"unterminated": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBAAA\n",
"bad base64": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n!!!!\n-----END PGP PUBLIC KEY BLOCK-----\n",
"empty payload": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n-----END PGP PUBLIC KEY BLOCK-----\n",
"checksum mismatch": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBAAA\n=AAAA\n-----END PGP PUBLIC KEY BLOCK-----\n",
}
for name, input := range tests {
t.Run(name, func(t *testing.T) {
if _, err := dearmorPGP([]byte(input)); err == nil {
t.Fatal("expected an error")
}
})
}
}
-41
View File
@@ -1,41 +0,0 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBGkdCo0BEADPLTDkieySQmsnUYbj6VyDve3OVY23AfUukdNoqe/FwOPRm9eW
hduEMo28W5mQZp2TCOWGc7E4V63l3DWjrAzDlYtgDmupZ0QiTLbz0BUWHPsZHRfF
+xVh/pbfcixMtSk/InAe4Dv0p7jc+LjM1u8E204FWsJAPc7DRurwowXS6nEBZlMu
cjTzX0jehRQ2QShtib0iexd5YpfvW4ZGkQ6uSBnOAn5e/jwiSUbLZZhYR7RHdxhG
XR08PVP0SBUQvsTo2lC7Fqi8O3mEZm5NHeuj7dulNJxNu4LbdGYftev7GzJ5yT8O
dNem+rYspFpLaBbdOieRVFCcyVf+jGNHJiWnBkxUNGNSZr+M+26v455/v83mdQW4
1pGBhFyFVirH8wxLmJm7P7CYEBes9HujwaLHBnSGg9VAzXfWb2g5FSdDb3drG8FH
g40HEMl1Kuwnm0o3HH9k94C2cBaS3mji9aE21RgoxGoRZ4vEZEQpoWZ8CVFrKHhU
tU1rbXLWsuG37MRZJYYRb2gCM/zgoEmgBFvoHeVqqd1i8T56j11UUCEfK68iCVVY
GlHfXxEzhBO54qGpfbh3sC9ce5koicyXUdhkBz0Ob+APJ7TLFUpLarxNb/rrJ7YC
0RnVgqtPyS78rMcpOAJTEyZgl9Blad5escDB7394Snkgu/Ghsol8DS+sRwARAQAB
tEJob21lOkF2ZW5nZU1lZGlhIE9CUyBQcm9qZWN0IDxob21lOkF2ZW5nZU1lZGlh
QGJ1aWxkLm9wZW5zdXNlLm9yZz6JAlQEEwEIAD4WIQQgyj5YBMp1AJs2caQoR3hI
O8hhFQUCaR0KjQIbAwUJBB6wAAULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRAo
R3hIO8hhFVgaEACxFCL5YwVJLlQvMjJuvdQt9D1T+vE9uvaYP8SONQOxhpRpOS0U
t+rHJXy73uy4cUieT0o1AS17/8C9b0ur+VlNdjgLf1fqPbAmcvEAB6UQ8yQzLC7j
/8XNJuc/aHcSX3fWFM7YwhVc8dXPE+eRUufxctFXqD8Otg3SDkmeObJE0HlR5m08
2BH8TaPE4HkkJJ4kZV23wnbNs0x4OdoOFOCT7CnCr9k0rgBNMP422S3vJ7zjf08t
GX0YGdNWyB0SSUt7OR372WPabImAeOUTB3f05vfd9fLWBeEPLt/I8wQj1pbo9SSF
upNXsoHcAnTl9gpBVxCZYJXVF+uefxY5d68ZZEYBVEAfzwwdxrvjosYqh0Whcv3V
DNlg2kQ2y9MCptv4a6qNTkVUTKAYv5SV0feV7b/OGUpAqu6dvjkzncMhSDZCmRUr
zjzG2/USv56ef1Hnayuw0YNyrM3IQuEz6jEKEwjofENIk7W2rcKQRZB8gp/BNI1M
MtRxC7YufcIRkyBANCtdM95Wi1tnV64LrPk3vnreG1HVII237xOBtiCn8XgBtHyA
NfJwFctjpaZCRcL6FgWO6uUHOfkZjLeLPehbNIFx/DCqphLWkusuWfH6jiDl0Y49
fpx4oJ6CfIfzsow2t1CL+JCF3D/OX5G20IlMhCMwdXoQ2H8Z/MNiHFJMKYkCMwQT
AQgAHRYhBMw1zD015aNkPlRaQ88Lkoze1k87BQJpHQqQAAoJEM8Lkoze1k87wTQP
/0b1PY/4uBWc0Uf5CK2t4lBUw+D9sL2wsLkCrSMS4mPlcutF+qh3xnO+d74I4Y8R
dg4RYlwHlRGFa2wMKp9q2HEeMxnqG4mRQBo3nCXvQDhPTerJG0mhnDzLVvS+4MaY
vcuxPntHvMygQDrguIChsdehU43FqlexjNKSBlAUBqDIkmgvTlcQjx62/m9m89Sr
Jg1QGqGoAYSaAPK3gDBpnBxYMpLMMCUtiIsHCbZFwxvOGBvTd+HWqaRpB56hm1sf
OFYXjoXl8ydkIh6HuAtP8NHHkHt3exL+dE5mL//KtmeLtm0aDklrd4Ol5ZUo25jQ
sOM0Tc/7AF0X3EyV3wTuz/MsyPFDMU6yJfrrlvpKIRhRtkDjkt8Ut9IOaCJ/iUoO
Fap45D4euq5M326HhkFfPKfnwO7MHWi82CNaQx05l7Lam+NniEOaBNMW0oAAS9JE
D6uoYgJSdCUHmhP8mt4f4C3j+VnQB+LsfPhQpqeEiqQtEar+arJ2KUgaOP/g3Z89
zaOiZN2nbpbK0wPmJQasJeyIGv1lQccjxD9ZxUd8iG8L4CJjnmXkUf1IrRyUZOoh
PK6GJB0YOGpiNnxG4bhiOjgdUMd9J5nBfjWTGNTjI+RzogfFbka+utDWuHR6ASCt
lNMZpysq5D+jaPXphRaKzHqaXrjtxWYiQN6JhGH8fgOF
=kr/Y
-----END PGP PUBLIC KEY BLOCK-----
Binary file not shown.
+53
View File
@@ -79,6 +79,7 @@ func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context,
}
dependencies = append(dependencies, u.detectMatugen())
dependencies = append(dependencies, u.detectDgop())
dependencies = append(dependencies, u.detectDanksearch())
dependencies = append(dependencies, u.detectDankCalendar())
@@ -123,6 +124,7 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"quickshell": u.getQuickshellMapping(variants["quickshell"]),
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"dgop": {Name: "dgop", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"danksearch": {Name: "danksearch", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
@@ -575,6 +577,10 @@ func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manua
if err := u.installRust(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Rust: %w", err)
}
case "dgop":
if err := u.installGo(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Go: %w", err)
}
}
}
@@ -634,6 +640,53 @@ func (u *UbuntuDistribution) installRust(ctx context.Context, sudoPassword strin
return nil
}
func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if u.commandExists("go") {
return nil
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.87,
Step: "Adding Go PPA repository...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo add-apt-repository ppa:longsleep/golang-backports",
}
addPPACmd := privesc.ExecCommand(ctx, sudoPassword,
"add-apt-repository -y ppa:longsleep/golang-backports")
if err := u.runWithProgress(addPPACmd, progressChan, PhaseSystemPackages, 0.87, 0.88); err != nil {
return fmt.Errorf("failed to add Go PPA: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.88,
Step: "Updating package lists...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get update",
}
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.88, 0.89); err != nil {
return fmt.Errorf("failed to update package lists after adding Go PPA: %w", err)
}
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.89,
Step: "Installing Go...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: "sudo apt-get install golang-go",
}
installCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y golang-go")
return u.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.89, 0.90)
}
func (u *UbuntuDistribution) InstallManualPackages(ctx context.Context, packages []string, variantMap map[string]deps.PackageVariant, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
if len(packages) == 0 {
return nil
+2
View File
@@ -83,6 +83,7 @@ func (v *VoidDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
}
dependencies = append(dependencies, v.detectMatugen())
dependencies = append(dependencies, v.detectDgop())
dependencies = append(dependencies, v.detectDanksearch())
dependencies = append(dependencies, v.detectDankCalendar())
@@ -182,6 +183,7 @@ func (v *VoidDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
"matugen": {Name: "matugen", Repository: RepoTypeSystem},
"dms (DankMaterialShell)": v.getDmsMapping(variants["dms (DankMaterialShell)"]),
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeXBPS, RepoURL: VoidDMSRepo},
"dgop": {Name: "dgop", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
"danksearch": {Name: "danksearch", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
"dankcalendar": {Name: "dankcalendar", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
}
-4
View File
@@ -26,8 +26,6 @@ const (
ErrTypeSecretPromptTimeout
ErrTypeSecretAgentFailed
ErrTypeGeneric
ErrTypeInvalidDuration
ErrTypeInvalidContrast
)
func NewCustomError(errType ErrorType, message string) error {
@@ -54,10 +52,8 @@ var (
ErrNoUpdateNeeded = NewCustomError(ErrTypeNoUpdateNeeded, "no update needed")
ErrInvalidTemperature = NewCustomError(ErrTypeInvalidTemperature, "temperature must be between 1000 and 10000")
ErrInvalidGamma = NewCustomError(ErrTypeInvalidGamma, "gamma must be between 0 and 10")
ErrInvalidContrast = NewCustomError(ErrTypeInvalidContrast, "contrast must be between 0.5 and 2")
ErrInvalidLocation = NewCustomError(ErrTypeInvalidLocation, "invalid latitude/longitude")
ErrInvalidManualTimes = NewCustomError(ErrTypeInvalidManualTimes, "both sunrise and sunset must be set or neither")
ErrInvalidDuration = NewCustomError(ErrTypeInvalidDuration, "duration must be between 0 and 6 hours")
ErrNoWaylandDisplay = NewCustomError(ErrTypeNoWaylandDisplay, "no wayland display available")
ErrNoGammaControl = NewCustomError(ErrTypeNoGammaControl, "compositor does not support gamma control")
ErrNotInitialized = NewCustomError(ErrTypeNotInitialized, "manager not initialized")
+31 -1
View File
@@ -6,7 +6,37 @@ func NewClient() Client {
geoclueClient, err := newGeoClueClient()
if err != nil {
log.Warnf("GeoClue2 unavailable: %v", err)
return newIpClient()
return newSeededIpClient()
}
loc, _ := geoclueClient.GetLocation()
if loc.Latitude != 0 || loc.Longitude != 0 {
log.Info("Using GeoClue2 location")
return geoclueClient
}
log.Info("GeoClue2 has no fix yet, seeding with IP location")
ipLoc, err := fetchIPLocation()
if err != nil {
log.Warnf("IP location seed failed: %v", err)
return geoclueClient
}
log.Info("Seeded GeoClue2 with IP location")
geoclueClient.SeedLocation(Location{Latitude: ipLoc.Latitude, Longitude: ipLoc.Longitude})
return geoclueClient
}
func newSeededIpClient() *IpClient {
client := newIpClient()
ipLoc, err := fetchIPLocation()
if err != nil {
log.Warnf("IP location also failed: %v", err)
return client
}
log.Info("Using IP location")
client.currLocation.Latitude = ipLoc.Latitude
client.currLocation.Longitude = ipLoc.Longitude
return client
}
+16 -25
View File
@@ -34,7 +34,6 @@ const (
type GeoClueClient struct {
currLocation *Location
locationMutex sync.RWMutex
seedOnce sync.Once
dbusConn *dbus.Conn
clientPath dbus.ObjectPath
@@ -47,7 +46,7 @@ type GeoClueClient struct {
}
func newGeoClueClient() (*GeoClueClient, error) {
dbusConn, err := dbus.SystemBus()
dbusConn, err := dbus.ConnectSystemBus()
if err != nil {
return nil, fmt.Errorf("system bus connection failed: %w", err)
}
@@ -64,6 +63,7 @@ func newGeoClueClient() (*GeoClueClient, error) {
}
if err := c.setupClient(); err != nil {
dbusConn.Close()
return nil, err
}
@@ -89,6 +89,10 @@ func (c *GeoClueClient) Close() {
c.subscribers.Delete(key)
return true
})
if c.dbusConn != nil {
c.dbusConn.Close()
}
}
func (c *GeoClueClient) Subscribe(id string) chan Location {
@@ -133,7 +137,9 @@ func (c *GeoClueClient) startSignalPump() error {
return err
}
c.sigWG.Go(func() {
c.sigWG.Add(1)
go func() {
defer c.sigWG.Done()
clientObj := c.dbusConn.Object(dbusGeoClueService, c.clientPath)
clientObj.Call(dbusGeoClueClientTimeStart, 0)
@@ -154,7 +160,7 @@ func (c *GeoClueClient) startSignalPump() error {
c.handleSignal(sig)
}
}
})
}()
return nil
}
@@ -224,29 +230,14 @@ func (c *GeoClueClient) SeedLocation(loc Location) {
}
func (c *GeoClueClient) GetLocation() (Location, error) {
loc := c.currentLocation()
if loc.Latitude != 0 || loc.Longitude != 0 {
return loc, nil
}
c.seedOnce.Do(func() {
ipLoc, err := fetchIPLocation()
if err != nil {
log.Warnf("GeoClue2 has no fix, IP location seed failed: %v", err)
return
}
log.Info("Seeded GeoClue2 with IP location")
c.SeedLocation(Location{Latitude: ipLoc.Latitude, Longitude: ipLoc.Longitude})
})
return c.currentLocation(), nil
}
func (c *GeoClueClient) currentLocation() Location {
c.locationMutex.RLock()
defer c.locationMutex.RUnlock()
if c.currLocation == nil {
return Location{}
return Location{
Latitude: 0.0,
Longitude: 0.0,
}, nil
}
return *c.currLocation
stateCopy := *c.currLocation
return stateCopy, nil
}
-169
View File
@@ -1,169 +0,0 @@
package gpu
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/linux_dmabuf"
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
"golang.org/x/sys/unix"
)
var eglVendorDirs = []string{"/etc/glvnd/egl_vendor.d", "/usr/share/glvnd/egl_vendor.d"}
const (
dmabufFeedbackMinVersion = 4
// v6 stops sending main_device
dmabufMainDeviceMaxVersion = 5
compositorProbeTimeout = 2 * time.Second
sysDevCharDir = "/sys/dev/char"
)
func EGLVendorEnv() []string {
if os.Getenv("__EGL_VENDOR_LIBRARY_FILENAMES") != "" || os.Getenv("__EGL_VENDOR_LIBRARY_DIRS") != "" {
return nil
}
mesa, nvidia := findEGLVendorICDs(eglVendorDirs)
if mesa == "" || nvidia == "" {
return nil
}
driver, err := compositorRenderDriver()
if err != nil {
log.Debugf("egl: keeping glvnd vendor defaults: %v", err)
return nil
}
if driver == "nvidia" {
return nil
}
log.Infof("egl: compositor renders on %s, loading mesa vendor only", driver)
return []string{"__EGL_VENDOR_LIBRARY_FILENAMES=" + mesa}
}
type eglVendorICD struct {
ICD struct {
LibraryPath string `json:"library_path"`
} `json:"ICD"`
}
func findEGLVendorICDs(dirs []string) (mesa, nvidia string) {
for _, dir := range dirs {
entries, err := filepath.Glob(filepath.Join(dir, "*.json"))
if err != nil {
continue
}
for _, path := range entries {
switch eglVendorLibrary(path) {
case "libEGL_mesa":
if mesa == "" {
mesa = path
}
case "libEGL_nvidia":
if nvidia == "" {
nvidia = path
}
}
}
}
return mesa, nvidia
}
func eglVendorLibrary(path string) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
var icd eglVendorICD
if err := json.Unmarshal(data, &icd); err != nil {
return ""
}
base := filepath.Base(icd.ICD.LibraryPath)
name, _, _ := strings.Cut(base, ".so")
return name
}
func compositorRenderDriver() (string, error) {
dev, err := compositorMainDevice()
if err != nil {
return "", err
}
return drmDeviceDriver(sysDevCharDir, dev)
}
func drmDeviceDriver(charDir string, dev uint64) (string, error) {
link := filepath.Join(charDir, fmt.Sprintf("%d:%d", unix.Major(dev), unix.Minor(dev)), "device", "driver")
target, err := os.Readlink(link)
if err != nil {
return "", err
}
return filepath.Base(target), nil
}
func compositorMainDevice() (uint64, error) {
display, err := wlclient.Connect("")
if err != nil {
return 0, err
}
ctx := display.Context()
defer ctx.Close()
if err := ctx.SetReadDeadline(time.Now().Add(compositorProbeTimeout)); err != nil {
return 0, err
}
registry, err := display.GetRegistry()
if err != nil {
return 0, err
}
var dmabuf *linux_dmabuf.ZwpLinuxDmabufV1
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
if e.Interface != linux_dmabuf.ZwpLinuxDmabufV1InterfaceName || e.Version < dmabufFeedbackMinVersion {
return
}
candidate := linux_dmabuf.NewZwpLinuxDmabufV1(ctx)
if err := registry.Bind(e.Name, e.Interface, min(e.Version, dmabufMainDeviceMaxVersion), candidate); err != nil {
return
}
dmabuf = candidate
})
if err := display.Roundtrip(); err != nil {
return 0, err
}
if dmabuf == nil {
return 0, errors.New("compositor lacks linux-dmabuf feedback")
}
defer dmabuf.Destroy()
feedback, err := dmabuf.GetDefaultFeedback()
if err != nil {
return 0, err
}
defer feedback.Destroy()
var device []byte
done := false
feedback.SetMainDeviceHandler(func(e linux_dmabuf.ZwpLinuxDmabufFeedbackV1MainDeviceEvent) {
device = e.Device
})
feedback.SetDoneHandler(func(linux_dmabuf.ZwpLinuxDmabufFeedbackV1DoneEvent) {
done = true
})
for !done {
if err := ctx.Dispatch(); err != nil {
return 0, err
}
}
return mainDeviceFromEvent(device)
}
func mainDeviceFromEvent(device []byte) (uint64, error) {
if len(device) != 8 {
return 0, fmt.Errorf("main_device has %d bytes, want 8", len(device))
}
return binary.NativeEndian.Uint64(device), nil
}
-79
View File
@@ -1,79 +0,0 @@
package gpu
import (
"encoding/binary"
"os"
"path/filepath"
"testing"
)
func writeICD(t *testing.T, dir, name, library string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(`{"file_format_version":"1.0.0","ICD":{"library_path":"`+library+`"}}`), 0o644); err != nil {
t.Fatal(err)
}
return path
}
func TestFindEGLVendorICDs(t *testing.T) {
etc := t.TempDir()
usr := t.TempDir()
writeICD(t, usr, "10_nvidia.json", "libEGL_nvidia.so.0")
mesaPath := writeICD(t, usr, "50_mesa.json", "libEGL_mesa.so.0")
nvidiaPath := writeICD(t, etc, "10_nvidia.json", "/usr/lib/libEGL_nvidia.so.0")
if err := os.WriteFile(filepath.Join(etc, "broken.json"), []byte("{"), 0o644); err != nil {
t.Fatal(err)
}
mesa, nvidia := findEGLVendorICDs([]string{etc, usr})
if mesa != mesaPath {
t.Errorf("mesa = %q, want %q", mesa, mesaPath)
}
if nvidia != nvidiaPath {
t.Errorf("nvidia = %q, want %q", nvidia, nvidiaPath)
}
mesa, nvidia = findEGLVendorICDs([]string{filepath.Join(etc, "missing")})
if mesa != "" || nvidia != "" {
t.Errorf("missing dir: got %q %q, want empty", mesa, nvidia)
}
}
func TestDrmDeviceDriver(t *testing.T) {
root := t.TempDir()
charDir := filepath.Join(root, "char")
deviceDir := filepath.Join(charDir, "226:128", "device")
if err := os.MkdirAll(deviceDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink("../../../../bus/pci/drivers/amdgpu", filepath.Join(deviceDir, "driver")); err != nil {
t.Fatal(err)
}
driver, err := drmDeviceDriver(charDir, 226<<8|128)
if err != nil {
t.Fatal(err)
}
if driver != "amdgpu" {
t.Errorf("driver = %q, want amdgpu", driver)
}
if _, err := drmDeviceDriver(charDir, 226<<8|129); err == nil {
t.Error("unknown device: want error")
}
}
func TestMainDeviceFromEvent(t *testing.T) {
raw := make([]byte, 8)
binary.NativeEndian.PutUint64(raw, 226<<8|128)
dev, err := mainDeviceFromEvent(raw)
if err != nil {
t.Fatal(err)
}
if dev != 226<<8|128 {
t.Errorf("dev = %d", dev)
}
if _, err := mainDeviceFromEvent(raw[:4]); err == nil {
t.Error("short array: want error")
}
}
+9 -110
View File
@@ -34,12 +34,8 @@ var orderedConfigNames = []string{"niri", "hyprland", "ghostty", "kitty", "alacr
// Config holds all CLI parameters for unattended installation.
type Config struct {
Compositor string // "niri", "hyprland", or "mango"
Terminal string // "ghostty", "kitty", or "alacritty"
PrivescTool string // "sudo", "doas", or "run0"; empty means autodetect
GitAll bool // git variant for every dep that supports it
GitDeps []string // deps to force the git variant of
AllFeatures bool // enable all optional deps
Compositor string // "niri" or "hyprland"
Terminal string // "ghostty", "kitty", or "alacritty"
IncludeDeps []string
ExcludeDeps []string
ReplaceConfigs []string // specific configs to deploy (e.g. "niri", "ghostty")
@@ -47,7 +43,6 @@ type Config struct {
Yes bool
DankSearch bool // install danksearch and enable its user service
DankCalendar bool // install dankcalendar
DmsGreeter bool // install dms-greeter
}
// Runner orchestrates unattended (headless) installation.
@@ -73,10 +68,6 @@ func (r *Runner) GetLogChan() <-chan string {
func (r *Runner) Run() error {
r.log("Starting headless installation")
if err := r.applyPrivescTool(); err != nil {
return err
}
// 1. Parse compositor and terminal selections
wm, err := r.parseWindowManager()
if err != nil {
@@ -121,16 +112,14 @@ func (r *Runner) Run() error {
return fmt.Errorf("dependency detection failed: %w", err)
}
reinstallItems, err := r.applyGitVariants(dependencies, gitVariantChecker(distro, wm, dependencies))
if err != nil {
return err
}
// 5. Apply include/exclude filters and build the disabled-items map.
// Headless mode does not currently collect any explicit reinstall selections,
// so keep reinstallItems nil instead of constructing an always-empty map.
disabledItems, err := r.buildDisabledItems(dependencies)
if err != nil {
return err
}
var reinstallItems map[string]bool
// Print dependency summary
fmt.Fprintln(os.Stdout, "\nDependencies:")
@@ -140,9 +129,6 @@ func (r *Runner) Run() error {
if disabledItems[dep.Name] {
marker = " SKIP "
status = "(disabled)"
} else if reinstallItems[dep.Name] {
marker = " RE "
status = "(will reinstall)"
} else {
switch dep.Status {
case deps.StatusInstalled:
@@ -297,96 +283,15 @@ func (r *Runner) Run() error {
return nil
}
func (r *Runner) applyPrivescTool() error {
if r.cfg.PrivescTool == "" {
return nil
}
switch tool := privesc.Tool(strings.ToLower(r.cfg.PrivescTool)); tool {
case privesc.ToolSudo, privesc.ToolDoas, privesc.ToolRun0:
if err := privesc.SetTool(tool); err != nil {
return fmt.Errorf("--privesc: %w", err)
}
return nil
default:
return fmt.Errorf("invalid --privesc value %q: must be 'sudo', 'doas', or 'run0'", r.cfg.PrivescTool)
}
}
// CanToggle is not enough: some distros discard the variant (hyprland on Arch maps to stable either way)
func gitVariantChecker(distro distros.Distribution, wm deps.WindowManager, dependencies []deps.Dependency) func(name string) bool {
stable := distro.GetPackageMappingWithVariants(wm, map[string]deps.PackageVariant{})
allGit := make(map[string]deps.PackageVariant, len(dependencies))
for _, dep := range dependencies {
allGit[dep.Name] = deps.VariantGit
}
git := distro.GetPackageMappingWithVariants(wm, allGit)
return func(name string) bool {
mapping, ok := git[name]
return ok && mapping != stable[name]
}
}
func (r *Runner) applyGitVariants(dependencies []deps.Dependency, hasGitVariant func(name string) bool) (map[string]bool, error) {
reinstallItems := make(map[string]bool)
// InstallPackages skips StatusInstalled deps unless marked for reinstall
markGit := func(dep *deps.Dependency) {
if dep.Variant == deps.VariantStable && dep.Status == deps.StatusInstalled {
reinstallItems[dep.Name] = true
}
dep.Variant = deps.VariantGit
}
for _, name := range r.cfg.GitDeps {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
continue
}
dep := findDepFold(dependencies, name)
if dep == nil {
return nil, fmt.Errorf("--git-deps: unknown dependency %q", name)
}
if !dep.CanToggle || !hasGitVariant(dep.Name) {
return nil, fmt.Errorf("--git-deps: %q does not have a git variant on this distribution", dep.Name)
}
markGit(dep)
}
if !r.cfg.GitAll {
return reinstallItems, nil
}
for i := range dependencies {
if dependencies[i].CanToggle && hasGitVariant(dependencies[i].Name) {
markGit(&dependencies[i])
}
}
return reinstallItems, nil
}
func findDepFold(dependencies []deps.Dependency, lowerName string) *deps.Dependency {
if lowerName == "dms" {
lowerName = "dms (dankmaterialshell)"
}
for i := range dependencies {
if strings.ToLower(dependencies[i].Name) == lowerName {
return &dependencies[i]
}
}
return nil
}
// buildDisabledItems computes the set of dependencies that should be skipped
// during installation. Optional components are opt-in (disabled by default),
// then re-enabled by --all-features, the dedicated flags, and --include-deps.
// then re-enabled by the dedicated flags and --include-deps.
func (r *Runner) buildDisabledItems(dependencies []deps.Dependency) (map[string]bool, error) {
disabledItems := make(map[string]bool)
if !r.cfg.AllFeatures {
for i := range dependencies {
if !dependencies[i].Required {
disabledItems[dependencies[i].Name] = true
}
for i := range dependencies {
if !dependencies[i].Required {
disabledItems[dependencies[i].Name] = true
}
}
@@ -403,12 +308,6 @@ func (r *Runner) buildDisabledItems(dependencies []deps.Dependency) (map[string]
}
delete(disabledItems, "dankcalendar")
}
if r.cfg.DmsGreeter {
if !r.depExists(dependencies, "dms-greeter") {
return nil, fmt.Errorf("--dms-greeter: not available on this distribution")
}
delete(disabledItems, "dms-greeter")
}
// Process --include-deps (enable items that are disabled by default)
for _, name := range r.cfg.IncludeDeps {

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