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

Compare commits

..

4 Commits

Author SHA1 Message Date
LuckShiba 969b780ad1 niri: update embedded config to add optional=true 2026-07-07 22:20:32 -03:00
LuckShiba f5dc5061fd nix: fix tests 2026-07-07 22:19:39 -03:00
LuckShiba c44de46022 nix/niri: use optional=true in includes 2026-07-07 21:52:22 -03:00
LuckShiba 03ebf6693d nix: update flake.lock 2026-07-07 21:50:28 -03:00
252 changed files with 9825 additions and 35668 deletions
-23
View File
@@ -1,23 +0,0 @@
# Categories for GitHub's auto-generated release notes
changelog:
exclude:
authors:
- github-actions[bot]
- dependabot[bot]
labels:
- internal
categories:
- title: Breaking Changes
labels: [breaking]
- title: Features
labels: [feature]
- title: Fixes
labels: [fix]
- title: Packaging
labels: [packaging]
- title: Internationalization
labels: [i18n]
- title: Documentation
labels: [docs]
- title: Other Changes
labels: ["*"]
-170
View File
@@ -1,170 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "usage: $0 download|publish" >&2
exit 2
}
require_env() {
local name
for name in "$@"; do
if [[ -z "${!name:-}" ]]; then
echo "error: $name is required" >&2
exit 2
fi
done
}
require_env R2_BUCKET R2_ENDPOINT R2_PREFIX REPOSITORY_DIR
aws_r2() {
aws --endpoint-url "$R2_ENDPOINT" "$@"
}
download() {
mkdir -p "$REPOSITORY_DIR/current" "$REPOSITORY_DIR/previous-current"
aws_r2 s3 sync \
"s3://${R2_BUCKET}/${R2_PREFIX}/current/" \
"$REPOSITORY_DIR/current/" \
--only-show-errors
cp -a "$REPOSITORY_DIR/current/." "$REPOSITORY_DIR/previous-current/"
}
build_manifest() {
local packages='[]'
local file filename pkgver name version revision sha size
for file in "$REPOSITORY_DIR"/current/*.xbps; do
filename="$(basename "$file")"
pkgver="$(xbps-uhelper binpkgver "$filename")"
name="$(xbps-uhelper getpkgname "$pkgver")"
version="$(xbps-uhelper getpkgversion "$pkgver")"
revision="$(xbps-uhelper getpkgrevision "$pkgver")"
version="${version%_"${revision}"}"
sha="$(sha256sum "$file" | cut -d' ' -f1)"
size="$(stat -c '%s' "$file")"
packages="$(jq \
--arg name "$name" \
--arg version "$version" \
--arg revision "$revision" \
--arg filename "$filename" \
--arg sha256 "$sha" \
--argjson size "$size" \
'. + [{name: $name, version: $version, revision: $revision, filename: $filename, sha256: $sha256, size: $size}]' \
<<<"$packages")"
done
jq -n \
--arg repository "$R2_PREFIX" \
--arg source_commit "$SOURCE_COMMIT" \
--arg published_at "$PUBLISHED_AT" \
--argjson packages "$packages" \
'{schema: 1, repository: $repository, source_commit: $source_commit, published_at: $published_at, packages: $packages}' \
> "$REPOSITORY_DIR/current/manifest.json"
}
verify_immutable_packages() {
local file previous
for file in "$REPOSITORY_DIR"/current/*.xbps "$REPOSITORY_DIR"/current/*.sig2; do
previous="$REPOSITORY_DIR/previous-current/$(basename "$file")"
if [[ -f "$previous" ]] && ! cmp -s "$previous" "$file"; then
echo "error: refusing to replace immutable object $(basename "$file")" >&2
echo "bump the XBPS revision or version before publishing a changed build" >&2
exit 1
fi
done
}
archive_retired() {
local old filename
local archive_prefix="archive/${R2_PREFIX}/${PUBLISHED_AT//:/-}"
shopt -s nullglob
for old in "$REPOSITORY_DIR"/previous-current/*.xbps "$REPOSITORY_DIR"/previous-current/*.sig2; do
filename="$(basename "$old")"
if [[ ! -e "$REPOSITORY_DIR/current/$filename" ]]; then
aws_r2 s3 cp \
"$old" \
"s3://${R2_BUCKET}/${archive_prefix}/${filename}" \
--cache-control 'private,no-store' \
--only-show-errors
fi
done
}
upload_current() {
local file filename old
# Versioned package objects must exist before repodata can reference them.
for file in "$REPOSITORY_DIR"/current/*.xbps "$REPOSITORY_DIR"/current/*.sig2; do
filename="$(basename "$file")"
aws_r2 s3 cp \
"$file" \
"s3://${R2_BUCKET}/${R2_PREFIX}/current/${filename}" \
--cache-control 'public,max-age=31536000,immutable' \
--only-show-errors
done
aws_r2 s3 cp \
"$REPOSITORY_DIR/current/x86_64-repodata" \
"s3://${R2_BUCKET}/${R2_PREFIX}/current/x86_64-repodata" \
--cache-control 'no-cache' \
--only-show-errors
# The manifest is the publication marker and is always uploaded last.
aws_r2 s3 cp \
"$REPOSITORY_DIR/current/manifest.json" \
"s3://${R2_BUCKET}/${R2_PREFIX}/current/manifest.json" \
--cache-control 'no-cache' \
--only-show-errors
# Once the new index and marker are live, remove objects no longer referenced.
shopt -s nullglob
for old in "$REPOSITORY_DIR"/previous-current/*; do
filename="$(basename "$old")"
if [[ ! -e "$REPOSITORY_DIR/current/$filename" ]]; then
aws_r2 s3 rm \
"s3://${R2_BUCKET}/${R2_PREFIX}/current/${filename}" \
--only-show-errors
fi
done
}
publish() {
require_env SOURCE_COMMIT
PUBLISHED_AT="${PUBLISHED_AT:-$(date -u +'%Y-%m-%dT%H:%M:%SZ')}"
export PUBLISHED_AT
shopt -s nullglob
local packages=("$REPOSITORY_DIR"/current/*.xbps)
if (( ${#packages[@]} == 0 )); then
echo "error: refusing to publish an empty XBPS repository" >&2
exit 1
fi
[[ -s "$REPOSITORY_DIR/current/x86_64-repodata" ]] || {
echo "error: x86_64-repodata is missing or empty" >&2
exit 1
}
for file in "${packages[@]}"; do
[[ -s "${file}.sig2" ]] || {
echo "error: signature is missing for $(basename "$file")" >&2
exit 1
}
done
verify_immutable_packages
build_manifest
archive_retired
upload_current
}
case "${1:-}" in
download) download ;;
publish) publish ;;
*) usage ;;
esac
+2 -55
View File
@@ -7,17 +7,14 @@ on:
permissions: permissions:
contents: write contents: write
issues: write
jobs: jobs:
update-stable: update-stable:
# skip prerelease tags
if: ${{ !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Create GitHub App token - name: Create GitHub App token
id: app_token id: app_token
uses: actions/create-github-app-token@v2 uses: actions/create-github-app-token@v1
with: with:
app-id: ${{ secrets.APP_ID }} app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }} private-key: ${{ secrets.APP_PRIVATE_KEY }}
@@ -31,54 +28,4 @@ jobs:
- name: Push to stable branch - name: Push to stable branch
env: env:
GH_TOKEN: ${{ steps.app_token.outputs.token }} GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: | run: git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:refs/heads/stable --force
set -euo pipefail
# don't roll stable backwards
if git fetch origin stable --quiet 2>/dev/null; then
stable_tag=$(git describe --tags --abbrev=0 FETCH_HEAD 2>/dev/null || echo "v0.0.0")
newest=$(printf '%s\n%s\n' "$stable_tag" "${GITHUB_REF_NAME}" | sort -V | tail -1)
if [ "$newest" != "${GITHUB_REF_NAME}" ]; then
echo "skipping: ${GITHUB_REF_NAME} is older than stable (${stable_tag})"
exit 0
fi
fi
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" HEAD:refs/heads/stable --force
cut-release-branch:
# create stable-X.Y at each vX.Y.0 tag
if: ${{ !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}
- name: Create release branch
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
set -euo pipefail
if [[ ! "${GITHUB_REF_NAME}" =~ ^v([0-9]+)\.([0-9]+)\.0$ ]]; then
echo "not a vX.Y.0 tag, no release branch to cut"
exit 0
fi
branch="stable-${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
if git ls-remote --exit-code origin "refs/heads/${branch}" >/dev/null 2>&1; then
echo "${branch} already exists"
exit 0
fi
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" "HEAD:refs/heads/${branch}"
echo "created ${branch} at ${GITHUB_REF_NAME}"
GH_TOKEN="${{ secrets.GITHUB_TOKEN }}" gh label create "port ${branch}" \
--repo "${{ github.repository }}" \
--description "Auto-port merged PR to ${branch}" --color 0e8a16 ||
echo "label 'port ${branch}' may already exist"
+1 -1
View File
@@ -8,7 +8,7 @@ on:
- "core/**" - "core/**"
- ".github/workflows/go-ci.yml" - ".github/workflows/go-ci.yml"
pull_request: pull_request:
branches: [master, main, "stable-*"] branches: [master, main]
paths: paths:
- "core/**" - "core/**"
- ".github/workflows/go-ci.yml" - ".github/workflows/go-ci.yml"
+1 -1
View File
@@ -2,7 +2,7 @@ name: Nix flake and NixOS tests
on: on:
pull_request: pull_request:
branches: [master, main, "stable-*"] branches: [master, main]
paths: paths:
- "flake.*" - "flake.*"
- "distro/nix/**" - "distro/nix/**"
-98
View File
@@ -1,98 +0,0 @@
name: Point release
# Cuts vX.Y.Z from stable-X.Y: runs the port audit (warn-only), bumps
# quickshell/VERSION, tags, and dispatches the Release workflow. Distro
# builds are dispatched separately.
on:
workflow_dispatch:
inputs:
version:
description: "Point release version (e.g. 1.5.1)"
required: true
type: string
notify_issues:
description: "Comment on open related/fixes issues asking reporters to retest"
required: false
type: boolean
default: true
permissions:
contents: write
actions: write
concurrency:
group: point-release
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
env:
VERSION: ${{ inputs.version }}
steps:
- name: Validate version and derive branch
id: derive
run: |
set -euo pipefail
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::version must be X.Y.Z (got '$VERSION')"; exit 1
fi
echo "branch=stable-${VERSION%.*}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Checkout release branch
uses: actions/checkout@v6
with:
ref: ${{ steps.derive.outputs.branch }}
fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}
- name: Port audit (informational)
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
bash scripts/port-audit.sh "${{ steps.derive.outputs.branch }}" ||
echo "::warning::port audit failed; continuing"
- name: Bump VERSION, tag, and push
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: ${{ steps.derive.outputs.tag }}
BRANCH: ${{ steps.derive.outputs.branch }}
run: |
set -euo pipefail
if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
echo "::error::tag ${TAG} already exists"; exit 1
fi
git config user.name "dms-ci[bot]"
git config user.email "dms-ci[bot]@users.noreply.github.com"
echo "${TAG}" > quickshell/VERSION
git add quickshell/VERSION
git commit -m "bump VERSION to ${TAG}"
git tag "${TAG}"
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" "HEAD:${BRANCH}" "refs/tags/${TAG}"
- name: Dispatch Release workflow
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
gh workflow run release.yml --ref "${{ steps.derive.outputs.tag }}" \
-f tag="${{ steps.derive.outputs.tag }}" \
-f notify_issues="${{ inputs.notify_issues }}"
- name: Next steps
run: |
{
echo "## ${{ steps.derive.outputs.tag }} tagged on ${{ steps.derive.outputs.branch }} — Release workflow dispatched"
echo ""
echo "Distro builds are manual: run the per-distro workflows (COPR/OBS/PPA/XBPS) once the release is published."
} >> "$GITHUB_STEP_SUMMARY"
-30
View File
@@ -1,30 +0,0 @@
name: Port audit
# On-demand report of master commits not yet ported to a release branch.
# Updates the "Port status: <branch>" tracking issue and the step summary.
on:
workflow_dispatch:
inputs:
target:
description: "Release branch to audit (default: newest release/*)"
required: false
type: string
permissions:
contents: read
issues: write
jobs:
audit:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Run audit
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash scripts/port-audit.sh ${{ inputs.target }} --issue
-101
View File
@@ -1,101 +0,0 @@
name: Port to release branch
# Ports flagged commits from master onto stable-X.Y branches:
# - "port 1.5" flag in a commit message pushed to master — own line or
# mid-line; separators : / - or space; "stable-1.5" also accepted;
# comma lists ("Port: 1.5, 1.4") work in the own-line form
# - "port stable-1.5" label on a merged PR
# Conflicts are reported to the "Port status: <branch>" tracking issue.
on:
push:
branches: [master]
pull_request_target:
types: [closed, labeled]
permissions:
contents: write
issues: write
concurrency:
group: port-engine
cancel-in-progress: false
jobs:
port:
name: Port flagged commits
runs-on: ubuntu-latest
steps:
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
# Base-repo code only; PR head code is never checked out or executed.
- name: Checkout
uses: actions/checkout@v6
with:
ref: master
fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}
- name: Port trailer-flagged commits
if: github.event_name == 'push'
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
COMMITS: ${{ toJSON(github.event.commits) }}
run: |
set -euo pipefail
git config user.name "dms-ci[bot]"
git config user.email "dms-ci[bot]@users.noreply.github.com"
for sha in $(jq -r '.[].id' <<<"$COMMITS"); do
git cat-file -e "$sha" 2>/dev/null || continue
# skip merge commits (handled by the label path)
[ "$(git rev-list --no-walk --count --min-parents=2 "$sha")" -eq 0 ] || continue
# own-line form (supports comma lists), validated version-shaped
t1=$(git log -1 --format=%B "$sha" |
{ grep -iE '^Port[:/ -]' || true; } | sed -E 's|^port[-:/ ]+||I' | tr ',' '\n' |
sed 's/[[:space:]]//g; /^$/d' | sed -E 's,^stable-,,I' |
{ grep -E '^[0-9]+\.[0-9]+(\.[0-9]+)?$' || true; })
# mid-line form, e.g. "fix thing fixes #2802 port 1.5"
t2=$(git log -1 --format=%B "$sha" |
{ grep -oiE '\bport[-: /]+(stable-)?[0-9]+\.[0-9]+(\.[0-9]+)?\b' || true; } |
sed -E 's|^port[-:/ ]+||I' | sed -E 's,^stable-,,I')
targets=$(printf '%s\n%s\n' "$t1" "$t2" | sed '/^$/d' | sort -u)
for ver in $targets; do
echo "::group::port $sha -> stable-$ver"
bash scripts/port.sh "stable-$ver" "$sha"
echo "::endgroup::"
done
done
- name: Port label-flagged PR
if: >
github.event_name == 'pull_request_target' &&
github.event.pull_request.merged == true &&
(github.event.action == 'closed' ||
(github.event.action == 'labeled' && startsWith(github.event.label.name, 'port ')))
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
PORT_SOURCE_PR: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
git config user.name "dms-ci[bot]"
git config user.email "dms-ci[bot]@users.noreply.github.com"
targets=$(jq -r '.[] | select(startswith("port ")) | sub("^port +"; "")' <<<"$LABELS" |
sed -E 's,^stable-,,' | sort -u)
[ -n "$targets" ] || { echo "no port labels, nothing to do"; exit 0; }
[ -n "$MERGE_SHA" ] || { echo "::error::PR has no merge commit sha"; exit 1; }
for ver in $targets; do
echo "::group::port PR #${PORT_SOURCE_PR} ($MERGE_SHA) -> stable-$ver"
bash scripts/port.sh "stable-$ver" "$MERGE_SHA"
echo "::endgroup::"
done
+1 -1
View File
@@ -3,7 +3,7 @@ name: Pre-commit Checks
on: on:
push: push:
pull_request: pull_request:
branches: [master, main, "stable-*"] branches: [master, main]
jobs: jobs:
pre-commit-check: pre-commit-check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+4 -36
View File
@@ -7,16 +7,10 @@ on:
description: "Tag to release (e.g., v1.0.1)" description: "Tag to release (e.g., v1.0.1)"
required: true required: true
type: string type: string
notify_issues:
description: "Comment on open related/fixes issues asking reporters to retest"
required: false
type: boolean
default: false
permissions: permissions:
contents: write contents: write
actions: write actions: write
issues: write
concurrency: concurrency:
group: release-${{ inputs.tag }} group: release-${{ inputs.tag }}
@@ -211,23 +205,13 @@ jobs:
- name: Generate Changelog - name: Generate Changelog
id: changelog id: changelog
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
set -e set -e
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "") PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "")
CHANGELOG="" if [ -z "$PREVIOUS_TAG" ]; then
if [ -n "$PREVIOUS_TAG" ]; then CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /' | head -50)
# PR-based notes with author credits; falls back to raw git log below else
CHANGELOG=$(python3 scripts/release-notes.py "${PREVIOUS_TAG}..${TAG}" --format github --bare 2>/dev/null || true) CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" "${PREVIOUS_TAG}..${TAG}" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /')
fi
if [ -z "$CHANGELOG" ]; then
echo "release-notes.py unavailable or empty, using git log fallback"
if [ -z "$PREVIOUS_TAG" ]; then
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /' | head -50)
else
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" "${PREVIOUS_TAG}..${TAG}" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /')
fi
fi fi
cat > RELEASE_BODY.md << 'EOF' cat > RELEASE_BODY.md << 'EOF'
@@ -433,19 +417,3 @@ jobs:
prerelease: ${{ contains(env.TAG, '-') }} prerelease: ${{ contains(env.TAG, '-') }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Ask reporters on still-open "related/fixes #N" issues to retest.
# Opt-in via notify_issues (Point release defaults it on). Skipped for
# prereleases. Preview: scripts/notify-issues.sh <prev> <tag> --dry-run
- name: Notify open issues to retest
if: ${{ inputs.notify_issues && !contains(env.TAG, '-') }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)
if [ -z "$PREVIOUS_TAG" ]; then
echo "no previous tag; skipping issue notify"
exit 0
fi
bash scripts/notify-issues.sh "$PREVIOUS_TAG" "$TAG"
+39 -111
View File
@@ -3,15 +3,10 @@ name: Void Linux XBPS Repository
on: on:
schedule: schedule:
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown) - cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
# release trigger disabled; dispatch manually after a release release:
# release: types: [published]
# types: [published]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version:
description: 'Stable release tag/version to build (e.g. v1.5.0 or 1.5.0). Leave blank to use the selected ref/template version'
type: string
required: false
force_rebuild: force_rebuild:
description: 'Force rebuilding packages even if they already exist in the repository' description: 'Force rebuilding packages even if they already exist in the repository'
type: boolean type: boolean
@@ -34,63 +29,30 @@ on:
default: true default: true
permissions: permissions:
contents: read contents: write
concurrency:
group: void-xbps-${{ github.ref }}
cancel-in-progress: false
jobs: jobs:
preflight:
name: Check for unpublished changes
runs-on: ubuntu-latest
outputs:
should_build: ${{ steps.check.outputs.should_build }}
steps:
- name: Check published commit
id: check
env:
MANIFEST_URL: https://void.danklinux.com/dms/current/manifest.json
run: |
SHOULD_BUILD=true
if [ "${{ github.event_name }}" = "schedule" ]; then
PUBLISHED_COMMIT="$(curl -fsSL --retry 3 "$MANIFEST_URL" | jq -r '.source_commit // empty' 2>/dev/null || true)"
if [ "$PUBLISHED_COMMIT" = "${{ github.sha }}" ]; then
SHOULD_BUILD=false
echo "${{ github.sha }} is already published; skipping the scheduled build."
fi
fi
echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT"
build-and-deploy: build-and-deploy:
name: Build & Deploy XBPS packages name: Build & Deploy XBPS packages
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: preflight if: github.repository == 'AvengeMedia/DankMaterialShell'
if: github.repository == 'AvengeMedia/DankMaterialShell' && needs.preflight.outputs.should_build == 'true'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: true
R2_BUCKET: danklinux-void
R2_ENDPOINT: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com
R2_PREFIX: dms
REPOSITORY_DIR: ${{ github.workspace }}/r2-repo
SOURCE_COMMIT: ${{ github.sha }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
with: with:
ref: ${{ github.event.release.tag_name || (github.event.inputs.version && (startsWith(github.event.inputs.version, 'v') && github.event.inputs.version || format('v{0}', github.event.inputs.version))) || github.ref }}
fetch-depth: 0 fetch-depth: 0
- name: Record checked-out source commit - name: Checkout gh-pages branch
run: echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
- name: Download current R2 repository
run: | run: |
aws --version git clone --branch gh-pages https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git gh-pages-repo || {
bash .github/scripts/publish-void-r2.sh download echo "⚠️ gh-pages branch not found or empty, initializing a new one..."
mkdir gh-pages-repo
cd gh-pages-repo
git init
git checkout -b gh-pages
git remote add origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git
cd ..
}
- name: Install XBPS Static Utilities - name: Install XBPS Static Utilities
run: | run: |
@@ -164,19 +126,19 @@ jobs:
- name: Configure repositories - name: Configure repositories
run: | run: |
# Append the repository to repos-remote templates so xbps-src translates it automatically # Append the repository to repos-remote templates so xbps-src translates it automatically
echo "repository=https://void.danklinux.com/danklinux/current" >> void-packages/etc/xbps.d/repos-remote.conf echo "repository=https://avengemedia.github.io/DankLinux/current" >> void-packages/etc/xbps.d/repos-remote.conf
echo "repository=https://void.danklinux.com/danklinux/current" >> void-packages/etc/xbps.d/repos-remote-x86_64-multilib.conf echo "repository=https://avengemedia.github.io/DankLinux/current" >> void-packages/etc/xbps.d/repos-remote-x86_64-multilib.conf
# Add any existing compiled packages to the build cache directory to avoid rebuilds # Add any existing compiled packages to the build cache directory to avoid rebuilds
if [ -d "r2-repo/current" ]; then if [ -d "gh-pages-repo/current" ]; then
mkdir -p void-packages/hostdir/binpkgs mkdir -p void-packages/hostdir/binpkgs
cp -L r2-repo/current/*.xbps void-packages/hostdir/binpkgs/ 2>/dev/null || true cp -L gh-pages-repo/current/*.xbps void-packages/hostdir/binpkgs/ 2>/dev/null || true
xbps-rindex -a void-packages/hostdir/binpkgs/*.xbps 2>/dev/null || true xbps-rindex -a void-packages/hostdir/binpkgs/*.xbps 2>/dev/null || true
fi fi
- name: Build packages - name: Build packages
run: | run: |
mkdir -p r2-repo/current mkdir -p gh-pages-repo/current
BUILD_DMS="${{ github.event.inputs.build_dms || 'true' }}" BUILD_DMS="${{ github.event.inputs.build_dms || 'true' }}"
BUILD_GREETER="${{ github.event.inputs.build_greeter || 'true' }}" BUILD_GREETER="${{ github.event.inputs.build_greeter || 'true' }}"
@@ -195,25 +157,6 @@ jobs:
BUILD_GIT="false" BUILD_GIT="false"
fi fi
RELEASE_TAG=""
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_TAG="${{ github.event.release.tag_name }}"
elif [ -n "${{ github.event.inputs.version || '' }}" ]; then
RELEASE_TAG="${{ github.event.inputs.version }}"
elif [ "${{ github.ref_type }}" = "tag" ]; then
RELEASE_TAG="${{ github.ref_name }}"
fi
if [ -n "$RELEASE_TAG" ]; then
RELEASE_VER="${RELEASE_TAG#v}"
ARCHIVE_TAG="v${RELEASE_VER}"
echo "Stable release tag: $ARCHIVE_TAG"
else
RELEASE_VER=""
ARCHIVE_TAG=""
echo "Stable release tag: (template version)"
fi
echo "=== Starting Builds ===" echo "=== Starting Builds ==="
echo "DMS stable build enabled: $BUILD_DMS" echo "DMS stable build enabled: $BUILD_DMS"
echo "Greeter stable build enabled: $BUILD_GREETER" echo "Greeter stable build enabled: $BUILD_GREETER"
@@ -222,19 +165,6 @@ jobs:
cd void-packages cd void-packages
if [ -n "$RELEASE_VER" ] && { [ "$BUILD_DMS" = "true" ] || [ "$BUILD_GREETER" = "true" ]; }; then
echo "🔧 Updating stable templates for $ARCHIVE_TAG"
TARBALL="$(mktemp)"
curl -fsSL -o "$TARBALL" "https://github.com/${{ github.repository }}/archive/refs/tags/${ARCHIVE_TAG}.tar.gz"
RELEASE_CHECKSUM="$(sha256sum "$TARBALL" | cut -d' ' -f1)"
rm -f "$TARBALL"
for pkg in dms dms-greeter; do
sed -i "s/^version=.*/version=${RELEASE_VER}/" "srcpkgs/${pkg}/template"
sed -i "s/^checksum=.*/checksum=${RELEASE_CHECKSUM}/" "srcpkgs/${pkg}/template"
done
fi
# 1. Build dms-git (development package) # 1. Build dms-git (development package)
if [ "$BUILD_GIT" = "true" ]; then if [ "$BUILD_GIT" = "true" ]; then
# Calculate dynamic git version (tag.commits.hash) # Calculate dynamic git version (tag.commits.hash)
@@ -248,7 +178,7 @@ jobs:
mkdir -p "$SRC_CACHE" mkdir -p "$SRC_CACHE"
tar -czf "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" \ tar -czf "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" \
--exclude=void-packages \ --exclude=void-packages \
--exclude=r2-repo \ --exclude=gh-pages-repo \
--exclude=.git \ --exclude=.git \
--exclude=danklinux \ --exclude=danklinux \
-C .. . -C .. .
@@ -262,13 +192,13 @@ jobs:
EXPECTED_GIT_FILE="dms-git-${GIT_VER}_1.x86_64.xbps" EXPECTED_GIT_FILE="dms-git-${GIT_VER}_1.x86_64.xbps"
if [ -f "../r2-repo/current/$EXPECTED_GIT_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then if [ -f "../gh-pages-repo/current/$EXPECTED_GIT_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
echo "✅ $EXPECTED_GIT_FILE already exists, skipping build." echo "✅ $EXPECTED_GIT_FILE already exists, skipping build."
else else
echo "🔨 Compiling dms-git..." echo "🔨 Compiling dms-git..."
./xbps-src pkg dms-git ./xbps-src pkg dms-git
rm -f "../r2-repo/current/${EXPECTED_GIT_FILE}" rm -f "../gh-pages-repo/current/${EXPECTED_GIT_FILE}"
cp -L "hostdir/binpkgs/${EXPECTED_GIT_FILE}" ../r2-repo/current/ cp -L hostdir/binpkgs/dms-git-*.xbps ../gh-pages-repo/current/
fi fi
fi fi
@@ -278,13 +208,13 @@ jobs:
STABLE_REV=$(grep -E '^revision=' srcpkgs/dms/template | cut -d= -f2 | tr -d '"') STABLE_REV=$(grep -E '^revision=' srcpkgs/dms/template | cut -d= -f2 | tr -d '"')
EXPECTED_DMS_FILE="dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps" EXPECTED_DMS_FILE="dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps"
if [ -f "../r2-repo/current/$EXPECTED_DMS_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then if [ -f "../gh-pages-repo/current/$EXPECTED_DMS_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
echo "✅ $EXPECTED_DMS_FILE already exists, skipping build." echo "✅ $EXPECTED_DMS_FILE already exists, skipping build."
else else
echo "🔨 Compiling dms ($STABLE_VER)..." echo "🔨 Compiling dms ($STABLE_VER)..."
./xbps-src pkg dms ./xbps-src pkg dms
rm -f "../r2-repo/current/${EXPECTED_DMS_FILE}" rm -f "../gh-pages-repo/current/${EXPECTED_DMS_FILE}"
cp -L hostdir/binpkgs/dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps ../r2-repo/current/ cp -L hostdir/binpkgs/dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps ../gh-pages-repo/current/
fi fi
fi fi
@@ -294,21 +224,22 @@ jobs:
GREETER_REV=$(grep -E '^revision=' srcpkgs/dms-greeter/template | cut -d= -f2 | tr -d '"') GREETER_REV=$(grep -E '^revision=' srcpkgs/dms-greeter/template | cut -d= -f2 | tr -d '"')
EXPECTED_GREETER_FILE="dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps" EXPECTED_GREETER_FILE="dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps"
if [ -f "../r2-repo/current/$EXPECTED_GREETER_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then if [ -f "../gh-pages-repo/current/$EXPECTED_GREETER_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
echo "✅ $EXPECTED_GREETER_FILE already exists, skipping build." echo "✅ $EXPECTED_GREETER_FILE already exists, skipping build."
else else
echo "🔨 Compiling dms-greeter ($GREETER_VER)..." echo "🔨 Compiling dms-greeter ($GREETER_VER)..."
./xbps-src pkg dms-greeter ./xbps-src pkg dms-greeter
rm -f "../r2-repo/current/${EXPECTED_GREETER_FILE}" rm -f "../gh-pages-repo/current/${EXPECTED_GREETER_FILE}"
cp -L hostdir/binpkgs/dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps ../r2-repo/current/ cp -L hostdir/binpkgs/dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps ../gh-pages-repo/current/
fi fi
fi fi
- name: Index and sign repository - name: Index and sign repository
run: | run: |
cd r2-repo/current cd gh-pages-repo/current
rm -f *.sig # Clean up any stale or dangling signature files to prevent O_CREAT ENOENT errors
rm -f *.sig2 *.sig
# Guard: nothing to index if no .xbps files exist # Guard: nothing to index if no .xbps files exist
if ! ls *.xbps 1>/dev/null 2>&1; then if ! ls *.xbps 1>/dev/null 2>&1; then
@@ -318,14 +249,6 @@ jobs:
# Regenerate repo index # Regenerate repo index
xbps-rindex -a $(pwd)/*.xbps xbps-rindex -a $(pwd)/*.xbps
xbps-rindex --remove-obsoletes $(pwd)
# Remove signatures left behind by obsolete packages. Signatures for
# unchanged immutable packages are retained byte-for-byte.
for sig in *.sig2; do
[ -e "$sig" ] || break
[ -f "${sig%.sig2}" ] || rm -f "$sig"
done
# Sign repository # Sign repository
echo "${{ secrets.XBPS_PRIVATE_KEY }}" > /tmp/xbps_privkey.pem echo "${{ secrets.XBPS_PRIVATE_KEY }}" > /tmp/xbps_privkey.pem
@@ -336,5 +259,10 @@ jobs:
rm -f /tmp/xbps_privkey.pem rm -f /tmp/xbps_privkey.pem
- name: Publish repository to R2 - name: Deploy to gh-pages branch
run: bash .github/scripts/publish-void-r2.sh publish run: |
cd gh-pages-repo
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add current/
git diff --quiet && git diff --staged --quiet || (git commit -m "Update XBPS packages [skip ci]" && git push origin gh-pages)
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
steps: steps:
- name: Create GitHub App token - name: Create GitHub App token
id: app_token id: app_token
uses: actions/create-github-app-token@v2 uses: actions/create-github-app-token@v1
with: with:
app-id: ${{ secrets.APP_ID }} app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }} private-key: ${{ secrets.APP_PRIVATE_KEY }}
-3
View File
@@ -126,6 +126,3 @@ __pycache__
distro/void/temp/ distro/void/temp/
distro/void/hostdir/ distro/void/hostdir/
distro/void/masterdir*/ distro/void/masterdir*/
# Often gets built
core/dms
+3 -3
View File
@@ -6,8 +6,8 @@ BINARY_NAME=dms
CORE_DIR=core CORE_DIR=core
BUILD_DIR=$(CORE_DIR)/bin BUILD_DIR=$(CORE_DIR)/bin
PREFIX ?= /usr/local PREFIX ?= /usr/local
INSTALL_DIR=$(DESTDIR)$(PREFIX)/bin INSTALL_DIR=$(PREFIX)/bin
DATA_DIR=$(DESTDIR)$(PREFIX)/share DATA_DIR=$(PREFIX)/share
ICON_DIR=$(DATA_DIR)/icons/hicolor/scalable/apps ICON_DIR=$(DATA_DIR)/icons/hicolor/scalable/apps
USER_HOME := $(if $(SUDO_USER),$(shell getent passwd $(SUDO_USER) | cut -d: -f6),$(HOME)) USER_HOME := $(if $(SUDO_USER),$(shell getent passwd $(SUDO_USER) | cut -d: -f6),$(HOME))
@@ -62,7 +62,7 @@ install-systemd:
@echo "Installing systemd user service..." @echo "Installing systemd user service..."
@mkdir -p $(SYSTEMD_USER_DIR) @mkdir -p $(SYSTEMD_USER_DIR)
@if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR); fi @if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR); fi
@sed 's|/usr/bin/dms|$(PREFIX)/bin/dms|g' $(ASSETS_DIR)/systemd/dms.service > $(SYSTEMD_USER_DIR)/dms.service @sed 's|/usr/bin/dms|$(INSTALL_DIR)/dms|g' $(ASSETS_DIR)/systemd/dms.service > $(SYSTEMD_USER_DIR)/dms.service
@chmod 644 $(SYSTEMD_USER_DIR)/dms.service @chmod 644 $(SYSTEMD_USER_DIR)/dms.service
@if [ -n "$(SUDO_USER)" ]; then chown $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR)/dms.service; fi @if [ -n "$(SUDO_USER)" ]; then chown $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR)/dms.service; fi
@echo "Systemd service installed to $(SYSTEMD_USER_DIR)/dms.service" @echo "Systemd service installed to $(SYSTEMD_USER_DIR)/dms.service"
+1 -1
View File
@@ -206,7 +206,7 @@ Uses COPR repositories (`avengemedia/danklinux`, `avengemedia/dms`).
Requires PPA support. Most packages built from source (slow first install). Requires PPA support. Most packages built from source (slow first install).
**Debian** **Debian**
Debian 13+ (Trixie), testing, and sid. Stable quickshell from Debian (trixie-backports on 13). Other companions via OBS. Debian 13+ (Trixie). niri only, no Hyprland support. Builds from source.
**openSUSE** **openSUSE**
Most packages available in standard repos. Minimal building required. Most packages available in standard repos. Minimal building required.
-8
View File
@@ -23,8 +23,6 @@ var (
replaceConfigs []string replaceConfigs []string
replaceConfigsAll bool replaceConfigsAll bool
yes bool yes bool
danksearch bool
dankcalendar bool
) )
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
@@ -51,8 +49,6 @@ func init() {
rootCmd.Flags().StringSliceVar(&replaceConfigs, "replace-configs", []string{}, "Deploy only named configs (e.g. niri,ghostty)") rootCmd.Flags().StringSliceVar(&replaceConfigs, "replace-configs", []string{}, "Deploy only named configs (e.g. niri,ghostty)")
rootCmd.Flags().BoolVar(&replaceConfigsAll, "replace-configs-all", false, "Deploy and replace all configurations") rootCmd.Flags().BoolVar(&replaceConfigsAll, "replace-configs-all", false, "Deploy and replace all configurations")
rootCmd.Flags().BoolVarP(&yes, "yes", "y", false, "Auto-confirm all prompts") rootCmd.Flags().BoolVarP(&yes, "yes", "y", false, "Auto-confirm all prompts")
rootCmd.Flags().BoolVar(&danksearch, "danksearch", false, "Install danksearch and enable its user indexing service")
rootCmd.Flags().BoolVar(&dankcalendar, "dankcalendar", false, "Install dankcalendar")
} }
func main() { func main() {
@@ -78,8 +74,6 @@ func runDankinstall(cmd *cobra.Command, args []string) error {
"replace-configs", "replace-configs",
"replace-configs-all", "replace-configs-all",
"yes", "yes",
"danksearch",
"dankcalendar",
} }
var set []string var set []string
for _, name := range headlessOnly { for _, name := range headlessOnly {
@@ -115,8 +109,6 @@ func runHeadless() error {
ReplaceConfigs: replaceConfigs, ReplaceConfigs: replaceConfigs,
ReplaceConfigsAll: replaceConfigsAll, ReplaceConfigsAll: replaceConfigsAll,
Yes: yes, Yes: yes,
DankSearch: danksearch,
DankCalendar: dankcalendar,
} }
runner := headless.NewRunner(cfg) runner := headless.NewRunner(cfg)
-122
View File
@@ -1,10 +1,7 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"os"
"path/filepath"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
@@ -38,128 +35,9 @@ var authSyncCmd = &cobra.Command{
}, },
} }
var authResolveLockCmd = &cobra.Command{
Use: "resolve-lock",
Short: "Generate the lock-screen PAM config from the system auth stack",
Long: "Resolve the distribution's PAM auth stack into a self-contained lock-screen config under the user state directory.\n" +
"Runs unprivileged (reads /etc/pam.d, writes to the user's state dir) and is used by the shell as a fallback when /etc/pam.d/dankshell is not managed.\n" +
"Prints the path of the generated file.",
Run: func(cmd *cobra.Command, args []string) {
quiet, _ := cmd.Flags().GetBool("quiet")
logFunc := func(msg string) {
if !quiet {
fmt.Println(msg)
}
}
path, err := sharedpam.WriteUserLockscreenPamConfig(logFunc)
if err != nil {
log.Fatalf("Error resolving lock-screen PAM config: %v", err)
}
fmt.Println(path)
},
}
var authListServicesCmd = &cobra.Command{
Use: "list-services",
Short: "List candidate lock-screen PAM services available on this system",
Long: "Enumerate the lock-screen PAM services that exist on this system and report their resolved auth stack (whether it has an auth directive and whether fingerprint/U2F modules appear inline).",
Run: func(cmd *cobra.Command, args []string) {
asJSON, _ := cmd.Flags().GetBool("json")
services := sharedpam.ListLockscreenPamServices()
if asJSON {
payload := struct {
Services []sharedpam.LockscreenPamServiceInfo `json:"services"`
}{Services: services}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
log.Fatalf("Error encoding services: %v", err)
}
fmt.Println(string(data))
return
}
if len(services) == 0 {
fmt.Println("No candidate lock-screen PAM services found.")
return
}
for _, s := range services {
fmt.Printf("%-20s %-30s auth=%-5t fingerprint=%-5t u2f=%t\n", s.Name, s.Path, s.HasAuth, s.InlineFingerprint, s.InlineU2f)
}
},
}
var authValidateCmd = &cobra.Command{
Use: "validate",
Short: "Validate a PAM service file for use as the DMS lock-screen password stack",
Long: "Validate one PAM service (by --service NAME or --path /abs/file) for use as the DMS lock-screen password stack. Exits 1 when the file is not usable.",
Run: func(cmd *cobra.Command, args []string) {
path, _ := cmd.Flags().GetString("path")
service, _ := cmd.Flags().GetString("service")
asJSON, _ := cmd.Flags().GetBool("json")
if (path == "") == (service == "") {
log.Fatalf("Error: exactly one of --path or --service is required")
}
var result sharedpam.LockscreenPamValidation
switch {
case service != "":
result = sharedpam.ValidateLockscreenPamService(service)
case !filepath.IsAbs(path):
result = sharedpam.LockscreenPamValidation{
Path: path,
MissingModules: []string{},
Warnings: []string{},
Errors: []string{"--path must be an absolute file path"},
}
default:
result = sharedpam.ValidateLockscreenPamPath(path)
}
if asJSON {
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
log.Fatalf("Error encoding validation: %v", err)
}
fmt.Println(string(data))
} else {
printLockscreenPamValidation(result)
}
if !result.Valid {
os.Exit(1)
}
},
}
func printLockscreenPamValidation(result sharedpam.LockscreenPamValidation) {
fmt.Printf("Path: %s\n", result.Path)
fmt.Printf("Valid: %t\n", result.Valid)
fmt.Printf("Has auth: %t\n", result.HasAuth)
fmt.Printf("Inline fingerprint: %t\n", result.InlineFingerprint)
fmt.Printf("Inline U2F: %t\n", result.InlineU2f)
if len(result.MissingModules) > 0 {
fmt.Printf("Missing modules: %s\n", strings.Join(result.MissingModules, ", "))
}
for _, w := range result.Warnings {
fmt.Println("⚠ " + w)
}
for _, e := range result.Errors {
fmt.Println("✗ " + e)
}
}
func init() { func init() {
authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts") authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts")
authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)") authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)")
authResolveLockCmd.Flags().BoolP("quiet", "q", false, "Only print the resulting file path")
authListServicesCmd.Flags().Bool("json", false, "Output as JSON")
authValidateCmd.Flags().String("path", "", "Absolute path to a PAM service file to validate")
authValidateCmd.Flags().String("service", "", "Name of a PAM service to resolve across the system PAM dirs")
authValidateCmd.Flags().Bool("json", false, "Output as JSON")
} }
func syncAuth(nonInteractive bool) error { func syncAuth(nonInteractive bool) error {
-1
View File
@@ -759,7 +759,6 @@ func getCommonCommands() []*cobra.Command {
greeterCmd, greeterCmd,
setupCmd, setupCmd,
colorCmd, colorCmd,
qrCmd,
screenshotCmd, screenshotCmd,
notifyActionCmd, notifyActionCmd,
notifyCmd, notifyCmd,
-25
View File
@@ -320,7 +320,6 @@ func checkEnvironmentVars() []checkResult {
var results []checkResult var results []checkResult
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME")...) results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME")...)
results = append(results, checkEnvVar("QS_ICON_THEME")...) results = append(results, checkEnvVar("QS_ICON_THEME")...)
results = append(results, checkXDGMenuPrefix()...)
return results return results
} }
@@ -335,29 +334,6 @@ func checkEnvVar(name string) []checkResult {
return nil return nil
} }
func checkXDGMenuPrefix() []checkResult {
menuPrefix := os.Getenv("XDG_MENU_PREFIX")
if menuPrefix != "" {
if checkXDGMenuFile(menuPrefix) {
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusInfo, menuPrefix, "", doctorDocsURL + "#xdg-menu-prefix"}}
}
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusWarn, fmt.Sprintf("%s (menu file not found)", menuPrefix), fmt.Sprintf("Dolphin 'Open with…' dialog may be empty. Ensure /etc/xdg/menus/%sapplications.menu exists.", menuPrefix), doctorDocsURL + "#xdg-menu-prefix"}}
}
if _, err := exec.LookPath("keditfiletype"); err == nil {
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusWarn, "Not set", "Dolphin file associations and 'Open with…' dialog may be empty. Set XDG_MENU_PREFIX=plasma- in your compositor's environment block.", doctorDocsURL + "#xdg-menu-prefix"}}
}
if doctorVerbose {
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusInfo, "Not set", "", doctorDocsURL + "#xdg-menu-prefix"}}
}
return nil
}
func checkXDGMenuFile(prefix string) bool {
menuPath := fmt.Sprintf("/etc/xdg/menus/%sapplications.menu", prefix)
_, err := os.Stat(menuPath)
return err == nil
}
func readOSRelease() map[string]string { func readOSRelease() map[string]string {
result := make(map[string]string) result := make(map[string]string)
data, err := os.ReadFile("/etc/os-release") data, err := os.ReadFile("/etc/os-release")
@@ -910,7 +886,6 @@ func checkOptionalDependencies() []checkResult {
{"cava", "cava", "Audio visualizer", true}, {"cava", "cava", "Audio visualizer", true},
{"khal", "khal", "Calendar events", false}, {"khal", "khal", "Calendar events", false},
{"danksearch", "dsearch", "File search", false}, {"danksearch", "dsearch", "File search", false},
{"dankcalendar", "dcal", "Calendar app", false},
{"fprintd", "fprintd-list", "Fingerprint auth", false}, {"fprintd", "fprintd-list", "Fingerprint auth", false},
} }
+6 -23
View File
@@ -1081,11 +1081,8 @@ func ensureGreetdEnabled() error {
if !runitServiceInstalled("greetd") { if !runitServiceInstalled("greetd") {
return fmt.Errorf("greetd service not found in %s. Please install greetd first", runitSvDir) return fmt.Errorf("greetd service not found in %s. Please install greetd first", runitSvDir)
} }
if greeter.IsVoidLinux() { // Seat + runtime-dir setup that logind handles automatically on systemd.
ensureVoidLogindGreeter("_greeter") ensureRunitSeat("_greeter")
} else {
ensureRunitSeat("_greeter")
}
ensureGreetdPamRundir() ensureGreetdPamRundir()
if err := enableRunitService("greetd"); err != nil { if err := enableRunitService("greetd"); err != nil {
return fmt.Errorf("failed to enable greetd: %w", err) return fmt.Errorf("failed to enable greetd: %w", err)
@@ -1264,9 +1261,6 @@ func enableGreeter(nonInteractive bool) error {
if err := greeter.EnsureGreeterCacheDir(logFunc, ""); err != nil { if err := greeter.EnsureGreeterCacheDir(logFunc, ""); err != nil {
fmt.Printf("⚠ Could not ensure cache directory: %v\n Run: sudo mkdir -p %s && sudo chown root:%s %s && sudo chmod 2770 %s\n", err, greeter.GreeterCacheDir, greeterGroup, greeter.GreeterCacheDir, greeter.GreeterCacheDir) fmt.Printf("⚠ Could not ensure cache directory: %v\n Run: sudo mkdir -p %s && sudo chown root:%s %s && sudo chmod 2770 %s\n", err, greeter.GreeterCacheDir, greeterGroup, greeter.GreeterCacheDir, greeter.GreeterCacheDir)
} }
if err := greeter.EnsureVoidLogindGreetdCommand(logFunc, ""); err != nil {
return err
}
if err := ensureGraphicalTarget(); err != nil { if err := ensureGraphicalTarget(); err != nil {
return err return err
@@ -1481,23 +1475,12 @@ func extractGreeterWrapperFromCommand(command string) string {
if len(tokens) == 0 { if len(tokens) == 0 {
return "" return ""
} }
wrapperIndex := 0 wrapper := strings.Trim(tokens[0], "\"")
if filepath.Base(strings.Trim(tokens[0], "\"")) == "env" {
wrapperIndex = 1
for wrapperIndex < len(tokens) && strings.Contains(tokens[wrapperIndex], "=") {
wrapperIndex++
}
}
if wrapperIndex >= len(tokens) {
return ""
}
wrapper := strings.Trim(tokens[wrapperIndex], "\"")
if wrapper == "" { if wrapper == "" {
return "" return ""
} }
if wrapperIndex+1 < len(tokens) { if len(tokens) > 1 {
next := strings.Trim(tokens[wrapperIndex+1], "\"") next := strings.Trim(tokens[1], "\"")
if next != "" && (filepath.Base(wrapper) == "bash" || filepath.Base(wrapper) == "sh") && strings.Contains(filepath.Base(next), "dms-greeter") { if next != "" && (filepath.Base(wrapper) == "bash" || filepath.Base(wrapper) == "sh") && strings.Contains(filepath.Base(next), "dms-greeter") {
return fmt.Sprintf("%s (script: %s)", wrapper, next) return fmt.Sprintf("%s (script: %s)", wrapper, next)
} }
@@ -1552,7 +1535,7 @@ func packageInstallHint() string {
case distros.FamilyArch: case distros.FamilyArch:
return "Install from AUR with 'paru -S greetd-dms-greeter-git' or 'yay -S greetd-dms-greeter-git'" return "Install from AUR with 'paru -S greetd-dms-greeter-git' or 'yay -S greetd-dms-greeter-git'"
case distros.FamilyVoid: 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)" return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=https://avengemedia.github.io/DankMaterialShell/current' | sudo tee /etc/xbps.d/dms.conf)"
default: default:
return "Run 'dms greeter install' to install greeter" return "Run 'dms greeter install' to install greeter"
} }
-7
View File
@@ -101,13 +101,6 @@ func TestGreeterStatusStateDirHonorsExplicitOverrideOnNixOS(t *testing.T) {
} }
} }
func TestExtractGreeterWrapperFromEnvCommand(t *testing.T) {
command := "env LIBSEAT_BACKEND=logind DMS_VOID=1 /usr/bin/dms-greeter --command niri"
if got := extractGreeterWrapperFromCommand(command); got != "/usr/bin/dms-greeter" {
t.Fatalf("extractGreeterWrapperFromCommand() = %q, want %q", got, "/usr/bin/dms-greeter")
}
}
func TestRejectNixOSGreeterMutationBlocksImperativeCommands(t *testing.T) { func TestRejectNixOSGreeterMutationBlocksImperativeCommands(t *testing.T) {
origGreeterIsNixOSFn := greeterIsNixOSFn origGreeterIsNixOSFn := greeterIsNixOSFn
greeterIsNixOSFn = func() bool { return true } greeterIsNixOSFn = func() bool { return true }
-265
View File
@@ -1,265 +0,0 @@
package main
import (
"fmt"
"io"
"os"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/qrcode"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
)
var (
qrEcc string
qrVersion int
qrOutput string
qrStdout bool
qrClipboard bool
qrCopyText bool
qrRender bool
qrNoRender bool
qrInvert bool
qrQuietZone int
qrModuleSize int
qrFg string
qrBg string
qrTransparent bool
qrBorder int
qrShape string
qrLogo string
qrLogoScale int
qrWifiPassword string
qrWifiSecurity string
qrWifiHidden bool
)
var qrCmd = &cobra.Command{
Use: "qr [text]",
Short: "Generate QR codes",
Long: `Generate a QR code from text, stdin, or a WiFi network.
By default the code is rendered to the terminal when stdout is a TTY, or
written as PNG bytes to stdout when piped. Use flags to also copy to the
clipboard, save a PNG, or tune encoding and colors.
Input:
dms qr "https://example.com" # encode an argument
echo -n "data" | dms qr # encode stdin
dms qr - # encode stdin explicitly
Output (combine freely):
dms qr "text" --clipboard # copy PNG image to clipboard
dms qr "text" --copy-text # copy the source text to clipboard
dms qr "text" -o code.png # save a PNG file
dms qr "text" > code.png # PNG to stdout (piped)
dms qr "text" --render # force terminal render
Encoding & style:
--ecc L|M|Q|H # error correction (default M)
--qr-version 10 # force symbol version (1-40)
--module-size 12 --fg '#000' ... # PNG sizing and colors
--shape circle # round modules
--logo icon.png # center logo (bumps --ecc to H)
--invert # flip colors for light terminals
WiFi:
dms qr wifi MySSID -p secret # build from an explicit password
dms qr wifi MySSID # pull the saved secret from the shell`,
Args: cobra.ArbitraryArgs,
Run: runQR,
}
var qrWifiCmd = &cobra.Command{
Use: "wifi <ssid>",
Short: "Generate a WiFi QR code",
Long: `Generate a QR code that joins a WiFi network when scanned.
With --password the code is built entirely offline. Without it, the saved
credentials are fetched from the running DMS shell (like the network panel).`,
Args: cobra.ExactArgs(1),
Run: runQRWifi,
}
func init() {
qrCmd.PersistentFlags().StringVar(&qrEcc, "ecc", "", "Error correction level: L, M, Q, H (default M, or H with --logo)")
qrCmd.PersistentFlags().IntVar(&qrVersion, "qr-version", 0, "Force QR symbol version 1-40 (0 = auto)")
qrCmd.PersistentFlags().StringVarP(&qrOutput, "output", "o", "", "Write a PNG to this file")
qrCmd.PersistentFlags().BoolVar(&qrStdout, "stdout", false, "Write PNG bytes to stdout")
qrCmd.PersistentFlags().BoolVar(&qrClipboard, "clipboard", false, "Copy the PNG image to the clipboard")
qrCmd.PersistentFlags().BoolVar(&qrCopyText, "copy-text", false, "Copy the source text to the clipboard")
qrCmd.PersistentFlags().BoolVar(&qrRender, "render", false, "Force terminal rendering")
qrCmd.PersistentFlags().BoolVar(&qrNoRender, "no-render", false, "Never render to the terminal")
qrCmd.PersistentFlags().BoolVar(&qrInvert, "invert", false, "Swap colors (for light terminals)")
qrCmd.PersistentFlags().IntVar(&qrQuietZone, "quiet-zone", 2, "Terminal margin in modules")
qrCmd.PersistentFlags().IntVar(&qrModuleSize, "module-size", 0, "PNG pixels per module (0 = auto)")
qrCmd.PersistentFlags().StringVar(&qrFg, "fg", "", "Dark module color (#RGB or #RRGGBB)")
qrCmd.PersistentFlags().StringVar(&qrBg, "bg", "", "Light module color (#RGB or #RRGGBB)")
qrCmd.PersistentFlags().BoolVar(&qrTransparent, "transparent", false, "Transparent PNG background")
qrCmd.PersistentFlags().IntVar(&qrBorder, "border", -1, "PNG border in pixels (-1 = auto)")
qrCmd.PersistentFlags().StringVar(&qrShape, "shape", "square", "PNG module shape (square, circle)")
qrCmd.PersistentFlags().StringVar(&qrLogo, "logo", "", "Center a PNG/JPEG logo on the PNG output")
qrCmd.PersistentFlags().IntVar(&qrLogoScale, "logo-scale", 0, "Max logo size as 1/N of the code (0 = library default of 5)")
qrWifiCmd.Flags().StringVarP(&qrWifiPassword, "password", "p", "", "WiFi password (offline build)")
qrWifiCmd.Flags().StringVar(&qrWifiSecurity, "security", "WPA", "Security type (WPA, WEP, nopass)")
qrWifiCmd.Flags().BoolVar(&qrWifiHidden, "hidden", false, "Mark the network as hidden")
qrCmd.AddCommand(qrWifiCmd)
}
func runQR(cmd *cobra.Command, args []string) {
text := strings.Join(args, " ")
if text == "" || text == "-" {
data, err := io.ReadAll(os.Stdin)
if err != nil {
fatalf("Error reading stdin: %v", err)
}
text = strings.TrimRight(string(data), "\n")
}
if text == "" {
fatalf("Error: no input (provide text, pipe stdin, or use a subcommand)")
}
emitQR(text)
}
func runQRWifi(cmd *cobra.Command, args []string) {
ssid := args[0]
if qrWifiPassword != "" || strings.EqualFold(qrWifiSecurity, "nopass") {
emitQR(qrcode.WiFiString(qrWifiSecurity, ssid, qrWifiPassword, qrWifiHidden))
return
}
content, err := fetchWifiQRContent(ssid)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
fmt.Fprintln(os.Stderr, "Hint: pass --password to build the code without the shell.")
os.Exit(1)
}
if qrWifiHidden {
content = strings.TrimSuffix(content, ";") + "H:true;;"
}
emitQR(content)
}
func emitQR(text string) {
renderTerm := shouldRenderTerminal()
pngToStdout := qrStdout || (!renderTerm && qrOutput == "" && !qrClipboard && !qrCopyText)
if pngToStdout || qrOutput != "" || qrClipboard {
png, err := qrcode.RenderPNG(text, qrcode.ImageOptions{
ECC: effectiveEcc(),
Version: qrVersion,
ModuleSize: qrModuleSize,
Fg: qrFg,
Bg: qrBg,
Transparent: qrTransparent,
Border: qrBorder,
Shape: qrShape,
Logo: qrLogo,
LogoScale: qrLogoScale,
})
if err != nil {
fatalf("Error encoding QR: %v", err)
}
emitPNG(png, pngToStdout)
}
if qrCopyText {
if err := clipboard.CopyText(text); err != nil {
fatalf("Error copying text: %v", err)
}
}
if !renderTerm {
return
}
out, err := qrcode.RenderTerminal(text, qrcode.TermOptions{
ECC: effectiveEcc(),
Version: qrVersion,
QuietZone: qrQuietZone,
Invert: qrInvert,
Fg: qrFg,
Bg: qrBg,
})
if err != nil {
fatalf("Error rendering QR: %v", err)
}
dst := os.Stdout
if pngToStdout {
dst = os.Stderr
}
fmt.Fprint(dst, out)
}
func emitPNG(png []byte, toStdout bool) {
if qrOutput != "" {
if err := os.WriteFile(qrOutput, png, 0o644); err != nil {
fatalf("Error writing file: %v", err)
}
fmt.Fprintln(os.Stderr, qrOutput)
}
if toStdout {
os.Stdout.Write(png)
}
if qrClipboard {
if err := clipboard.Copy(png, "image/png"); err != nil {
fatalf("Error copying image: %v", err)
}
}
}
func shouldRenderTerminal() bool {
switch {
case qrNoRender:
return false
case qrRender:
return true
case qrStdout, qrOutput != "", qrClipboard, qrCopyText:
return false
default:
return isatty.IsTerminal(os.Stdout.Fd())
}
}
func effectiveEcc() string {
switch {
case qrEcc != "":
return qrEcc
case qrLogo != "":
return "H"
default:
return "M"
}
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
func fetchWifiQRContent(ssid string) (string, error) {
resp, err := sendServerRequest(models.Request{
ID: 1,
Method: "network.qrcode-content",
Params: map[string]any{"ssid": ssid},
})
if err != nil {
return "", err
}
if resp.Error != "" {
return "", fmt.Errorf("%s", resp.Error)
}
if resp.Result == nil {
return "", fmt.Errorf("empty response")
}
content, ok := (*resp.Result).(string)
if !ok {
return "", fmt.Errorf("unexpected response format")
}
return content, nil
}
+11 -70
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -28,19 +27,8 @@ var (
ssNoConfirm bool ssNoConfirm bool
ssReset bool ssReset bool
ssStdout bool ssStdout bool
ssJSON bool
) )
type screenshotMetadata struct {
Status string `json:"status"`
Path string `json:"path,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Scale float64 `json:"scale,omitempty"`
Mime string `json:"mime,omitempty"`
Error string `json:"error,omitempty"`
}
var screenshotCmd = &cobra.Command{ var screenshotCmd = &cobra.Command{
Use: "screenshot", Use: "screenshot",
Short: "Capture screenshots", Short: "Capture screenshots",
@@ -71,8 +59,7 @@ Examples:
dms screenshot --no-file # Clipboard only dms screenshot --no-file # Clipboard only
dms screenshot --no-confirm # Region capture on mouse release dms screenshot --no-confirm # Region capture on mouse release
dms screenshot --cursor=on # Include cursor dms screenshot --cursor=on # Include cursor
dms screenshot -f jpg -q 85 # JPEG with quality 85 dms screenshot -f jpg -q 85 # JPEG with quality 85`,
dms screenshot --json # Print capture metadata as JSON`,
} }
var ssRegionCmd = &cobra.Command{ var ssRegionCmd = &cobra.Command{
@@ -141,7 +128,6 @@ func init() {
screenshotCmd.PersistentFlags().BoolVar(&ssNoConfirm, "no-confirm", false, "Region mode: capture on mouse release without Enter/Space confirmation") screenshotCmd.PersistentFlags().BoolVar(&ssNoConfirm, "no-confirm", false, "Region mode: capture on mouse release without Enter/Space confirmation")
screenshotCmd.PersistentFlags().BoolVar(&ssReset, "reset", false, "Reset saved last-region preselection before capturing") screenshotCmd.PersistentFlags().BoolVar(&ssReset, "reset", false, "Reset saved last-region preselection before capturing")
screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)") screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
screenshotCmd.PersistentFlags().BoolVar(&ssJSON, "json", false, "Print capture metadata as JSON")
screenshotCmd.AddCommand(ssRegionCmd) screenshotCmd.AddCommand(ssRegionCmd)
screenshotCmd.AddCommand(ssFullCmd) screenshotCmd.AddCommand(ssFullCmd)
@@ -217,36 +203,7 @@ func setPopoutScreenshotMode(begin bool) {
_ = exec.Command("qs", cmdArgs...).Run() _ = exec.Command("qs", cmdArgs...).Run()
} }
func writeScreenshotJSON(meta screenshotMetadata) {
_ = json.NewEncoder(os.Stdout).Encode(meta)
}
func exitScreenshotError(context string, err error) {
if ssJSON {
writeScreenshotJSON(screenshotMetadata{Status: "error", Error: err.Error()})
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "Error%s: %v\n", context, err)
os.Exit(1)
}
func formatMime(format screenshot.Format) string {
switch format {
case screenshot.FormatJPEG:
return "image/jpeg"
case screenshot.FormatPPM:
return "image/x-portable-pixmap"
default:
return "image/png"
}
}
func runScreenshot(config screenshot.Config) { func runScreenshot(config screenshot.Config) {
if ssJSON && config.Stdout {
fmt.Fprintln(os.Stderr, "Error: --json cannot be combined with --stdout")
os.Exit(1)
}
// Region select needs the keyboard; drop popout grabs for its duration. // Region select needs the keyboard; drop popout grabs for its duration.
result, err := func() (*screenshot.CaptureResult, error) { result, err := func() (*screenshot.CaptureResult, error) {
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion
@@ -258,13 +215,11 @@ func runScreenshot(config screenshot.Config) {
}() }()
if err != nil { if err != nil {
exitScreenshotError("", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
} }
if result == nil { if result == nil {
if ssJSON {
writeScreenshotJSON(screenshotMetadata{Status: "aborted", Error: "User cancelled selection"})
}
os.Exit(0) os.Exit(0)
} }
@@ -276,7 +231,8 @@ func runScreenshot(config screenshot.Config) {
if config.Stdout { if config.Stdout {
if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format); err != nil { if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
exitScreenshotError(" writing to stdout", err) fmt.Fprintf(os.Stderr, "Error writing to stdout: %v\n", err)
os.Exit(1)
} }
return return
} }
@@ -296,37 +252,22 @@ func runScreenshot(config screenshot.Config) {
filePath = filepath.Join(outputDir, filename) filePath = filepath.Join(outputDir, filename)
if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format); err != nil { if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format); err != nil {
exitScreenshotError(" writing file", err) fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
} os.Exit(1)
if !ssJSON {
fmt.Println(filePath)
} }
fmt.Println(filePath)
} }
if config.Clipboard { if config.Clipboard {
if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format); err != nil { if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
exitScreenshotError(" copying to clipboard", err) fmt.Fprintf(os.Stderr, "Error copying to clipboard: %v\n", err)
os.Exit(1)
} }
if !ssJSON && !config.SaveFile { if !config.SaveFile {
fmt.Println("Copied to clipboard") fmt.Println("Copied to clipboard")
} }
} }
if ssJSON {
scale := result.Scale
if scale <= 0 {
scale = 1.0
}
writeScreenshotJSON(screenshotMetadata{
Status: "success",
Path: filePath,
Width: result.Buffer.Width,
Height: result.Buffer.Height,
Scale: scale,
Mime: formatMime(config.Format),
})
}
if config.Notify { if config.Notify {
thumbData, thumbW, thumbH := bufferToRGBThumbnail(result.Buffer, 256, result.Format) thumbData, thumbW, thumbH := bufferToRGBThumbnail(result.Buffer, 256, result.Format)
screenshot.SendNotification(screenshot.NotifyResult{ screenshot.SendNotification(screenshot.NotifyResult{
+2 -12
View File
@@ -47,7 +47,6 @@ var (
sysUpdateJSON bool sysUpdateJSON bool
sysUpdateNoFlatpak bool sysUpdateNoFlatpak bool
sysUpdateNoAUR bool sysUpdateNoAUR bool
sysUpdateIgnore []string
sysUpdateIntervalS int sysUpdateIntervalS int
sysUpdateListPmTime = 5 * time.Minute sysUpdateListPmTime = 5 * time.Minute
) )
@@ -59,7 +58,6 @@ func init() {
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)") systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay") systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)") systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
systemUpdateCmd.Flags().StringSliceVar(&sysUpdateIgnore, "ignore", nil, "Skip specific packages (repeatable or comma-separated)")
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)") systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
systemCmd.AddCommand(systemUpdateCmd) systemCmd.AddCommand(systemUpdateCmd)
@@ -194,7 +192,6 @@ func runSystemUpdateApply() {
Targets: pkgs, Targets: pkgs,
IncludeFlatpak: !sysUpdateNoFlatpak, IncludeFlatpak: !sysUpdateNoFlatpak,
IncludeAUR: !sysUpdateNoAUR, IncludeAUR: !sysUpdateNoAUR,
Ignored: sysUpdateIgnore,
DryRun: sysUpdateDry, DryRun: sysUpdateDry,
UseSudo: true, UseSudo: true,
} }
@@ -237,19 +234,12 @@ func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupd
} }
func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package { func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
if !sysUpdateNoAUR && len(sysUpdateIgnore) == 0 { if !sysUpdateNoAUR {
return pkgs return pkgs
} }
ignored := make(map[string]bool, len(sysUpdateIgnore))
for _, name := range sysUpdateIgnore {
ignored[name] = true
}
out := pkgs[:0] out := pkgs[:0]
for _, p := range pkgs { for _, p := range pkgs {
if sysUpdateNoAUR && p.Repo == sysupdate.RepoAUR { if p.Repo == sysupdate.RepoAUR {
continue
}
if ignored[p.Name] {
continue continue
} }
out = append(out, p) out = append(out, p)
+3 -26
View File
@@ -6,7 +6,6 @@ import (
"os" "os"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
) )
@@ -84,31 +83,9 @@ func ensureRunitSeat(greeterUser string) {
} }
} }
// ensureVoidLogindGreeter configures the elogind-backed greeter on Void. // ensureGreetdPamRundir adds pam_rundir to the greetd PAM stack so the post-login
func ensureVoidLogindGreeter(greeterUser string) { // session gets an XDG_RUNTIME_DIR on systems without logind (Void with seatd).
for _, service := range []string{"dbus", "elogind"} { // Appended outside DMS's managed auth block so it survives `dms greeter sync`.
if err := enableRunitService(service); err != nil {
fmt.Printf(" ⚠ could not enable %s: %v\n", service, err)
} else {
fmt.Printf(" ✓ %s enabled\n", service)
}
}
greeter.EnsureVoidGreetdRunScript(func(msg string) { fmt.Println(" " + msg) }, "")
if runitServiceEnabled("seatd") {
if err := disableRunitService("seatd"); err != nil {
fmt.Printf(" ⚠ could not disable seatd: %v\n", err)
} else {
fmt.Println(" ✓ seatd disabled (elogind manages the seat)")
}
}
if err := privesc.Run(context.Background(), "", "usermod", "-aG", "video,input", greeterUser); err != nil {
fmt.Printf(" ⚠ could not add %s to video/input groups: %v\n", greeterUser, err)
} else {
fmt.Printf(" ✓ %s added to video/input groups (elogind manages the seat)\n", greeterUser)
}
}
// ensureGreetdPamRundir provides XDG_RUNTIME_DIR to runit greeter sessions.
func ensureGreetdPamRundir() { func ensureGreetdPamRundir() {
const pamPath = "/etc/pam.d/greetd" const pamPath = "/etc/pam.d/greetd"
data, err := os.ReadFile(pamPath) data, err := os.ReadFile(pamPath)
+1 -1
View File
@@ -20,7 +20,7 @@ func init() {
runCmd.Flags().MarkHidden("daemon-child") runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd) greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd)
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd) authCmd.AddCommand(authSyncCmd)
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
updateCmd.AddCommand(updateCheckCmd) updateCmd.AddCommand(updateCheckCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
+1 -1
View File
@@ -20,7 +20,7 @@ func init() {
runCmd.Flags().MarkHidden("daemon-child") runCmd.Flags().MarkHidden("daemon-child")
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd) greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd)
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd) authCmd.AddCommand(authSyncCmd)
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
rootCmd.AddCommand(getCommonCommands()...) rootCmd.AddCommand(getCommonCommands()...)
+2 -10
View File
@@ -92,14 +92,6 @@ func appendLogEnv(env []string) []string {
return env return env
} }
func withDMSExecutable(env []string) []string {
selfPath, err := os.Executable()
if err != nil {
return env
}
return append(env, "DMS_EXECUTABLE="+selfPath)
}
func hasSystemdRun() bool { func hasSystemdRun() bool {
_, err := exec.LookPath("systemd-run") _, err := exec.LookPath("systemd-run")
return err == nil return err == nil
@@ -215,7 +207,7 @@ func runShellInteractive(session bool) {
log.Infof("Spawning quickshell with -p %s", configPath) log.Infof("Spawning quickshell with -p %s", configPath)
cmd := exec.CommandContext(ctx, "qs", "-p", configPath) cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
cmd.Env = withDMSExecutable(append(os.Environ(), "DMS_SOCKET="+socketPath)) cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
if os.Getenv("QT_LOGGING_RULES") == "" { if os.Getenv("QT_LOGGING_RULES") == "" {
if qtRules := log.GetQtLoggingRules(); qtRules != "" { if qtRules := log.GetQtLoggingRules(); qtRules != "" {
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules) cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
@@ -469,7 +461,7 @@ func runShellDaemon(session bool) {
log.Infof("Spawning quickshell with -p %s", configPath) log.Infof("Spawning quickshell with -p %s", configPath)
cmd := exec.CommandContext(ctx, "qs", "-p", configPath) cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
cmd.Env = withDMSExecutable(append(os.Environ(), "DMS_SOCKET="+socketPath)) cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
if os.Getenv("QT_LOGGING_RULES") == "" { if os.Getenv("QT_LOGGING_RULES") == "" {
if qtRules := log.GetQtLoggingRules(); qtRules != "" { if qtRules := log.GetQtLoggingRules(); qtRules != "" {
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules) cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
+1 -2
View File
@@ -11,8 +11,7 @@ end)
hl.config({ hl.config({
input = { input = {
-- empty inherits XKB_DEFAULT_LAYOUT (libxkbcommon), falls back to "us" kb_layout = "us",
kb_layout = "",
numlock_by_default = true, numlock_by_default = true,
follow_mouse = 0, follow_mouse = 0,
touchpad = { touchpad = {
+18 -18
View File
@@ -51,24 +51,24 @@ binds {
XF86AudioMicMute allow-when-locked=true { XF86AudioMicMute allow-when-locked=true {
spawn "dms" "ipc" "call" "audio" "micmute"; spawn "dms" "ipc" "call" "audio" "micmute";
} }
XF86AudioPause allow-when-locked=true { XF86AudioPause allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "playPause"; spawn "dms" "ipc" "call" "mpris" "playPause";
} }
XF86AudioPlay allow-when-locked=true { XF86AudioPlay allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "playPause"; spawn "dms" "ipc" "call" "mpris" "playPause";
} }
XF86AudioPrev allow-when-locked=true { XF86AudioPrev allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "previous"; spawn "dms" "ipc" "call" "mpris" "previous";
} }
XF86AudioNext allow-when-locked=true { XF86AudioNext allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "next"; spawn "dms" "ipc" "call" "mpris" "next";
} }
Ctrl+XF86AudioRaiseVolume allow-when-locked=true { Ctrl+XF86AudioRaiseVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "increment" "3"; spawn "dms" "ipc" "call" "mpris" "increment" "3";
} }
Ctrl+XF86AudioLowerVolume allow-when-locked=true { Ctrl+XF86AudioLowerVolume allow-when-locked=true {
spawn "dms" "ipc" "call" "mpris" "decrement" "3"; spawn "dms" "ipc" "call" "mpris" "decrement" "3";
} }
// === Brightness Controls === // === Brightness Controls ===
XF86MonBrightnessUp allow-when-locked=true { XF86MonBrightnessUp allow-when-locked=true {
+1 -18
View File
@@ -133,11 +133,6 @@ func mergedAssociations() *MimeAssociations {
return merged return merged
} }
// isSafeIniField rejects values that would corrupt a key=value line in mimeapps.list
func isSafeIniField(s string) bool {
return !strings.ContainsAny(s, "\n\r[]")
}
func writeUserMimeapps(update func(*MimeAssociations)) error { func writeUserMimeapps(update func(*MimeAssociations)) error {
mimeappsWriteMu.Lock() mimeappsWriteMu.Lock()
defer mimeappsWriteMu.Unlock() defer mimeappsWriteMu.Unlock()
@@ -157,7 +152,6 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
var buf bytes.Buffer var buf bytes.Buffer
w := bufio.NewWriter(&buf) w := bufio.NewWriter(&buf)
var writeErr error
writeSection := func(name string, entries map[string]string) { writeSection := func(name string, entries map[string]string) {
fmt.Fprintf(w, "[%s]\n", name) fmt.Fprintf(w, "[%s]\n", name)
keys := make([]string, 0, len(entries)) keys := make([]string, 0, len(entries))
@@ -166,14 +160,7 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
} }
sort.Strings(keys) sort.Strings(keys)
for _, k := range keys { for _, k := range keys {
v := entries[k] fmt.Fprintf(w, "%s=%s\n", k, 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) fmt.Fprintln(w)
} }
@@ -190,10 +177,6 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
writeSection(groupAdded, flatten(assoc.Added)) writeSection(groupAdded, flatten(assoc.Added))
writeSection(groupRemoved, flatten(assoc.Removed)) writeSection(groupRemoved, flatten(assoc.Removed))
if writeErr != nil {
return writeErr
}
if err := w.Flush(); err != nil { if err := w.Flush(); err != nil {
return err return err
} }
+2 -51
View File
@@ -119,30 +119,10 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
dependencies = append(dependencies, a.detectMatugen()) dependencies = append(dependencies, a.detectMatugen())
dependencies = append(dependencies, a.detectDgop()) dependencies = append(dependencies, a.detectDgop())
dependencies = append(dependencies, a.detectDanksearch())
dependencies = append(dependencies, a.detectDankCalendar())
return dependencies, nil return dependencies, nil
} }
func (a *ArchDistribution) detectDanksearch() deps.Dependency {
dep := a.BaseDistribution.detectDanksearch()
dep.CanToggle = true
if a.packageInstalled("dsearch-git") {
dep.Variant = deps.VariantGit
}
return dep
}
func (a *ArchDistribution) detectDankCalendar() deps.Dependency {
dep := a.BaseDistribution.detectDankCalendar()
dep.CanToggle = true
if a.packageInstalled("dankcalendar-git") {
dep.Variant = deps.VariantGit
}
return dep
}
func (a *ArchDistribution) detectXDGPortal() deps.Dependency { func (a *ArchDistribution) detectXDGPortal() deps.Dependency {
return a.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", a.packageInstalled("xdg-desktop-portal-gtk")) return a.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", a.packageInstalled("xdg-desktop-portal-gtk"))
} }
@@ -152,13 +132,7 @@ func (a *ArchDistribution) detectAccountsService() deps.Dependency {
} }
func (a *ArchDistribution) detectDMSGreeter() deps.Dependency { func (a *ArchDistribution) detectDMSGreeter() deps.Dependency {
installed := a.packageInstalled("greetd-dms-greeter-git") || a.packageInstalled("greetd-dms-greeter-bin") return a.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", a.packageInstalled("greetd-dms-greeter-git"))
dep := a.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", installed)
dep.CanToggle = true
if a.packageInstalled("greetd-dms-greeter-git") {
dep.Variant = deps.VariantGit
}
return dep
} }
func (a *ArchDistribution) packageInstalled(pkg string) bool { func (a *ArchDistribution) packageInstalled(pkg string) bool {
@@ -217,7 +191,7 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
"dms (DankMaterialShell)": a.getDMSMapping(variants["dms (DankMaterialShell)"]), "dms (DankMaterialShell)": a.getDMSMapping(variants["dms (DankMaterialShell)"]),
"git": {Name: "git", Repository: RepoTypeSystem}, "git": {Name: "git", Repository: RepoTypeSystem},
"quickshell": a.getQuickshellMapping(variants["quickshell"]), "quickshell": a.getQuickshellMapping(variants["quickshell"]),
"dms-greeter": a.getDMSGreeterMapping(variants["dms-greeter"]), "dms-greeter": {Name: "greetd-dms-greeter-git", Repository: RepoTypeAUR},
"matugen": a.getMatugenMapping(variants["matugen"]), "matugen": a.getMatugenMapping(variants["matugen"]),
"dgop": {Name: "dgop", Repository: RepoTypeSystem}, "dgop": {Name: "dgop", Repository: RepoTypeSystem},
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem}, "ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
@@ -225,8 +199,6 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem}, "alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem}, "xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem}, "accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"danksearch": a.getDanksearchMapping(variants["danksearch"]),
"dankcalendar": a.getDankCalendarMapping(variants["dankcalendar"]),
} }
switch wm { switch wm {
@@ -281,27 +253,6 @@ func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) Packag
return PackageMapping{Name: "matugen", Repository: RepoTypeSystem} return PackageMapping{Name: "matugen", Repository: RepoTypeSystem}
} }
func (a *ArchDistribution) getDanksearchMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "dsearch-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "dsearch-bin", Repository: RepoTypeAUR}
}
func (a *ArchDistribution) getDankCalendarMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "dankcalendar-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "dankcalendar-bin", Repository: RepoTypeAUR}
}
func (a *ArchDistribution) getDMSGreeterMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "greetd-dms-greeter-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "greetd-dms-greeter-bin", Repository: RepoTypeAUR}
}
func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMapping { func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMapping {
if forceDMSGit || variant == deps.VariantGit { if forceDMSGit || variant == deps.VariantGit {
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR} return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
-8
View File
@@ -107,14 +107,6 @@ func (b *BaseDistribution) detectDgop() deps.Dependency {
return b.detectCommand("dgop", "Desktop portal management tool") return b.detectCommand("dgop", "Desktop portal management tool")
} }
func (b *BaseDistribution) detectDanksearch() deps.Dependency {
return b.detectOptionalPackage("danksearch", "File indexing and search service", b.commandExists("dsearch") || b.commandExists("danksearch"))
}
func (b *BaseDistribution) detectDankCalendar() deps.Dependency {
return b.detectOptionalPackage("dankcalendar", "Calendar application", b.commandExists("dcal") || b.commandExists("dankcalendar"))
}
func (b *BaseDistribution) detectDMS() deps.Dependency { func (b *BaseDistribution) detectDMS() deps.Dependency {
dmsPath := filepath.Join(os.Getenv("HOME"), ".config/quickshell/dms") dmsPath := filepath.Join(os.Getenv("HOME"), ".config/quickshell/dms")
+1 -62
View File
@@ -71,8 +71,6 @@ func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, d.detectMatugen()) dependencies = append(dependencies, d.detectMatugen())
dependencies = append(dependencies, d.detectDgop()) dependencies = append(dependencies, d.detectDgop())
dependencies = append(dependencies, d.detectDanksearch())
dependencies = append(dependencies, d.detectDankCalendar())
return dependencies, nil return dependencies, nil
} }
@@ -137,8 +135,6 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, "matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, "dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, "ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
} }
if wm == deps.WindowManagerNiri { if wm == deps.WindowManagerNiri {
@@ -161,60 +157,7 @@ func (d *DebianDistribution) getQuickshellMapping(variant deps.PackageVariant) P
if forceQuickshellGit || variant == deps.VariantGit { if forceQuickshellGit || variant == deps.VariantGit {
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} return PackageMapping{Name: "quickshell-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}
} }
// Debian 13 ships stable quickshell in trixie-backports only. return PackageMapping{Name: "quickshell", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}
if d.debianNeedsQuickshellBackports() {
return PackageMapping{Name: "quickshell/trixie-backports", Repository: RepoTypeSystem}
}
return PackageMapping{Name: "quickshell", Repository: RepoTypeSystem}
}
func (d *DebianDistribution) debianNeedsQuickshellBackports() bool {
osInfo, err := GetOSInfo()
if err != nil {
return false
}
return osInfo.VersionID == "13" || strings.EqualFold(osInfo.VersionCodename, "trixie")
}
func (d *DebianDistribution) ensureQuickshellBackports(ctx context.Context, systemPkgs []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
needsBackports := false
for _, pkg := range systemPkgs {
if strings.Contains(pkg, "trixie-backports") {
needsBackports = true
break
}
}
if !needsBackports {
return nil
}
policyOut, err := exec.CommandContext(ctx, "apt-cache", "policy").Output()
if err == nil && strings.Contains(string(policyOut), "trixie-backports") {
d.log("trixie-backports already configured")
return nil
}
listFile := "/etc/apt/sources.list.d/trixie-backports.list"
repoLine := "deb http://deb.debian.org/debian trixie-backports main contrib non-free non-free-firmware"
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.30,
Step: "Enabling trixie-backports for quickshell...",
IsComplete: false,
NeedsSudo: true,
CommandInfo: fmt.Sprintf("echo '%s' | sudo tee %s", repoLine, listFile),
LogOutput: "Debian 13 ships quickshell in trixie-backports",
}
addCmd := privesc.ExecCommand(ctx, sudoPassword,
fmt.Sprintf("bash -c \"echo '%s' | tee %s\"", repoLine, listFile))
if err := d.runWithProgress(addCmd, progressChan, PhaseSystemPackages, 0.30, 0.31); err != nil {
return err
}
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
return d.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.31, 0.33)
} }
func (d *DebianDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping { func (d *DebianDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
@@ -319,10 +262,6 @@ func (d *DebianDistribution) InstallPackages(ctx context.Context, dependencies [
} }
} }
if err := d.ensureQuickshellBackports(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to enable trixie-backports for quickshell: %w", err)
}
// System Packages // System Packages
if len(systemPkgs) > 0 { if len(systemPkgs) > 0 {
progressChan <- InstallProgressMsg{ progressChan <- InstallProgressMsg{
-34
View File
@@ -1,34 +0,0 @@
package distros
import (
"context"
"fmt"
"os/exec"
)
// SetupDsearchService enables the dsearch.service user unit. Enablement failures
// are returned for the caller to surface as a non-fatal warning.
func SetupDsearchService(ctx context.Context, logf func(string)) error {
if logf == nil {
logf = func(string) {}
}
if err := runSystemctlUser(ctx, "daemon-reload"); err != nil {
return err
}
if err := runSystemctlUser(ctx, "enable", "--now", "dsearch.service"); err != nil {
return err
}
logf("Enabled dsearch.service")
return nil
}
func runSystemctlUser(ctx context.Context, args ...string) error {
cmd := exec.CommandContext(ctx, "systemctl", append([]string{"--user"}, args...)...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("systemctl --user %v failed: %w: %s", args, err, string(output))
}
return nil
}
-4
View File
@@ -104,8 +104,6 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, f.detectMatugen()) dependencies = append(dependencies, f.detectMatugen())
dependencies = append(dependencies, f.detectDgop()) dependencies = append(dependencies, f.detectDgop())
dependencies = append(dependencies, f.detectDanksearch())
dependencies = append(dependencies, f.detectDankCalendar())
return dependencies, nil return dependencies, nil
} }
@@ -140,8 +138,6 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}, "matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]), "dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]),
"dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}, "dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"danksearch": {Name: "danksearch", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
} }
switch wm { switch wm {
-2
View File
@@ -113,7 +113,6 @@ func (g *GentooDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, g.detectMatugen()) dependencies = append(dependencies, g.detectMatugen())
dependencies = append(dependencies, g.detectDgop()) dependencies = append(dependencies, g.detectDgop())
dependencies = append(dependencies, g.detectDanksearch())
return dependencies, nil return dependencies, nil
} }
@@ -172,7 +171,6 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}, "matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
"dms (DankMaterialShell)": g.getDmsMapping(), "dms (DankMaterialShell)": g.getDmsMapping(),
"dgop": {Name: "gui-apps/dgop", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}, "dgop": {Name: "gui-apps/dgop", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
"danksearch": {Name: "gui-apps/danksearch", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
} }
switch wm { switch wm {
-4
View File
@@ -91,8 +91,6 @@ func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Contex
dependencies = append(dependencies, o.detectMatugen()) dependencies = append(dependencies, o.detectMatugen())
dependencies = append(dependencies, o.detectDgop()) dependencies = append(dependencies, o.detectDgop())
dependencies = append(dependencies, o.detectDanksearch())
dependencies = append(dependencies, o.detectDankCalendar())
return dependencies, nil return dependencies, nil
} }
@@ -131,8 +129,6 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, "ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, "matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, "dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
} }
switch wm { switch wm {
-4
View File
@@ -80,8 +80,6 @@ func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, u.detectMatugen()) dependencies = append(dependencies, u.detectMatugen())
dependencies = append(dependencies, u.detectDgop()) dependencies = append(dependencies, u.detectDgop())
dependencies = append(dependencies, u.detectDanksearch())
dependencies = append(dependencies, u.detectDankCalendar())
return dependencies, nil return dependencies, nil
} }
@@ -126,8 +124,6 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}, "matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"dgop": {Name: "dgop", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}, "dgop": {Name: "dgop", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}, "ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"danksearch": {Name: "danksearch", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
} }
switch wm { switch wm {
+2 -13
View File
@@ -13,8 +13,8 @@ import (
) )
const ( const (
VoidDMSRepo = "https://void.danklinux.com/dms/current" VoidDMSRepo = "https://avengemedia.github.io/DankMaterialShell/current"
VoidDankLinuxRepo = "https://void.danklinux.com/danklinux/current" VoidDankLinuxRepo = "https://avengemedia.github.io/DankLinux/current"
VoidHyprlandRepo = "https://mirror.black-hole.dev/x86_64" VoidHyprlandRepo = "https://mirror.black-hole.dev/x86_64"
voidRunitSvDir = "/etc/sv" voidRunitSvDir = "/etc/sv"
@@ -72,7 +72,6 @@ func (v *VoidDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
dependencies = append(dependencies, v.detectAccountsService()) dependencies = append(dependencies, v.detectAccountsService())
dependencies = append(dependencies, v.detectDBus()) dependencies = append(dependencies, v.detectDBus())
dependencies = append(dependencies, v.detectElogind()) dependencies = append(dependencies, v.detectElogind())
dependencies = append(dependencies, v.detectMesaDri())
if wm == deps.WindowManagerHyprland { if wm == deps.WindowManagerHyprland {
dependencies = append(dependencies, v.detectHyprlandTools()...) dependencies = append(dependencies, v.detectHyprlandTools()...)
@@ -84,8 +83,6 @@ func (v *VoidDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
dependencies = append(dependencies, v.detectMatugen()) dependencies = append(dependencies, v.detectMatugen())
dependencies = append(dependencies, v.detectDgop()) dependencies = append(dependencies, v.detectDgop())
dependencies = append(dependencies, v.detectDanksearch())
dependencies = append(dependencies, v.detectDankCalendar())
return dependencies, nil return dependencies, nil
} }
@@ -143,10 +140,6 @@ func (v *VoidDistribution) detectElogind() deps.Dependency {
return v.detectPackage("elogind", "loginctl/logind provider for power management and session tracking", v.packageInstalled("elogind") || v.commandExists("loginctl")) return v.detectPackage("elogind", "loginctl/logind provider for power management and session tracking", v.packageInstalled("elogind") || v.commandExists("loginctl"))
} }
func (v *VoidDistribution) detectMesaDri() deps.Dependency {
return v.detectPackage("mesa-dri", "Mesa DRI/EGL drivers (GPU rendering; compositors find no outputs without it)", v.packageInstalled("mesa-dri"))
}
func (v *VoidDistribution) detectXwaylandSatellite() deps.Dependency { func (v *VoidDistribution) detectXwaylandSatellite() deps.Dependency {
return v.detectPackage("xwayland-satellite", "Xwayland support", v.packageInstalled("xwayland-satellite")) return v.detectPackage("xwayland-satellite", "Xwayland support", v.packageInstalled("xwayland-satellite"))
} }
@@ -177,15 +170,12 @@ func (v *VoidDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem}, "accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"dbus": {Name: "dbus", Repository: RepoTypeSystem}, "dbus": {Name: "dbus", Repository: RepoTypeSystem},
"elogind": {Name: "elogind", Repository: RepoTypeSystem}, "elogind": {Name: "elogind", Repository: RepoTypeSystem},
"mesa-dri": {Name: "mesa-dri", Repository: RepoTypeSystem},
"quickshell": {Name: "quickshell", Repository: RepoTypeSystem}, "quickshell": {Name: "quickshell", Repository: RepoTypeSystem},
"matugen": {Name: "matugen", Repository: RepoTypeSystem}, "matugen": {Name: "matugen", Repository: RepoTypeSystem},
"dms (DankMaterialShell)": v.getDmsMapping(variants["dms (DankMaterialShell)"]), "dms (DankMaterialShell)": v.getDmsMapping(variants["dms (DankMaterialShell)"]),
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeXBPS, RepoURL: VoidDMSRepo}, "dms-greeter": {Name: "dms-greeter", Repository: RepoTypeXBPS, RepoURL: VoidDMSRepo},
"dgop": {Name: "dgop", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo}, "dgop": {Name: "dgop", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
"danksearch": {Name: "danksearch", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
"dankcalendar": {Name: "dankcalendar", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
} }
switch wm { switch wm {
@@ -311,7 +301,6 @@ func (v *VoidDistribution) ensureSessionServices(ctx context.Context, sudoPasswo
return nil return nil
} }
// D-Bus activation alone starts elogind without its wrapper mounts; the runit service is required.
for _, service := range []string{"dbus", "elogind"} { for _, service := range []string{"dbus", "elogind"} {
if !v.runitServiceInstalled(service) { if !v.runitServiceInstalled(service) {
v.log(fmt.Sprintf("Warning: %s runit service not found in %s; power/session actions may not work until %s is installed", service, voidRunitSvDir, service)) v.log(fmt.Sprintf("Warning: %s runit service not found in %s; power/session actions may not work until %s is installed", service, voidRunitSvDir, service))
+1 -1
View File
@@ -132,7 +132,7 @@ func (c *GeoClueClient) startSignalPump() error {
if err := c.dbusConn.AddMatchSignal( if err := c.dbusConn.AddMatchSignal(
dbus.WithMatchObjectPath(c.clientPath), dbus.WithMatchObjectPath(c.clientPath),
dbus.WithMatchInterface(dbusGeoClueClientInterface), dbus.WithMatchInterface(dbusGeoClueClientInterface),
dbus.WithMatchMember("LocationUpdated"), dbus.WithMatchSender(dbusGeoClueClientLocationUpdated),
); err != nil { ); err != nil {
return err return err
} }
+11 -149
View File
@@ -115,52 +115,6 @@ func ensureRunitSeat(greeterUser, sudoPassword string, logFunc func(string)) {
} }
} }
// VoidGreetdRunScript orders greetd after dbus/elogind so the first greeter session can't race elogind's runtime-dir setup.
const VoidGreetdRunScript = `#!/bin/sh
sv check dbus >/dev/null || exit 1
sv check elogind >/dev/null || exit 1
exec greetd
`
// EnsureVoidGreetdRunScript rewrites /etc/sv/greetd/run with dbus/elogind ordering (greetd updates restore stock; enable re-asserts).
func EnsureVoidGreetdRunScript(logFunc func(string), sudoPassword string) {
const runPath = "/etc/sv/greetd/run"
if data, err := os.ReadFile(runPath); err == nil && strings.Contains(string(data), "sv check elogind") {
logFunc("✓ greetd run script already waits for elogind")
return
}
script := fmt.Sprintf("cat > %s <<'EOF'\n%sEOF\nchmod 755 %s", runPath, VoidGreetdRunScript, runPath)
if err := privesc.Run(context.Background(), sudoPassword, "sh", "-c", script); err != nil {
logFunc(fmt.Sprintf("⚠ could not update %s: %v", runPath, err))
return
}
logFunc("✓ greetd run script now waits for dbus/elogind")
}
// ensureVoidLogindGreeter configures the elogind-backed greeter on Void.
func ensureVoidLogindGreeter(greeterUser, sudoPassword string, logFunc func(string)) {
for _, service := range []string{"dbus", "elogind"} {
if err := enableRunitService(service, sudoPassword); err != nil {
logFunc(fmt.Sprintf("⚠ could not enable %s: %v", service, err))
} else {
logFunc(fmt.Sprintf("✓ %s enabled", service))
}
}
EnsureVoidGreetdRunScript(logFunc, sudoPassword)
if runitServiceEnabled("seatd") {
if err := disableRunitService("seatd", sudoPassword); err != nil {
logFunc(fmt.Sprintf("⚠ could not disable seatd: %v", err))
} else {
logFunc("✓ seatd disabled (elogind manages the seat)")
}
}
if err := privesc.Run(context.Background(), sudoPassword, "usermod", "-aG", "video,input", greeterUser); err != nil {
logFunc(fmt.Sprintf("⚠ could not add %s to video/input groups: %v", greeterUser, err))
} else {
logFunc(fmt.Sprintf("✓ %s added to video/input groups (elogind manages the seat)", greeterUser))
}
}
func ensureGreetdPamRundir(sudoPassword string, logFunc func(string)) { func ensureGreetdPamRundir(sudoPassword string, logFunc func(string)) {
const pamPath = "/etc/pam.d/greetd" const pamPath = "/etc/pam.d/greetd"
data, err := os.ReadFile(pamPath) data, err := os.ReadFile(pamPath)
@@ -529,23 +483,11 @@ func execFromDesktopFile(path string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
inDesktopEntry := false
for line := range strings.SplitSeq(string(data), "\n") { for line := range strings.SplitSeq(string(data), "\n") {
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
switch { if strings.HasPrefix(trimmed, "Exec=") {
case trimmed == "" || strings.HasPrefix(trimmed, "#"): return strings.TrimSpace(trimmed[len("Exec="):]), nil
continue
case strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]"):
inDesktopEntry = trimmed == "[Desktop Entry]"
continue
case !inDesktopEntry:
continue
} }
key, value, found := strings.Cut(trimmed, "=")
if !found || strings.TrimSpace(key) != "Exec" {
continue
}
return strings.TrimSpace(value), nil
} }
return "", fmt.Errorf("no Exec= line found in %s", path) return "", fmt.Errorf("no Exec= line found in %s", path)
} }
@@ -1048,7 +990,7 @@ func TryInstallGreeterPackage(logFunc func(string), sudoPassword string) bool {
failHint = fmt.Sprintf("⚠ dms-greeter install failed. Install from AUR: %s -S greetd-dms-greeter-git", aurHelper) failHint = fmt.Sprintf("⚠ dms-greeter install failed. Install from AUR: %s -S greetd-dms-greeter-git", aurHelper)
installCmd = exec.CommandContext(ctx, aurHelper, "-S", "--noconfirm", "greetd-dms-greeter-git") installCmd = exec.CommandContext(ctx, aurHelper, "-S", "--noconfirm", "greetd-dms-greeter-git")
case distros.FamilyVoid: case distros.FamilyVoid:
failHint = "⚠ dms-greeter install failed. Add the DMS XBPS repo manually:\necho 'repository=https://void.danklinux.com/dms/current' | sudo tee /etc/xbps.d/dms.conf\nsudo xbps-install -Sy dms-greeter" failHint = "⚠ dms-greeter install failed. Add the DMS XBPS repo manually:\necho 'repository=https://avengemedia.github.io/DankMaterialShell/current' | sudo tee /etc/xbps.d/dms.conf\nsudo xbps-install -Sy dms-greeter"
logFunc("Adding DMS XBPS repository...") logFunc("Adding DMS XBPS repository...")
if err := ensureVoidXBPSRepo(ctx, sudoPassword, "dms", distros.VoidDMSRepo); err != nil { if err := ensureVoidXBPSRepo(ctx, sudoPassword, "dms", distros.VoidDMSRepo); err != nil {
logFunc(fmt.Sprintf("⚠ Failed to add DMS XBPS repository: %v", err)) logFunc(fmt.Sprintf("⚠ Failed to add DMS XBPS repository: %v", err))
@@ -1798,10 +1740,6 @@ func syncGreeterColorSource(homeDir, cacheDir string, state greeterThemeSyncStat
} }
func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string) error { func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
if err := EnsureVoidLogindGreetdCommand(logFunc, sudoPassword); err != nil {
return err
}
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()
if err != nil { if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err) return fmt.Errorf("failed to get user home directory: %w", err)
@@ -2333,7 +2271,13 @@ vt = 1
return fmt.Errorf("failed to read greetd config: %w", err) return fmt.Errorf("failed to read greetd config: %w", err)
} }
commandValue := buildGreetdCommand(resolveGreeterWrapperPath(), compositor, dmsPath, IsVoidLinux()) wrapperCmd := resolveGreeterWrapperPath()
compositorLower := strings.ToLower(compositor)
commandValue := fmt.Sprintf("%s --command %s --cache-dir %s", wrapperCmd, compositorLower, GreeterCacheDir)
if dmsPath != "" {
commandValue = fmt.Sprintf("%s -p %s", commandValue, dmsPath)
}
commandLine := fmt.Sprintf(`command = "%s"`, commandValue) commandLine := fmt.Sprintf(`command = "%s"`, commandValue)
newConfig := upsertDefaultSession(configContent, greeterUser, commandLine) newConfig := upsertDefaultSession(configContent, greeterUser, commandLine)
@@ -2345,84 +2289,6 @@ vt = 1
return nil return nil
} }
func buildGreetdCommand(wrapperCmd, compositor, dmsPath string, useVoidLogind bool) string {
commandValue := fmt.Sprintf("%s --command %s --cache-dir %s", wrapperCmd, strings.ToLower(compositor), GreeterCacheDir)
if dmsPath != "" {
commandValue = fmt.Sprintf("%s -p %s", commandValue, dmsPath)
}
if useVoidLogind {
commandValue = "env LIBSEAT_BACKEND=logind DMS_VOID=1 " + commandValue
}
return commandValue
}
// EnsureVoidLogindGreetdCommand migrates DMS greeter commands on Void.
func EnsureVoidLogindGreetdCommand(logFunc func(string), sudoPassword string) error {
if !IsVoidLinux() {
return nil
}
const configPath = "/etc/greetd/config.toml"
data, err := os.ReadFile(configPath)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("failed to read greetd config: %w", err)
}
configContent := string(data)
command := extractDefaultSessionCommand(configContent)
if command == "" || !strings.Contains(command, "dms-greeter") {
return nil
}
migratedCommand := voidLogindGreeterCommand(command)
if migratedCommand == command {
return nil
}
greeterUser := extractDefaultSessionUser(configContent)
if greeterUser == "" {
greeterUser = DetectGreeterUser()
}
newConfig := upsertDefaultSession(configContent, greeterUser, fmt.Sprintf(`command = "%s"`, migratedCommand))
return writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, "✓ Updated existing Void greeter to use elogind")
}
func extractDefaultSessionCommand(configContent string) string {
inDefaultSession := false
for line := range strings.SplitSeq(configContent, "\n") {
if section, ok := parseTomlSection(line); ok {
inDefaultSession = section == "default_session"
continue
}
if !inDefaultSession {
continue
}
trimmed := stripTomlComment(line)
if !strings.HasPrefix(trimmed, "command =") && !strings.HasPrefix(trimmed, "command=") {
continue
}
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) != 2 {
continue
}
if command := strings.Trim(strings.TrimSpace(parts[1]), `"`); command != "" {
return command
}
}
return ""
}
func voidLogindGreeterCommand(command string) string {
if strings.Contains(command, "LIBSEAT_BACKEND=logind") && strings.Contains(command, "DMS_VOID=1") {
return command
}
return "env LIBSEAT_BACKEND=logind DMS_VOID=1 " + command
}
func stripConfigFlag(command string) string { func stripConfigFlag(command string) string {
for _, flag := range []string{" -C ", " --config "} { for _, flag := range []string{" -C ", " --config "} {
idx := strings.Index(command, flag) idx := strings.Index(command, flag)
@@ -2564,11 +2430,7 @@ func EnableGreetd(sudoPassword string, logFunc func(string)) error {
if !runitServiceInstalled("greetd") { if !runitServiceInstalled("greetd") {
return fmt.Errorf("greetd service not found in %s; ensure greetd is installed", runitSvDir) return fmt.Errorf("greetd service not found in %s; ensure greetd is installed", runitSvDir)
} }
if IsVoidLinux() { ensureRunitSeat(DetectGreeterUser(), sudoPassword, logFunc)
ensureVoidLogindGreeter(DetectGreeterUser(), sudoPassword, logFunc)
} else {
ensureRunitSeat(DetectGreeterUser(), sudoPassword, logFunc)
}
ensureGreetdPamRundir(sudoPassword, logFunc) ensureGreetdPamRundir(sudoPassword, logFunc)
if err := enableRunitService("greetd", sudoPassword); err != nil { if err := enableRunitService("greetd", sudoPassword); err != nil {
return fmt.Errorf("failed to enable greetd: %w", err) return fmt.Errorf("failed to enable greetd: %w", err)
-50
View File
@@ -169,56 +169,6 @@ func TestStripDesktopExecCodes(t *testing.T) {
} }
} }
func TestBuildGreetdCommand(t *testing.T) {
t.Parallel()
tests := []struct {
name string
wrapper string
compositor string
dmsPath string
useVoidLogind bool
want string
}{
{
name: "standard command",
wrapper: "/usr/bin/dms-greeter",
compositor: "Niri",
want: "/usr/bin/dms-greeter --command niri --cache-dir /var/cache/dms-greeter",
},
{
name: "void selects elogind and keeps custom DMS path",
wrapper: "/usr/bin/dms-greeter",
compositor: "Niri",
dmsPath: "/usr/share/quickshell/dms-greeter",
useVoidLogind: true,
want: "env LIBSEAT_BACKEND=logind DMS_VOID=1 /usr/bin/dms-greeter --command niri --cache-dir /var/cache/dms-greeter -p /usr/share/quickshell/dms-greeter",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := buildGreetdCommand(tt.wrapper, tt.compositor, tt.dmsPath, tt.useVoidLogind); got != tt.want {
t.Fatalf("buildGreetdCommand() = %q, want %q", got, tt.want)
}
})
}
}
func TestVoidLogindGreeterCommand(t *testing.T) {
t.Parallel()
const oldCommand = "/usr/bin/dms-greeter --command niri -C /etc/greetd/niri.kdl"
const want = "env LIBSEAT_BACKEND=logind DMS_VOID=1 " + oldCommand
if got := voidLogindGreeterCommand(oldCommand); got != want {
t.Fatalf("voidLogindGreeterCommand() = %q, want %q", got, want)
}
if got := voidLogindGreeterCommand(want); got != want {
t.Fatalf("voidLogindGreeterCommand() must be idempotent, got %q", got)
}
}
func TestResolveGreeterAutoLoginState(t *testing.T) { func TestResolveGreeterAutoLoginState(t *testing.T) {
t.Parallel() t.Parallel()
+3 -81
View File
@@ -3,7 +3,6 @@ package greeter
import ( import (
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"syscall" "syscall"
@@ -94,95 +93,18 @@ func resolveSessionExecInDirs(sessionID string, dirs []string) (string, error) {
return "", fmt.Errorf("session desktop file %q was not found", id) return "", fmt.Errorf("session desktop file %q was not found", id)
} }
// parseExecString splits a Desktop Entry Exec= value into argv without
// involving a shell, mirroring quickshell's DesktopEntry::parseExecString
// (string quoting, value escapes, field code stripping).
func parseExecString(execLine string) []string {
var args []string
var cur strings.Builder
inString := false
escape := 0
percent := false
for _, c := range execLine {
switch {
case escape == 0 && c == '\\':
escape = 1
case inString:
switch {
case c == '\\':
escape++
if escape == 4 {
cur.WriteByte('\\')
escape = 0
}
case escape == 2:
cur.WriteRune(c)
escape = 0
case escape != 0:
switch c {
case 's':
cur.WriteByte(' ')
case 'n':
cur.WriteByte('\n')
case 't':
cur.WriteByte('\t')
case 'r':
cur.WriteByte('\r')
default:
cur.WriteRune(c)
}
escape = 0
case c == '"' || c == '\'':
inString = false
default:
cur.WriteRune(c)
}
case escape != 0:
cur.WriteRune(c)
escape = 0
case percent:
if c == '%' {
cur.WriteByte('%')
}
percent = false
case c == '%':
percent = true
case c == '"' || c == '\'':
inString = true
case c == ' ':
if cur.Len() > 0 {
args = append(args, cur.String())
cur.Reset()
}
default:
cur.WriteRune(c)
}
}
if cur.Len() > 0 {
args = append(args, cur.String())
}
return args
}
func LaunchSessionByID(sessionID string) error { func LaunchSessionByID(sessionID string) error {
execLine, err := ResolveSessionExec(sessionID) execLine, err := ResolveSessionExec(sessionID)
if err != nil { if err != nil {
return err return err
} }
execLine = strings.TrimSpace(stripDesktopExecCodes(execLine))
argv := parseExecString(strings.TrimSpace(execLine)) if execLine == "" {
if len(argv) == 0 {
return fmt.Errorf("session %q has an empty Exec command", sessionID) return fmt.Errorf("session %q has an empty Exec command", sessionID)
} }
resolved, err := exec.LookPath(argv[0])
if err != nil {
return fmt.Errorf("session %q command %q not found: %w", sessionID, argv[0], err)
}
env := append(os.Environ(), "XDG_SESSION_TYPE=wayland") env := append(os.Environ(), "XDG_SESSION_TYPE=wayland")
return syscall.Exec(resolved, argv, env) return syscall.Exec("/bin/sh", []string{"sh", "-c", "exec " + execLine}, env)
} }
func LaunchSessionFromMemory(cacheDir, homeDir string) error { func LaunchSessionFromMemory(cacheDir, homeDir string) error {
@@ -1,57 +0,0 @@
package greeter
import (
"path/filepath"
"reflect"
"testing"
)
func TestParseExecString(t *testing.T) {
t.Parallel()
tests := []struct {
name string
exec string
want []string
}{
{"plain", "niri --session", []string{"niri", "--session"}},
{"extra spaces", "niri --session", []string{"niri", "--session"}},
{"double quoted arg", `env "with space" run`, []string{"env", "with space", "run"}},
{"single quoted arg", `env 'with space' run`, []string{"env", "with space", "run"}},
{"escaped quote in quotes", `sh "say \\"hi\\""`, []string{"sh", `say "hi"`}},
{"field code dropped", "gnome-session %U", []string{"gnome-session"}},
{"field code mid-arg", "app --url=%u --run", []string{"app", "--url=", "--run"}},
{"literal percent", "app 100%% done", []string{"app", "100%", "done"}},
{"shell metachars stay literal", "sh -c $(reboot); echo", []string{"sh", "-c", "$(reboot);", "echo"}},
{"empty", "", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parseExecString(tt.exec); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("parseExecString(%q) = %#v, want %#v", tt.exec, got, tt.want)
}
})
}
}
func TestExecFromDesktopFileOnlyReadsDesktopEntryGroup(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "example.desktop")
writeTestFile(t, path, `[Desktop Action other]
Exec=/wrong/binary
[Desktop Entry]
Name=Example
Exec = /right/binary --flag
`)
got, err := execFromDesktopFile(path)
if err != nil {
t.Fatalf("execFromDesktopFile returned error: %v", err)
}
if got != "/right/binary --flag" {
t.Fatalf("execFromDesktopFile = %q, want %q", got, "/right/binary --flag")
}
}
+10 -38
View File
@@ -41,8 +41,6 @@ type Config struct {
ReplaceConfigs []string // specific configs to deploy (e.g. "niri", "ghostty") ReplaceConfigs []string // specific configs to deploy (e.g. "niri", "ghostty")
ReplaceConfigsAll bool // deploy/replace all configurations ReplaceConfigsAll bool // deploy/replace all configurations
Yes bool Yes bool
DankSearch bool // install danksearch and enable its user service
DankCalendar bool // install dankcalendar
} }
// Runner orchestrates unattended (headless) installation. // Runner orchestrates unattended (headless) installation.
@@ -216,11 +214,6 @@ func (r *Runner) Run() error {
return fmt.Errorf("package installation failed: %w", err) return fmt.Errorf("package installation failed: %w", err)
} }
useSystemd := true
if distroConfig, exists := distros.Registry[osInfo.Distribution.ID]; exists && distroConfig.Family == distros.FamilyVoid {
useSystemd = false
}
// 9. Greeter setup (if dms-greeter was included) // 9. Greeter setup (if dms-greeter was included)
if !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") { if !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
compositorName := "niri" compositorName := "niri"
@@ -238,24 +231,15 @@ func (r *Runner) Run() error {
} }
} }
// 9b. danksearch service setup (if danksearch was included)
if useSystemd && !disabledItems["danksearch"] && r.depExists(dependencies, "danksearch") {
fmt.Fprintln(os.Stdout, "Enabling danksearch service...")
logFunc := func(line string) {
r.log(line)
fmt.Fprintf(os.Stdout, " danksearch: %s\n", line)
}
if err := distros.SetupDsearchService(context.Background(), logFunc); err != nil {
// Non-fatal, matching greeter behavior
fmt.Fprintf(os.Stderr, "Warning: danksearch service setup issue (non-fatal): %v\n", err)
}
}
// 10. Deploy configurations // 10. Deploy configurations
fmt.Fprintln(os.Stdout, "Deploying configurations...") fmt.Fprintln(os.Stdout, "Deploying configurations...")
r.log("Starting configuration deployment") r.log("Starting configuration deployment")
deployer := config.NewConfigDeployer(r.logChan) deployer := config.NewConfigDeployer(r.logChan)
useSystemd := true
if distroConfig, exists := distros.Registry[osInfo.Distribution.ID]; exists && distroConfig.Family == distros.FamilyVoid {
useSystemd = false
}
results, err := deployer.DeployConfigurationsSelectiveWithReinstallsAndSystemd( results, err := deployer.DeployConfigurationsSelectiveWithReinstallsAndSystemd(
context.Background(), context.Background(),
wm, wm,
@@ -288,31 +272,19 @@ func (r *Runner) Run() error {
} }
// buildDisabledItems computes the set of dependencies that should be skipped // buildDisabledItems computes the set of dependencies that should be skipped
// during installation. Optional components are opt-in (disabled by default), // during installation, applying the --include-deps and --exclude-deps filters.
// then re-enabled by the dedicated flags and --include-deps. // dms-greeter is disabled by default (opt-in), matching TUI behavior.
func (r *Runner) buildDisabledItems(dependencies []deps.Dependency) (map[string]bool, error) { func (r *Runner) buildDisabledItems(dependencies []deps.Dependency) (map[string]bool, error) {
disabledItems := make(map[string]bool) disabledItems := make(map[string]bool)
// dms-greeter is opt-in (disabled by default), matching TUI behavior
for i := range dependencies { for i := range dependencies {
if !dependencies[i].Required { if dependencies[i].Name == "dms-greeter" {
disabledItems[dependencies[i].Name] = true disabledItems["dms-greeter"] = true
break
} }
} }
// Dedicated flags resolve before include/exclude
if r.cfg.DankSearch {
if !r.depExists(dependencies, "danksearch") {
return nil, fmt.Errorf("--danksearch: not available on this distribution")
}
delete(disabledItems, "danksearch")
}
if r.cfg.DankCalendar {
if !r.depExists(dependencies, "dankcalendar") {
return nil, fmt.Errorf("--dankcalendar: not available on this distribution")
}
delete(disabledItems, "dankcalendar")
}
// Process --include-deps (enable items that are disabled by default) // Process --include-deps (enable items that are disabled by default)
for _, name := range r.cfg.IncludeDeps { for _, name := range r.cfg.IncludeDeps {
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
+17 -51
View File
@@ -342,21 +342,17 @@ func TestConfigReplaceConfigsStoredCorrectly(t *testing.T) {
func TestBuildDisabledItems(t *testing.T) { func TestBuildDisabledItems(t *testing.T) {
dependencies := []deps.Dependency{ dependencies := []deps.Dependency{
{Name: "niri", Status: deps.StatusInstalled, Required: true}, {Name: "niri", Status: deps.StatusInstalled},
{Name: "ghostty", Status: deps.StatusMissing, Required: true}, {Name: "ghostty", Status: deps.StatusMissing},
{Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled, Required: true}, {Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled},
{Name: "dms-greeter", Status: deps.StatusMissing}, {Name: "dms-greeter", Status: deps.StatusMissing},
{Name: "danksearch", Status: deps.StatusMissing}, {Name: "waybar", Status: deps.StatusMissing},
{Name: "dankcalendar", Status: deps.StatusMissing},
{Name: "waybar", Status: deps.StatusMissing, Required: true},
} }
tests := []struct { tests := []struct {
name string name string
includeDeps []string includeDeps []string
excludeDeps []string excludeDeps []string
dankSearch bool
dankCalendar bool
deps []deps.Dependency // nil means use the shared fixture deps []deps.Dependency // nil means use the shared fixture
wantErr bool wantErr bool
errContains string // substring expected in error message errContains string // substring expected in error message
@@ -364,20 +360,19 @@ func TestBuildDisabledItems(t *testing.T) {
wantEnabled []string // dep names that should NOT be in disabledItems (extra check) wantEnabled []string // dep names that should NOT be in disabledItems (extra check)
}{ }{
{ {
name: "no flags set, optional deps disabled by default", name: "no flags set, dms-greeter disabled by default",
wantDisabled: []string{"dms-greeter", "danksearch", "dankcalendar"}, wantDisabled: []string{"dms-greeter"},
wantEnabled: []string{"niri", "ghostty", "waybar"}, wantEnabled: []string{"niri", "ghostty", "waybar"},
}, },
{ {
name: "include dms-greeter enables it", name: "include dms-greeter enables it",
includeDeps: []string{"dms-greeter"}, includeDeps: []string{"dms-greeter"},
wantEnabled: []string{"dms-greeter"}, wantEnabled: []string{"dms-greeter"},
wantDisabled: []string{"danksearch", "dankcalendar"},
}, },
{ {
name: "exclude a regular dep", name: "exclude a regular dep",
excludeDeps: []string{"waybar"}, excludeDeps: []string{"waybar"},
wantDisabled: []string{"dms-greeter", "danksearch", "dankcalendar", "waybar"}, wantDisabled: []string{"dms-greeter", "waybar"},
}, },
{ {
name: "include unknown dep returns error", name: "include unknown dep returns error",
@@ -404,53 +399,24 @@ func TestBuildDisabledItems(t *testing.T) {
wantDisabled: []string{"dms-greeter"}, wantDisabled: []string{"dms-greeter"},
}, },
{ {
name: "whitespace entries are skipped", name: "whitespace entries are skipped",
includeDeps: []string{" ", "dms-greeter"}, includeDeps: []string{" ", "dms-greeter"},
wantEnabled: []string{"dms-greeter"}, wantEnabled: []string{"dms-greeter"},
wantDisabled: []string{"danksearch", "dankcalendar"},
}, },
{ {
name: "no optional deps present, nothing disabled by default", name: "no dms-greeter in deps, nothing disabled by default",
deps: []deps.Dependency{ deps: []deps.Dependency{
{Name: "niri", Status: deps.StatusInstalled, Required: true}, {Name: "niri", Status: deps.StatusInstalled},
}, },
wantEnabled: []string{"niri"}, wantEnabled: []string{"niri"},
}, },
{
name: "danksearch flag enables it",
dankSearch: true,
wantEnabled: []string{"danksearch"},
wantDisabled: []string{"dms-greeter", "dankcalendar"},
},
{
name: "dankcalendar flag enables it",
dankCalendar: true,
wantEnabled: []string{"dankcalendar"},
wantDisabled: []string{"dms-greeter", "danksearch"},
},
{
name: "danksearch flag when unavailable errors",
dankSearch: true,
deps: []deps.Dependency{{Name: "niri", Status: deps.StatusInstalled, Required: true}},
wantErr: true,
errContains: "--danksearch",
},
{
name: "dankcalendar flag when unavailable errors",
dankCalendar: true,
deps: []deps.Dependency{{Name: "niri", Status: deps.StatusInstalled, Required: true}},
wantErr: true,
errContains: "--dankcalendar",
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
r := NewRunner(Config{ r := NewRunner(Config{
IncludeDeps: tt.includeDeps, IncludeDeps: tt.includeDeps,
ExcludeDeps: tt.excludeDeps, ExcludeDeps: tt.excludeDeps,
DankSearch: tt.dankSearch,
DankCalendar: tt.dankCalendar,
}) })
d := tt.deps d := tt.deps
if d == nil { if d == nil {
+16 -33
View File
@@ -299,9 +299,6 @@ type hyprlandOverrideBind struct {
Options map[string]any Options map[string]any
// Unbind: negative override (hl.unbind only, no rebind). // Unbind: negative override (hl.unbind only, no rebind).
Unbind bool Unbind bool
// RawLuaAction: Action is a custom hl.* Lua expression round-tripped from an
// existing Lua override; re-emit it verbatim instead of quoting it.
RawLuaAction bool
} }
func (h *HyprlandProvider) ensureWritableConfig() error { func (h *HyprlandProvider) ensureWritableConfig() error {
@@ -1049,26 +1046,18 @@ func luaActionStringFromHyprlangAction(action string) string {
if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok { if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok {
return expr return expr
} }
// Unrecognized dispatchers are freeform text, not Lua; forward them to return action
// hyprctl quoted so a stray `"` can't produce broken Lua output.
return luaHyprctlDispatchFunction(action)
} }
// luaExprToInternalAction converts a parsed Lua bind expression back into func luaExprToInternalAction(expr string) string {
// "dispatcher params" text. isRawLua reports that expr matched no known hl.*
// shape and must be re-emitted verbatim as Lua on write-back.
func luaExprToInternalAction(expr string) (action string, isRawLua bool) {
d, p := luaExprToDispatcherParams(expr) d, p := luaExprToDispatcherParams(expr)
if d == expr && p == "" {
return expr, true
}
if d == "exec" && p != "" && !strings.HasPrefix(p, "hyprctl dispatch lua:") { if d == "exec" && p != "" && !strings.HasPrefix(p, "hyprctl dispatch lua:") {
return "exec " + p, false return "exec " + p
} }
if p != "" { if p != "" {
return d + " " + p, false return d + " " + p
} }
return d, false return d
} }
func luaBindOptions(bind *hyprlandOverrideBind) []string { func luaBindOptions(bind *hyprlandOverrideBind) []string {
@@ -1086,25 +1075,20 @@ func luaBindOptions(bind *hyprlandOverrideBind) []string {
} }
func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) { func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
key := strconv.Quote(formatLuaBindKey(bind.Key)) key := formatLuaBindKey(bind.Key)
if bind.Unbind { if bind.Unbind {
fmt.Fprintf(sb, `hl.unbind(%s)`, key) fmt.Fprintf(sb, `hl.unbind("%s")`, key)
sb.WriteByte('\n') sb.WriteByte('\n')
return return
} }
var expr string expr := luaActionStringFromHyprlangAction(bind.Action)
if bind.RawLuaAction {
expr = bind.Action
} else {
expr = luaActionStringFromHyprlangAction(bind.Action)
}
opts := luaBindOptions(bind) opts := luaBindOptions(bind)
fmt.Fprintf(sb, `hl.unbind(%s)`, key) fmt.Fprintf(sb, `hl.unbind("%s")`, key)
sb.WriteByte('\n') sb.WriteByte('\n')
if len(opts) > 0 { if len(opts) > 0 {
fmt.Fprintf(sb, `hl.bind(%s, %s, { %s })`, key, expr, strings.Join(opts, ", ")) fmt.Fprintf(sb, `hl.bind("%s", %s, { %s })`, key, expr, strings.Join(opts, ", "))
} else { } else {
fmt.Fprintf(sb, `hl.bind(%s, %s)`, key, expr) fmt.Fprintf(sb, `hl.bind("%s", %s)`, key, expr)
} }
sb.WriteByte('\n') sb.WriteByte('\n')
} }
@@ -1120,18 +1104,17 @@ func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) {
} }
internalKey := luaKeyComboToInternalKey(kbc) internalKey := luaKeyComboToInternalKey(kbc)
action, isRawLua := luaExprToInternalAction(actionExpr) action := luaExprToInternalAction(actionExpr)
flags := luaBindOptFlags(optSuffix) flags := luaBindOptFlags(optSuffix)
description := luaBindOptDescription(optSuffix) description := luaBindOptDescription(optSuffix)
if description == "" { if description == "" {
description = luaLineTrailingComment(line) description = luaLineTrailingComment(line)
} }
return &hyprlandOverrideBind{ return &hyprlandOverrideBind{
Key: internalKey, Key: internalKey,
Action: action, Action: action,
Description: description, Description: description,
Flags: flags, Flags: flags,
RawLuaAction: isRawLua,
}, true }, true
} }
@@ -147,10 +147,9 @@ hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"), { descripti
func TestWriteLuaBindLineLeavesCustomLuaDispatcherRaw(t *testing.T) { func TestWriteLuaBindLineLeavesCustomLuaDispatcherRaw(t *testing.T) {
var sb strings.Builder var sb strings.Builder
writeLuaBindLine(&sb, &hyprlandOverrideBind{ writeLuaBindLine(&sb, &hyprlandOverrideBind{
Key: "Super+u", Key: "Super+u",
Action: "hl.dsp.no_op()", Action: "hl.dsp.no_op()",
Description: "Custom Lua", Description: "Custom Lua",
RawLuaAction: true,
}) })
want := `hl.unbind("SUPER + U") want := `hl.unbind("SUPER + U")
@@ -160,24 +159,6 @@ hl.bind("SUPER + U", hl.dsp.no_op(), { description = "Custom Lua" })`
} }
} }
func TestWriteLuaBindLineQuotesUnrecognizedActionWithoutRawLuaFlag(t *testing.T) {
var sb strings.Builder
writeLuaBindLine(&sb, &hyprlandOverrideBind{
Key: "Super+u",
Action: `customdispatcher "),os.execute("id")--`,
})
got := sb.String()
if !strings.Contains(got, "hl.exec_cmd(") {
t.Fatalf("expected unrecognized action to go through the hyprctl-dispatch wrapper, got %q", got)
}
// an unpaired bare quote means the action broke out of its string literal
withoutEscapedQuotes := strings.ReplaceAll(got, `\"`, "")
if n := strings.Count(withoutEscapedQuotes, `"`); n%2 != 0 {
t.Fatalf("action broke out of its string literal (%d unpaired quotes): %q", n, got)
}
}
func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) { func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) {
tests := []struct { tests := []struct {
action string action string
@@ -245,12 +226,15 @@ func TestParseLuaBindLineHandlesFunctionDispatcherFallback(t *testing.T) {
} }
} }
func TestLuaActionStringFromHyprlangActionAlwaysQuotesUnrecognizedText(t *testing.T) { func TestLuaActionStringLeavesCustomLuaDispatcherRaw(t *testing.T) {
got := luaActionStringFromHyprlangAction("hl.dsp.no_op()") got := luaActionStringFromHyprlangAction("hl.dsp.no_op()")
want := `function() hl.exec_cmd("hyprctl dispatch hl.dsp.no_op()") end` want := `hl.dsp.no_op()`
if got != want { if got != want {
t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want) t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want)
} }
if strings.Contains(got, "hl.dispatch") || strings.Contains(got, "hyprctl dispatch") {
t.Fatalf("expected custom Lua dispatcher expression to stay raw, got %q", got)
}
} }
func TestReadLuaOverrideMigratesTrailingCommentToDescription(t *testing.T) { func TestReadLuaOverrideMigratesTrailingCommentToDescription(t *testing.T) {
+4 -11
View File
@@ -260,15 +260,15 @@ func Run(opts Options) error {
return buildErr return buildErr
} }
if opts.SyncModeWithPortal {
syncColorScheme(opts.Mode)
}
if !changed { if !changed {
log.Info("No color changes detected, skipping refresh") log.Info("No color changes detected, skipping refresh")
return ErrNoChanges return ErrNoChanges
} }
if opts.SyncModeWithPortal {
syncColorScheme(opts.Mode)
}
log.Info("Done") log.Info("Done")
return nil return nil
} }
@@ -1006,13 +1006,6 @@ func syncColorScheme(mode ColorMode) {
scheme = "default" scheme = "default"
} }
if cur, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme"); err == nil {
cur = strings.Trim(cur, "'")
if cur == scheme || (mode == ColorModeLight && cur == "prefer-light") {
return
}
}
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", scheme); err != nil { if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", scheme); err != nil {
log.Warnf("Failed to sync color-scheme: %v", err) log.Warnf("Failed to sync color-scheme: %v", err)
} }
+50 -437
View File
@@ -12,7 +12,6 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
) )
const ( const (
@@ -33,32 +32,6 @@ const (
DankshellU2FPamPath = "/etc/pam.d/dankshell-u2f" DankshellU2FPamPath = "/etc/pam.d/dankshell-u2f"
) )
// lockscreenPamEntryCandidates are the /etc/pam.d entry-point services tried in
// order. "login" is first so systems that ship it behave exactly as before; the
// rest cover distros (or minimal installs) with no /etc/pam.d/login.
// lockscreenPamBaseDirs mirrors libpam's search order: /etc overrides, then the
// vendor dir (/usr/lib) and the stateless-distro default (/usr/share).
var lockscreenPamBaseDirs = []string{"/etc/pam.d", "/usr/lib/pam.d", "/usr/share/pam.d"}
// Standalone auth+account services, most universal first. login exists almost
// everywhere (util-linux); system-* cover Fedora/Arch/Gentoo/SUSE-Leap.
var lockscreenPamEntryCandidates = []string{
"login",
"system-auth",
"system-login",
"system-local-login",
}
// Fallback for distros with no standalone login service, only shared building
// blocks: openSUSE/Debian (common-*), Alpine/postmarketOS (base-*).
var lockscreenPamSharedIncludePairs = []struct {
auth string
account string
}{
{auth: "common-auth", account: "common-account"},
{auth: "base-auth", account: "base-account"},
}
var includedPamAuthFiles = []string{ var includedPamAuthFiles = []string{
"system-auth", "system-auth",
"common-auth", "common-auth",
@@ -70,11 +43,10 @@ var includedPamAuthFiles = []string{
} }
type AuthSettings struct { type AuthSettings struct {
EnableFprint bool `json:"enableFprint"` EnableFprint bool `json:"enableFprint"`
EnableU2f bool `json:"enableU2f"` EnableU2f bool `json:"enableU2f"`
GreeterEnableFprint bool `json:"greeterEnableFprint"` GreeterEnableFprint bool `json:"greeterEnableFprint"`
GreeterEnableU2f bool `json:"greeterEnableU2f"` GreeterEnableU2f bool `json:"greeterEnableU2f"`
GreeterPamExternallyManaged bool `json:"greeterPamExternallyManaged"`
} }
type SyncAuthOptions struct { type SyncAuthOptions struct {
@@ -103,50 +75,10 @@ type lockscreenPamIncludeDirective struct {
} }
type lockscreenPamResolver struct { type lockscreenPamResolver struct {
baseDirs []string pamDir string
readFile func(string) ([]byte, error) readFile func(string) ([]byte, error)
} }
// locate resolves a service/include name across baseDirs (libpam vendor-dir
// fallback). Targets may not escape the base dirs.
func (r lockscreenPamResolver) locate(target string) (string, error) {
target = strings.TrimSpace(target)
if target == "" {
return "", fmt.Errorf("empty PAM include target")
}
if filepath.IsAbs(target) {
clean := filepath.Clean(target)
for _, dir := range r.baseDirs {
if filepath.Dir(clean) == filepath.Clean(dir) {
return clean, nil
}
}
return "", fmt.Errorf("unsupported PAM include outside PAM dirs: %s", target)
}
clean := filepath.Clean(target)
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("invalid PAM include target: %s", target)
}
var firstErr error
for _, dir := range r.baseDirs {
path := filepath.Join(filepath.Clean(dir), clean)
if _, err := r.readFile(path); err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
return path, nil
}
if firstErr == nil {
firstErr = os.ErrNotExist
}
return "", firstErr
}
func defaultSyncDeps() syncDeps { func defaultSyncDeps() syncDeps {
return syncDeps{ return syncDeps{
pamDir: "/etc/pam.d", pamDir: "/etc/pam.d",
@@ -237,14 +169,6 @@ func syncAuthConfigWithDeps(logFunc func(string), sudoPassword string, options S
return fmt.Errorf("failed to inspect %s: %w", deps.greetdPath, err) return fmt.Errorf("failed to inspect %s: %w", deps.greetdPath, err)
} }
if settings.GreeterPamExternallyManaged {
if err := removeManagedGreeterPamBlockWithDeps(logFunc, sudoPassword, deps); err != nil {
return err
}
logFunc(" /etc/pam.d/greetd is externally managed. Skipping DMS greeter PAM sync.")
return nil
}
if err := syncGreeterPamConfigWithDeps(logFunc, sudoPassword, settings, options.ForceGreeterAuth, deps); err != nil { if err := syncGreeterPamConfigWithDeps(logFunc, sudoPassword, settings, options.ForceGreeterAuth, deps); err != nil {
return err return err
} }
@@ -451,10 +375,32 @@ func parseLockscreenPamIncludeDirective(trimmed string, inheritedFilter string)
return lockscreenPamIncludeDirective{}, false return lockscreenPamIncludeDirective{}, false
} }
func resolveLockscreenPamIncludePath(pamDir, target string) (string, error) {
if strings.TrimSpace(target) == "" {
return "", fmt.Errorf("empty PAM include target")
}
cleanPamDir := filepath.Clean(pamDir)
if filepath.IsAbs(target) {
cleanTarget := filepath.Clean(target)
if filepath.Dir(cleanTarget) != cleanPamDir {
return "", fmt.Errorf("unsupported PAM include outside %s: %s", cleanPamDir, target)
}
return cleanTarget, nil
}
cleanTarget := filepath.Clean(target)
if cleanTarget == "." || cleanTarget == ".." || strings.HasPrefix(cleanTarget, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("invalid PAM include target: %s", target)
}
return filepath.Join(cleanPamDir, cleanTarget), nil
}
func (r lockscreenPamResolver) resolveService(serviceName string, filterType string, stack []string) ([]string, error) { func (r lockscreenPamResolver) resolveService(serviceName string, filterType string, stack []string) ([]string, error) {
path, err := r.locate(serviceName) path, err := resolveLockscreenPamIncludePath(r.pamDir, serviceName)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read PAM file %s: %w", serviceName, err) return nil, err
} }
for _, seen := range stack { for _, seen := range stack {
@@ -512,74 +458,30 @@ func (r lockscreenPamResolver) resolveService(serviceName string, filterType str
return resolved, nil return resolved, nil
} }
func resolvedLinesHaveAuth(lines []string) bool { func buildManagedLockscreenPamContent(pamDir string, readFile func(string) ([]byte, error)) (string, error) {
for _, line := range lines { resolver := lockscreenPamResolver{
if pamDirectiveType(strings.TrimSpace(line)) == "auth" { pamDir: pamDir,
return true readFile: readFile,
}
}
return false
}
func (r lockscreenPamResolver) resolveLines() ([]string, error) {
var lastErr error
// Standalone login-like services: an existing one is authoritative.
for _, service := range lockscreenPamEntryCandidates {
if _, err := r.locate(service); err != nil {
lastErr = err
continue
}
lines, err := r.resolveService(service, "", nil)
if err != nil {
return nil, err
}
if !resolvedLinesHaveAuth(lines) {
return nil, fmt.Errorf("no auth directives remained after filtering %s", service)
}
return lines, nil
} }
// Shared building blocks for distros without a login service (openSUSE, resolvedLines, err := resolver.resolveService("login", "", nil)
// Alpine): stitch the auth stanza to the account stanza when present.
for _, pair := range lockscreenPamSharedIncludePairs {
if _, err := r.locate(pair.auth); err != nil {
lastErr = err
continue
}
authLines, err := r.resolveService(pair.auth, "auth", nil)
if err != nil {
return nil, err
}
if !resolvedLinesHaveAuth(authLines) {
lastErr = fmt.Errorf("no auth directives remained after filtering %s", pair.auth)
continue
}
resolved := append([]string{}, authLines...)
if _, err := r.locate(pair.account); err == nil {
acctLines, err := r.resolveService(pair.account, "account", nil)
if err != nil {
return nil, err
}
resolved = append(resolved, acctLines...)
}
return resolved, nil
}
if lastErr != nil {
return nil, fmt.Errorf("no usable PAM auth service found: %w", lastErr)
}
return nil, fmt.Errorf("no usable PAM auth service found")
}
func buildManagedLockscreenPamContent(baseDirs []string, readFile func(string) ([]byte, error)) (string, error) {
resolver := lockscreenPamResolver{baseDirs: baseDirs, readFile: readFile}
resolvedLines, err := resolver.resolveLines()
if err != nil { if err != nil {
return "", err return "", err
} }
if len(resolvedLines) == 0 {
return "", fmt.Errorf("no auth directives remained after filtering %s", filepath.Join(pamDir, "login"))
}
hasAuth := false
for _, line := range resolvedLines {
if pamDirectiveType(strings.TrimSpace(line)) == "auth" {
hasAuth = true
break
}
}
if !hasAuth {
return "", fmt.Errorf("no auth directives remained after filtering %s", filepath.Join(pamDir, "login"))
}
var b strings.Builder var b strings.Builder
b.WriteString("#%PAM-1.0\n") b.WriteString("#%PAM-1.0\n")
@@ -592,295 +494,6 @@ func buildManagedLockscreenPamContent(baseDirs []string, readFile func(string) (
return b.String(), nil return b.String(), nil
} }
var lockscreenPamCandidateServices = []string{
"login",
"system-auth",
"system-login",
"system-local-login",
"common-auth",
"base-auth",
}
type LockscreenPamServiceInfo struct {
Name string `json:"name"`
Dir string `json:"dir"`
Path string `json:"path"`
HasAuth bool `json:"hasAuth"`
InlineFingerprint bool `json:"inlineFingerprint"`
InlineU2f bool `json:"inlineU2f"`
}
type LockscreenPamValidation struct {
Valid bool `json:"valid"`
Path string `json:"path"`
HasAuth bool `json:"hasAuth"`
InlineFingerprint bool `json:"inlineFingerprint"`
InlineU2f bool `json:"inlineU2f"`
MissingModules []string `json:"missingModules"`
Warnings []string `json:"warnings"`
Errors []string `json:"errors"`
}
type lockscreenPamValidateDeps struct {
baseDirs []string
readFile func(string) ([]byte, error)
stat func(string) (os.FileInfo, error)
pamModuleExists func(string) bool
}
func defaultValidateDeps() lockscreenPamValidateDeps {
return lockscreenPamValidateDeps{
baseDirs: lockscreenPamBaseDirs,
readFile: os.ReadFile,
stat: os.Stat,
pamModuleExists: pamModuleExists,
}
}
// lockscreenPamAnalysis is a non-destructive walk of a PAM service. Unlike
// resolveService it detects (rather than strips) pam_fprintd/pam_u2f and
// records unknown directives instead of hard-failing on them.
type lockscreenPamAnalysis struct {
lines []string
hasAuth bool
inlineFingerprint bool
inlineU2f bool
modules []string
unknownDirectives []string
err error
}
func (r lockscreenPamResolver) analyzePath(path string) lockscreenPamAnalysis {
var acc lockscreenPamAnalysis
if err := r.analyzeInto(filepath.Clean(path), "", nil, &acc); err != nil {
acc.err = err
}
return acc
}
func (r lockscreenPamResolver) analyzeInto(path string, filterType string, stack []string, acc *lockscreenPamAnalysis) error {
for _, seen := range stack {
if seen == path {
chain := append(append([]string{}, stack...), path)
display := make([]string, 0, len(chain))
for _, item := range chain {
display = append(display, filepath.Base(item))
}
return fmt.Errorf("cyclic PAM include detected: %s", strings.Join(display, " -> "))
}
}
data, err := r.readFile(path)
if err != nil {
return fmt.Errorf("failed to read PAM file %s: %w", path, err)
}
for _, rawLine := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") {
rawLine = strings.TrimRight(rawLine, "\r")
trimmed := strings.TrimSpace(rawLine)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if include, ok := parseLockscreenPamIncludeDirective(trimmed, filterType); ok {
lineType := pamDirectiveType(trimmed)
if filterType != "" && lineType != "" && lineType != filterType {
continue
}
nestedPath := include.target
if filepath.IsAbs(nestedPath) {
nestedPath = filepath.Clean(nestedPath)
} else {
located, err := r.locate(include.target)
if err != nil {
return fmt.Errorf("failed to read PAM file %s: %w", include.target, err)
}
nestedPath = located
}
if err := r.analyzeInto(nestedPath, include.filterType, append(stack, path), acc); err != nil {
return err
}
continue
}
lineType := pamDirectiveType(trimmed)
if lineType == "" {
acc.unknownDirectives = append(acc.unknownDirectives, trimmed)
continue
}
if filterType != "" && lineType != filterType {
continue
}
acc.lines = append(acc.lines, rawLine)
if lineType == "auth" {
acc.hasAuth = true
}
foundModule := false
for _, field := range strings.Fields(trimmed) {
if strings.HasPrefix(field, "#") {
break
}
if strings.Contains(field, "pam_fprintd") {
acc.inlineFingerprint = true
}
if strings.Contains(field, "pam_u2f") {
acc.inlineU2f = true
}
if !foundModule && strings.HasSuffix(field, ".so") {
acc.modules = append(acc.modules, field)
foundModule = true
}
}
}
return nil
}
// Earlier base dir wins per name (libpam precedence).
func ListLockscreenPamServices() []LockscreenPamServiceInfo {
return listLockscreenPamServices(lockscreenPamBaseDirs, os.ReadFile)
}
func listLockscreenPamServices(baseDirs []string, readFile func(string) ([]byte, error)) []LockscreenPamServiceInfo {
resolver := lockscreenPamResolver{baseDirs: baseDirs, readFile: readFile}
out := make([]LockscreenPamServiceInfo, 0, len(lockscreenPamCandidateServices))
for _, name := range lockscreenPamCandidateServices {
path, err := resolver.locate(name)
if err != nil {
continue
}
info := LockscreenPamServiceInfo{
Name: name,
Dir: filepath.Dir(path),
Path: path,
}
if analysis := resolver.analyzePath(path); analysis.err == nil {
info.HasAuth = analysis.hasAuth
info.InlineFingerprint = analysis.inlineFingerprint
info.InlineU2f = analysis.inlineU2f
}
out = append(out, info)
}
return out
}
func ValidateLockscreenPamService(name string) LockscreenPamValidation {
return validateLockscreenPam(name, "", defaultValidateDeps())
}
func ValidateLockscreenPamPath(path string) LockscreenPamValidation {
return validateLockscreenPam("", path, defaultValidateDeps())
}
func validateLockscreenPam(serviceName string, path string, deps lockscreenPamValidateDeps) LockscreenPamValidation {
result := LockscreenPamValidation{
MissingModules: []string{},
Warnings: []string{},
Errors: []string{},
}
resolver := lockscreenPamResolver{baseDirs: deps.baseDirs, readFile: deps.readFile}
var analysis lockscreenPamAnalysis
if path != "" {
result.Path = path
analysis = resolver.analyzePath(path)
} else {
located, err := resolver.locate(serviceName)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("PAM service %q not found: %v", serviceName, err))
return result
}
result.Path = located
analysis = resolver.analyzePath(located)
}
if analysis.err != nil {
result.Errors = append(result.Errors, analysis.err.Error())
return result
}
result.HasAuth = analysis.hasAuth
result.InlineFingerprint = analysis.inlineFingerprint
result.InlineU2f = analysis.inlineU2f
if !analysis.hasAuth {
result.Errors = append(result.Errors, "no auth directives found after include resolution")
}
for _, directive := range analysis.unknownDirectives {
result.Warnings = append(result.Warnings, "unsupported PAM directive (libpam may still handle it at runtime): "+directive)
}
seen := map[string]bool{}
for _, ref := range analysis.modules {
name := filepath.Base(ref)
if seen[name] {
continue
}
seen[name] = true
if moduleReferenceExists(ref, deps) {
continue
}
result.MissingModules = append(result.MissingModules, name)
result.Warnings = append(result.Warnings, "referenced PAM module not found: "+name)
}
if analysis.inlineFingerprint {
result.Warnings = append(result.Warnings, "pam_fprintd is present in the resolved stack; may double-prompt with DMS's separate fingerprint context")
}
if analysis.inlineU2f {
result.Warnings = append(result.Warnings, "pam_u2f is present in the resolved stack; may double-prompt with DMS's separate U2F context")
}
result.Valid = len(result.Errors) == 0
return result
}
func moduleReferenceExists(ref string, deps lockscreenPamValidateDeps) bool {
if filepath.IsAbs(ref) {
_, err := deps.stat(ref)
return err == nil
}
return deps.pamModuleExists(ref)
}
const UserLockscreenPamService = "dankshell"
func UserLockscreenPamDir() string {
return filepath.Join(utils.XDGStateHome(), "DankMaterialShell", "pam")
}
// WriteUserLockscreenPamConfig resolves the distro's real auth stack into a
// self-contained lock-screen service under the user state dir, unprivileged
// (reads world-readable PAM dirs, writes the user's own state dir). Rewrites
// only on change to avoid inotify churn. Returns the written path.
func WriteUserLockscreenPamConfig(logFunc func(string)) (string, error) {
content, err := buildManagedLockscreenPamContent(lockscreenPamBaseDirs, os.ReadFile)
if err != nil {
return "", fmt.Errorf("failed to resolve system PAM auth stack: %w", err)
}
dir := UserLockscreenPamDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("failed to create %s: %w", dir, err)
}
path := filepath.Join(dir, UserLockscreenPamService)
if existing, err := os.ReadFile(path); err == nil && string(existing) == content {
return path, nil
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
return "", fmt.Errorf("failed to write %s: %w", path, err)
}
if logFunc != nil {
logFunc("✓ Wrote lock-screen PAM config " + path)
}
return path, nil
}
func buildManagedLockscreenU2FPamContent() string { func buildManagedLockscreenU2FPamContent() string {
var b strings.Builder var b strings.Builder
b.WriteString("#%PAM-1.0\n") b.WriteString("#%PAM-1.0\n")
@@ -909,7 +522,7 @@ func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string,
return fmt.Errorf("failed to read %s: %w", deps.dankshellPath, err) return fmt.Errorf("failed to read %s: %w", deps.dankshellPath, err)
} }
content, err := buildManagedLockscreenPamContent([]string{deps.pamDir}, deps.readFile) content, err := buildManagedLockscreenPamContent(deps.pamDir, deps.readFile)
if err != nil { if err != nil {
return fmt.Errorf("failed to build %s from %s: %w", deps.dankshellPath, filepath.Join(deps.pamDir, "login"), err) return fmt.Errorf("failed to build %s from %s: %w", deps.dankshellPath, filepath.Join(deps.pamDir, "login"), err)
} }
@@ -1112,7 +725,7 @@ func syncGreeterPamConfigWithDeps(logFunc func(string), sudoPassword string, set
if wantFprint || wantU2f { if wantFprint || wantU2f {
blockLines := []string{GreeterPamManagedBlockStart} blockLines := []string{GreeterPamManagedBlockStart}
if wantFprint { if wantFprint {
blockLines = append(blockLines, "auth sufficient pam_fprintd.so max-tries=2 timeout=10") blockLines = append(blockLines, "auth sufficient pam_fprintd.so max-tries=1 timeout=5")
} }
if wantU2f { if wantU2f {
blockLines = append(blockLines, "auth sufficient pam_u2f.so cue nouserok timeout=10") blockLines = append(blockLines, "auth sufficient pam_u2f.so cue nouserok timeout=10")
+3 -464
View File
@@ -246,31 +246,6 @@ func TestBuildManagedLockscreenPamContent(t *testing.T) {
"session optional pam_lastlog.so silent": 1, "session optional pam_lastlog.so silent": 1,
}, },
}, },
{
name: "falls back to system-auth when login is absent",
files: map[string]string{
"system-auth": "#%PAM-1.0\nauth sufficient pam_unix.so try_first_pass nullok\naccount required pam_unix.so\n",
},
wantContains: []string{
"auth sufficient pam_unix.so try_first_pass nullok",
"account required pam_unix.so",
},
},
{
name: "no usable service when none of the candidates exist",
files: map[string]string{
"other": "#%PAM-1.0\nauth required pam_deny.so\n",
},
wantErr: "no usable PAM auth service found",
},
{
name: "existing login with bad include is authoritative and does not fall back",
files: map[string]string{
"login": "#%PAM-1.0\nauth include missing-auth\n",
"system-auth": "#%PAM-1.0\nauth sufficient pam_unix.so\naccount required pam_unix.so\n",
},
wantErr: "failed to read PAM file",
},
{ {
name: "missing include fails", name: "missing include fails",
files: map[string]string{ files: map[string]string{
@@ -306,7 +281,7 @@ func TestBuildManagedLockscreenPamContent(t *testing.T) {
env.writePamFile(t, name, content) env.writePamFile(t, name, content)
} }
content, err := buildManagedLockscreenPamContent([]string{env.pamDir}, os.ReadFile) content, err := buildManagedLockscreenPamContent(env.pamDir, os.ReadFile)
if tt.wantErr != "" { if tt.wantErr != "" {
if err == nil { if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr) t.Fatalf("expected error containing %q, got nil", tt.wantErr)
@@ -339,192 +314,6 @@ func TestBuildManagedLockscreenPamContent(t *testing.T) {
} }
} }
// Real /etc/pam.d layouts of the non-Arch-shaped distros (#2789).
func TestBuildManagedLockscreenPamContent_DistroShapes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
files map[string]string
wantContains []string
wantNotContains []string
}{
{
// openSUSE: `include` (not @include), common-auth symlinked to
// common-auth-pc (here just a plain file), bracketed securetty
// control, keyring modules, pam_sss.
name: "openSUSE include + common-auth + bracket control",
files: map[string]string{
"login": "#%PAM-1.0\n" +
"auth requisite pam_nologin.so\n" +
"auth [user_unknown=ignore success=ok ignore=ignore auth_err=die default=bad] pam_securetty.so\n" +
"auth include common-auth\n" +
"account include common-account\n" +
"session required pam_loginuid.so\n" +
"session include common-session\n",
"common-auth": "auth required pam_env.so\n" +
"auth optional pam_gnome_keyring.so\n" +
"auth sufficient pam_unix.so try_first_pass\n" +
"auth required pam_sss.so use_first_pass\n",
"common-account": "account required pam_unix.so try_first_pass\naccount sufficient pam_localuser.so\n",
"common-session": "session optional pam_gnome_keyring.so auto_start\n",
},
wantContains: []string{
"pam_securetty.so",
"auth sufficient pam_unix.so try_first_pass",
"auth required pam_sss.so use_first_pass",
"account required pam_unix.so try_first_pass",
},
},
{
name: "openSUSE without login stitches common-auth and common-account",
files: map[string]string{
"common-auth": "auth required pam_env.so\n" +
"auth optional pam_gnome_keyring.so\n" +
"auth sufficient pam_unix.so try_first_pass\n" +
"auth required pam_sss.so use_first_pass\n",
"common-account": "account required pam_unix.so try_first_pass\n" +
"account sufficient pam_localuser.so\n" +
"account required pam_sss.so use_first_pass\n",
},
wantContains: []string{
"auth sufficient pam_unix.so try_first_pass",
"auth required pam_sss.so use_first_pass",
"account required pam_unix.so try_first_pass",
"account required pam_sss.so use_first_pass",
},
},
{
name: "openSUSE with only common-auth resolves auth-only",
files: map[string]string{
"common-auth": "auth sufficient pam_unix.so try_first_pass\nauth required pam_deny.so\n",
},
wantContains: []string{"auth sufficient pam_unix.so try_first_pass"},
wantNotContains: []string{
"account",
},
},
{
name: "Debian @include common-auth and common-account",
files: map[string]string{
"login": "#%PAM-1.0\n" +
"auth requisite pam_nologin.so\n" +
"@include common-auth\n" +
"@include common-account\n" +
"session required pam_loginuid.so\n" +
"@include common-session\n",
"common-auth": "auth\t[success=1 default=ignore]\tpam_unix.so nullok\n" +
"auth\trequisite\t\t\tpam_deny.so\n" +
"auth\trequired\t\t\tpam_permit.so\n",
"common-account": "account\t[success=1 new_authtok_reqd=done default=ignore]\tpam_unix.so\naccount\trequisite\t\t\tpam_deny.so\n",
"common-session": "session\t[default=1]\t\t\tpam_permit.so\n",
},
wantContains: []string{
"auth\t[success=1 default=ignore]\tpam_unix.so nullok",
"account\t[success=1 new_authtok_reqd=done default=ignore]\tpam_unix.so",
},
},
{
name: "NixOS flat login with absolute paths and dash directives",
files: map[string]string{
"login": "#%PAM-1.0\n" +
"auth required /nix/store/abc-pam/lib/security/pam_unix.so likeauth nullok try_first_pass\n" +
"auth sufficient /nix/store/abc-pam-u2f/lib/security/pam_u2f.so\n" +
"-auth optional /nix/store/abc-kbd/lib/security/pam_gnome_keyring.so\n" +
"account required /nix/store/abc-pam/lib/security/pam_unix.so\n" +
"-session optional /nix/store/abc-sd/lib/security/pam_systemd.so\n",
},
wantContains: []string{
"auth required /nix/store/abc-pam/lib/security/pam_unix.so likeauth nullok try_first_pass",
"-auth optional /nix/store/abc-kbd/lib/security/pam_gnome_keyring.so",
"account required /nix/store/abc-pam/lib/security/pam_unix.so",
},
wantNotContains: []string{"pam_u2f"},
},
{
name: "Gentoo deep include chain login->system-local-login->system-login->system-auth",
files: map[string]string{
"login": "#%PAM-1.0\nauth\tinclude\t\tsystem-local-login\naccount\tinclude\t\tsystem-local-login\n",
"system-local-login": "auth\trequired\tpam_group.so\nauth\tinclude\t\tsystem-login\naccount\tinclude\t\tsystem-login\n",
"system-login": "auth\tinclude\t\tsystem-auth\naccount\tinclude\t\tsystem-auth\n",
"system-auth": "auth\trequired\tpam_env.so\nauth\tsufficient\tpam_unix.so try_first_pass likeauth nullok\nauth\trequired\tpam_deny.so\naccount\trequired\tpam_unix.so\n",
},
wantContains: []string{
"auth\trequired\tpam_group.so",
"auth\tsufficient\tpam_unix.so try_first_pass likeauth nullok",
"account\trequired\tpam_unix.so",
},
},
{
name: "no login, entry falls through to system-auth",
files: map[string]string{
"system-auth": "#%PAM-1.0\n" +
"auth required pam_env.so\n" +
"auth sufficient pam_unix.so nullok\n" +
"auth sufficient pam_sss.so forward_pass\n" +
"auth required pam_deny.so\n" +
"account required pam_unix.so\n" +
"account [default=bad success=ok user_unknown=ignore] pam_sss.so\n",
},
wantContains: []string{
"auth sufficient pam_unix.so nullok",
"auth sufficient pam_sss.so forward_pass",
"account required pam_unix.so",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
for name, content := range tt.files {
env.writePamFile(t, name, content)
}
content, err := buildManagedLockscreenPamContent([]string{env.pamDir}, os.ReadFile)
if err != nil {
t.Fatalf("buildManagedLockscreenPamContent returned error: %v", err)
}
if !strings.Contains(content, "auth") {
t.Fatalf("resolved content has no auth line:\n%s", content)
}
for _, want := range tt.wantContains {
if !strings.Contains(content, want) {
t.Errorf("missing expected string %q in output:\n%s", want, content)
}
}
for _, notWant := range tt.wantNotContains {
if strings.Contains(content, notWant) {
t.Errorf("unexpected string %q found in output:\n%s", notWant, content)
}
}
})
}
}
func TestBuildManagedLockscreenPamContent_VendorDirFallback(t *testing.T) {
t.Parallel()
// Stateless/vendored-PAM systems (Clear Linux) ship the stack in
// /usr/lib|share/pam.d with /etc/pam.d empty; includes resolve in that dir.
etcDir := t.TempDir()
vendorDir := t.TempDir()
writeTestFile(t, filepath.Join(vendorDir, "login"), "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
writeTestFile(t, filepath.Join(vendorDir, "system-auth"), "auth sufficient pam_unix.so nullok\naccount required pam_unix.so\n")
content, err := buildManagedLockscreenPamContent([]string{etcDir, vendorDir}, os.ReadFile)
if err != nil {
t.Fatalf("buildManagedLockscreenPamContent returned error: %v", err)
}
for _, want := range []string{"auth sufficient pam_unix.so nullok", "account required pam_unix.so"} {
if !strings.Contains(content, want) {
t.Errorf("missing %q in output:\n%s", want, content)
}
}
}
func TestSyncLockscreenPamConfigWithDeps(t *testing.T) { func TestSyncLockscreenPamConfigWithDeps(t *testing.T) {
t.Parallel() t.Parallel()
@@ -723,7 +512,7 @@ func TestSyncGreeterPamConfigWithDeps(t *testing.T) {
got := readFileString(t, env.greetdPath) got := readFileString(t, env.greetdPath)
for _, want := range []string{ for _, want := range []string{
GreeterPamManagedBlockStart, GreeterPamManagedBlockStart,
"auth sufficient pam_fprintd.so max-tries=2 timeout=10", "auth sufficient pam_fprintd.so max-tries=1 timeout=5",
"auth sufficient pam_u2f.so cue nouserok timeout=10", "auth sufficient pam_u2f.so cue nouserok timeout=10",
GreeterPamManagedBlockEnd, GreeterPamManagedBlockEnd,
} { } {
@@ -786,223 +575,6 @@ func TestRemoveManagedGreeterPamBlockWithDeps(t *testing.T) {
} }
} }
func (e *pamTestEnv) validateDeps() lockscreenPamValidateDeps {
return lockscreenPamValidateDeps{
baseDirs: []string{e.pamDir},
readFile: os.ReadFile,
stat: os.Stat,
pamModuleExists: func(module string) bool { return e.availableModules[module] },
}
}
func TestListLockscreenPamServices(t *testing.T) {
t.Parallel()
t.Run("dedupes by name with earlier base dir winning", func(t *testing.T) {
t.Parallel()
etcDir := t.TempDir()
vendorDir := t.TempDir()
// login exists in both dirs; system-auth only in the vendor dir.
writeTestFile(t, filepath.Join(etcDir, "login"), "#%PAM-1.0\nauth required pam_unix.so\naccount required pam_unix.so\n")
writeTestFile(t, filepath.Join(vendorDir, "login"), "#%PAM-1.0\nauth required pam_deny.so\n")
writeTestFile(t, filepath.Join(vendorDir, "system-auth"), "#%PAM-1.0\nauth sufficient pam_unix.so\naccount required pam_unix.so\n")
services := listLockscreenPamServices([]string{etcDir, vendorDir}, os.ReadFile)
if len(services) != 2 {
t.Fatalf("expected 2 services (login, system-auth), got %d: %+v", len(services), services)
}
byName := map[string]LockscreenPamServiceInfo{}
for _, s := range services {
byName[s.Name] = s
}
login, ok := byName["login"]
if !ok {
t.Fatalf("expected login service, got %+v", services)
}
if login.Dir != etcDir || login.Path != filepath.Join(etcDir, "login") {
t.Fatalf("expected login to resolve in earlier dir %s, got dir=%s path=%s", etcDir, login.Dir, login.Path)
}
if !login.HasAuth {
t.Fatalf("expected login to report hasAuth")
}
if byName["system-auth"].Dir != vendorDir {
t.Fatalf("expected system-auth to resolve in vendor dir, got %s", byName["system-auth"].Dir)
}
})
t.Run("include resolution sets hasAuth and detects inline fprintd/u2f", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so\nauth sufficient pam_fprintd.so\nauth sufficient pam_u2f.so cue\naccount required pam_unix.so\n")
services := listLockscreenPamServices([]string{env.pamDir}, os.ReadFile)
var login LockscreenPamServiceInfo
for _, s := range services {
if s.Name == "login" {
login = s
}
}
if !login.HasAuth {
t.Fatalf("expected hasAuth via resolved include")
}
if !login.InlineFingerprint || !login.InlineU2f {
t.Fatalf("expected inline fingerprint and u2f detection, got %+v", login)
}
})
}
func TestValidateLockscreenPam(t *testing.T) {
t.Parallel()
t.Run("valid service with resolved auth", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so\naccount required pam_unix.so\n")
result := validateLockscreenPam("login", "", env.validateDeps())
if !result.Valid {
t.Fatalf("expected valid result, got %+v", result)
}
if !result.HasAuth {
t.Fatalf("expected hasAuth true")
}
if len(result.Errors) != 0 {
t.Fatalf("expected no errors, got %v", result.Errors)
}
})
t.Run("path outside base dirs is read directly", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
outside := filepath.Join(t.TempDir(), "custom-pam")
writeTestFile(t, outside, "#%PAM-1.0\nauth sufficient pam_unix.so\naccount required pam_unix.so\n")
result := validateLockscreenPam("", outside, env.validateDeps())
if !result.Valid || result.Path != outside {
t.Fatalf("expected valid result for outside path, got %+v", result)
}
})
t.Run("missing module produces warning and missingModules but stays valid", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
env.writePamFile(t, "system-auth", "#%PAM-1.0\nauth sufficient pam_unix.so\nauth required pam_absent.so\naccount required pam_unix.so\n")
result := validateLockscreenPam("system-auth", "", env.validateDeps())
if !result.Valid {
t.Fatalf("expected valid despite missing module, got %+v", result)
}
if len(result.MissingModules) != 1 || result.MissingModules[0] != "pam_absent.so" {
t.Fatalf("expected missing pam_absent.so, got %v", result.MissingModules)
}
if !containsSubstr(result.Warnings, "pam_absent.so") {
t.Fatalf("expected warning about missing module, got %v", result.Warnings)
}
})
t.Run("unknown directive is a warning not an error", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
env.availableModules["pam_foo.so"] = true
env.writePamFile(t, "system-auth", "#%PAM-1.0\nauth sufficient pam_unix.so\nbadtype required pam_foo.so\naccount required pam_unix.so\n")
result := validateLockscreenPam("system-auth", "", env.validateDeps())
if !result.Valid {
t.Fatalf("expected valid with unknown directive, got %+v", result)
}
if len(result.Errors) != 0 {
t.Fatalf("expected no errors, got %v", result.Errors)
}
if !containsSubstr(result.Warnings, "unsupported PAM directive") {
t.Fatalf("expected unsupported directive warning, got %v", result.Warnings)
}
})
t.Run("cyclic include is an error", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\n")
env.writePamFile(t, "system-auth", "auth include login\n")
result := validateLockscreenPam("login", "", env.validateDeps())
if result.Valid {
t.Fatalf("expected invalid on cyclic include, got %+v", result)
}
if !containsSubstr(result.Errors, "cyclic PAM include detected") {
t.Fatalf("expected cyclic include error, got %v", result.Errors)
}
})
t.Run("no auth directives is an error", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
env.writePamFile(t, "system-auth", "#%PAM-1.0\naccount required pam_unix.so\n")
result := validateLockscreenPam("system-auth", "", env.validateDeps())
if result.Valid {
t.Fatalf("expected invalid when no auth directives, got %+v", result)
}
if !containsSubstr(result.Errors, "no auth directives") {
t.Fatalf("expected no-auth error, got %v", result.Errors)
}
})
t.Run("missing file is an error", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
result := validateLockscreenPam("", filepath.Join(env.pamDir, "does-not-exist"), env.validateDeps())
if result.Valid || len(result.Errors) == 0 {
t.Fatalf("expected invalid for missing file, got %+v", result)
}
})
t.Run("inline fingerprint and u2f produce warnings", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_unix.so"] = true
env.availableModules["pam_fprintd.so"] = true
env.availableModules["pam_u2f.so"] = true
env.writePamFile(t, "system-auth", "#%PAM-1.0\nauth sufficient pam_unix.so\nauth sufficient pam_fprintd.so\nauth sufficient pam_u2f.so cue\naccount required pam_unix.so\n")
result := validateLockscreenPam("system-auth", "", env.validateDeps())
if !result.Valid {
t.Fatalf("expected valid, got %+v", result)
}
if !result.InlineFingerprint || !result.InlineU2f {
t.Fatalf("expected inline flags set, got %+v", result)
}
if !containsSubstr(result.Warnings, "pam_fprintd") || !containsSubstr(result.Warnings, "pam_u2f") {
t.Fatalf("expected double-prompt warnings, got %v", result.Warnings)
}
})
}
func containsSubstr(items []string, substr string) bool {
for _, item := range items {
if strings.Contains(item, substr) {
return true
}
}
return false
}
func TestSyncAuthConfigWithDeps(t *testing.T) { func TestSyncAuthConfigWithDeps(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1057,7 +629,7 @@ func TestSyncAuthConfigWithDeps(t *testing.T) {
} }
greetd := readFileString(t, env.greetdPath) greetd := readFileString(t, env.greetdPath)
if !strings.Contains(greetd, "auth sufficient pam_fprintd.so max-tries=2 timeout=10") { if !strings.Contains(greetd, "auth sufficient pam_fprintd.so max-tries=1 timeout=5") {
t.Fatalf("expected greetd PAM to receive fingerprint auth block:\n%s", greetd) t.Fatalf("expected greetd PAM to receive fingerprint auth block:\n%s", greetd)
} }
if strings.Contains(greetd, "auth sufficient pam_u2f.so cue nouserok timeout=10") { if strings.Contains(greetd, "auth sufficient pam_u2f.so cue nouserok timeout=10") {
@@ -1065,39 +637,6 @@ func TestSyncAuthConfigWithDeps(t *testing.T) {
} }
}) })
t.Run("externally managed greetd is stripped and greeter sync skipped", func(t *testing.T) {
t.Parallel()
env := newPamTestEnv(t)
env.availableModules["pam_fprintd.so"] = true
env.writeSettings(t, `{"greeterPamExternallyManaged":true,"greeterEnableFprint":true}`)
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so\naccount required pam_unix.so\n")
env.writePamFile(t, "greetd", "#%PAM-1.0\nauth include system-auth\n"+
GreeterPamManagedBlockStart+"\n"+
"auth sufficient pam_fprintd.so max-tries=2 timeout=10\n"+
GreeterPamManagedBlockEnd+"\n")
var logs []string
err := syncAuthConfigWithDeps(func(msg string) {
logs = append(logs, msg)
}, "", SyncAuthOptions{HomeDir: env.homeDir}, env.deps(false))
if err != nil {
t.Fatalf("syncAuthConfigWithDeps returned error: %v", err)
}
greetd := readFileString(t, env.greetdPath)
if strings.Contains(greetd, GreeterPamManagedBlockStart) || strings.Contains(greetd, "pam_fprintd") {
t.Fatalf("expected DMS-managed block stripped from externally managed greetd:\n%s", greetd)
}
if !strings.Contains(greetd, "auth include system-auth") {
t.Fatalf("expected non-DMS greetd lines to remain:\n%s", greetd)
}
if !containsSubstr(logs, "externally managed") {
t.Fatalf("expected externally-managed skip log, got %v", logs)
}
})
t.Run("NixOS remains informational and non-mutating", func(t *testing.T) { t.Run("NixOS remains informational and non-mutating", func(t *testing.T) {
t.Parallel() t.Parallel()
-17
View File
@@ -64,20 +64,7 @@ func (m *Manager) findInstalledPath(pluginID string) (string, error) {
return m.findInDir(systemDir, pluginID) return m.findInDir(systemDir, pluginID)
} }
// isSafePluginPathComponent rejects ids that aren't a single path component,
// so filepath.Join can't resolve (and later RemoveAll) outside the plugins dir
func isSafePluginPathComponent(s string) bool {
if s == "" || s == "." || s == ".." {
return false
}
return !strings.ContainsAny(s, "/\\")
}
func (m *Manager) findInDir(dir, pluginID string) (string, error) { func (m *Manager) findInDir(dir, pluginID string) (string, error) {
if !isSafePluginPathComponent(pluginID) {
return "", fmt.Errorf("invalid plugin id: %q", pluginID)
}
// First, check if folder with exact ID name exists // First, check if folder with exact ID name exists
exactPath := filepath.Join(dir, pluginID) exactPath := filepath.Join(dir, pluginID)
if exists, _ := afero.DirExists(m.fs, exactPath); exists { if exists, _ := afero.DirExists(m.fs, exactPath); exists {
@@ -520,10 +507,6 @@ func (m *Manager) findInstalledPathByIDOrName(idOrName string) (string, error) {
} }
func (m *Manager) findInDirByIDOrName(dir, idOrName string) (string, error) { func (m *Manager) findInDirByIDOrName(dir, idOrName string) (string, error) {
if !isSafePluginPathComponent(idOrName) {
return "", fmt.Errorf("invalid plugin id/name: %q", idOrName)
}
// Check exact folder name match first // Check exact folder name match first
exactPath := filepath.Join(dir, idOrName) exactPath := filepath.Join(dir, idOrName)
if exists, _ := afero.DirExists(m.fs, exactPath); exists { if exists, _ := afero.DirExists(m.fs, exactPath); exists {
+10 -16
View File
@@ -135,14 +135,15 @@ func EscapeSingleQuotes(s string) string {
} }
// MakeCommand returns a bash command string that runs `command` with the // MakeCommand returns a bash command string that runs `command` with the
// detected tool, prompting interactively on a TTY where applicable. The // detected tool. When the tool supports stdin passwords and password is
// sudo-with-password case lives in ExecCommand, which pipes the password via // non-empty, the password is piped in. Otherwise the tool is invoked with
// stdin so it never lands in argv. // no non-interactive flag so that an interactive TTY prompt is still
// possible for CLI callers.
// //
// If detection fails, the returned shell string exits 1 with an error // If detection fails, the returned shell string exits 1 with an error
// message so callers that treat the *exec.Cmd as infallible still fail // message so callers that treat the *exec.Cmd as infallible still fail
// deterministically. // deterministically.
func MakeCommand(command string) string { func MakeCommand(password, command string) string {
t, err := Detect() t, err := Detect()
if err != nil { if err != nil {
return failingShell(err) return failingShell(err)
@@ -150,6 +151,9 @@ func MakeCommand(command string) string {
switch t { switch t {
case ToolSudo: case ToolSudo:
if password != "" {
return fmt.Sprintf("echo '%s' | sudo -S %s", EscapeSingleQuotes(password), command)
}
return fmt.Sprintf("sudo %s", command) return fmt.Sprintf("sudo %s", command)
case ToolDoas: case ToolDoas:
return fmt.Sprintf("doas sh -c '%s'", EscapeSingleQuotes(command)) return fmt.Sprintf("doas sh -c '%s'", EscapeSingleQuotes(command))
@@ -162,19 +166,9 @@ func MakeCommand(command string) string {
// ExecCommand builds an exec.Cmd that runs `command` as root via the // ExecCommand builds an exec.Cmd that runs `command` as root via the
// detected tool. Detection errors surface at Run() time as a failing // detected tool. Detection errors surface at Run() time as a failing
// command writing a clear error to stderr. A sudo password is piped via // command writing a clear error to stderr.
// stdin (sudo -S) so it never appears in argv.
func ExecCommand(ctx context.Context, password, command string) *exec.Cmd { func ExecCommand(ctx context.Context, password, command string) *exec.Cmd {
t, err := Detect() return exec.CommandContext(ctx, "bash", "-c", MakeCommand(password, command))
if err != nil {
return exec.CommandContext(ctx, "bash", "-c", failingShell(err))
}
if t == ToolSudo && password != "" {
cmd := exec.CommandContext(ctx, "sudo", "-S", "sh", "-c", command)
cmd.Stdin = strings.NewReader(password + "\n")
return cmd
}
return exec.CommandContext(ctx, "bash", "-c", MakeCommand(command))
} }
// ExecArgv builds an exec.Cmd that runs argv as root via the detected tool. // ExecArgv builds an exec.Cmd that runs argv as root via the detected tool.
-282
View File
@@ -1,282 +0,0 @@
package qrcode
import (
"bytes"
"fmt"
"image"
"image/color"
_ "image/jpeg"
_ "image/png"
"os"
"strings"
qr "github.com/yeqown/go-qrcode/v2"
"github.com/yeqown/go-qrcode/writer/standard"
)
type TermOptions struct {
ECC string
Version int
QuietZone int
Invert bool
Fg string
Bg string
}
type ImageOptions struct {
ECC string
Version int
ModuleSize int
Fg string
Bg string
Transparent bool
Border int
Shape string
Logo string
LogoScale int
}
var wifiEscaper = strings.NewReplacer(`\`, `\\`, `;`, `\;`, `,`, `\,`, `:`, `\:`, `"`, `\"`)
func WiFiString(security, ssid, password string, hidden bool) string {
if security == "" {
security = "WPA"
}
var b strings.Builder
fmt.Fprintf(&b, "WIFI:T:%s;S:%s;", security, wifiEscaper.Replace(ssid))
if !strings.EqualFold(security, "nopass") {
fmt.Fprintf(&b, "P:%s;", wifiEscaper.Replace(password))
}
if hidden {
b.WriteString("H:true;")
}
b.WriteString(";")
return b.String()
}
// Colors are painted explicitly on both halves of each ▀ cell so polarity
// does not depend on the terminal theme.
func RenderTerminal(text string, opt TermOptions) (string, error) {
fg, err := parseColor(opt.Fg, color.RGBA{A: 255})
if err != nil {
return "", err
}
bg, err := parseColor(opt.Bg, color.RGBA{R: 255, G: 255, B: 255, A: 255})
if err != nil {
return "", err
}
if opt.Invert {
fg, bg = bg, fg
}
mat, err := encode(text, opt.ECC, opt.Version)
if err != nil {
return "", err
}
grid := bitmapWithQuietZone(mat, opt.QuietZone)
var b strings.Builder
for y := 0; y < len(grid); y += 2 {
for x := range grid[y] {
top := moduleColor(grid[y][x], fg, bg)
bottom := bg
if y+1 < len(grid) {
bottom = moduleColor(grid[y+1][x], fg, bg)
}
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm\x1b[48;2;%d;%d;%dm▀", top.R, top.G, top.B, bottom.R, bottom.G, bottom.B)
}
b.WriteString("\x1b[0m\n")
}
return b.String(), nil
}
func RenderPNG(text string, opt ImageOptions) ([]byte, error) {
encOpts, err := encodeOptions(opt.ECC, opt.Version)
if err != nil {
return nil, err
}
imgOpts, err := imageOptions(opt)
if err != nil {
return nil, err
}
q, err := qr.NewWith(text, encOpts...)
if err != nil {
return nil, err
}
var buf bytes.Buffer
w := standard.NewWithWriter(nopCloser{&buf}, imgOpts...)
if err := q.Save(w); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func encodeOptions(ecc string, version int) ([]qr.EncodeOption, error) {
eccOpt, err := eccOption(ecc)
if err != nil {
return nil, err
}
opts := []qr.EncodeOption{eccOpt}
switch {
case version == 0:
case version >= 1 && version <= 40:
opts = append(opts, qr.WithVersion(version))
default:
return nil, fmt.Errorf("QR version must be 1-40, got %d", version)
}
return opts, nil
}
func eccOption(level string) (qr.EncodeOption, error) {
switch strings.ToUpper(level) {
case "", "M":
return qr.WithErrorCorrectionLevel(qr.ErrorCorrectionMedium), nil
case "L":
return qr.WithErrorCorrectionLevel(qr.ErrorCorrectionLow), nil
case "Q":
return qr.WithErrorCorrectionLevel(qr.ErrorCorrectionQuart), nil
case "H":
return qr.WithErrorCorrectionLevel(qr.ErrorCorrectionHighest), nil
default:
return nil, fmt.Errorf("invalid error correction level %q (want L, M, Q, or H)", level)
}
}
func imageOptions(opt ImageOptions) ([]standard.ImageOption, error) {
if opt.ModuleSize < 0 || opt.ModuleSize > 255 {
return nil, fmt.Errorf("module size must be 0-255, got %d", opt.ModuleSize)
}
opts := []standard.ImageOption{standard.WithBuiltinImageEncoder(standard.PNG_FORMAT)}
if opt.ModuleSize > 0 {
opts = append(opts, standard.WithQRWidth(uint8(opt.ModuleSize)))
}
if opt.Border >= 0 {
opts = append(opts, standard.WithBorderWidth(opt.Border))
}
switch {
case opt.Transparent:
opts = append(opts, standard.WithBgTransparent())
case opt.Bg != "":
c, err := parseColor(opt.Bg, color.RGBA{})
if err != nil {
return nil, err
}
opts = append(opts, standard.WithBgColor(c))
}
if opt.Fg != "" {
c, err := parseColor(opt.Fg, color.RGBA{})
if err != nil {
return nil, err
}
opts = append(opts, standard.WithFgColor(c))
}
switch strings.ToLower(opt.Shape) {
case "", "square":
case "circle":
opts = append(opts, standard.WithCircleShape())
default:
return nil, fmt.Errorf("invalid shape %q (want square or circle)", opt.Shape)
}
if opt.Logo != "" {
img, err := loadImage(opt.Logo)
if err != nil {
return nil, err
}
opts = append(opts, standard.WithLogoImage(img))
if opt.LogoScale > 0 {
opts = append(opts, standard.WithLogoSizeMultiplier(opt.LogoScale))
}
}
return opts, nil
}
func encode(text, ecc string, version int) (qr.Matrix, error) {
opts, err := encodeOptions(ecc, version)
if err != nil {
return qr.Matrix{}, err
}
q, err := qr.NewWith(text, opts...)
if err != nil {
return qr.Matrix{}, err
}
mw := &matrixWriter{}
if err := q.Save(mw); err != nil {
return qr.Matrix{}, err
}
return mw.mat, nil
}
func bitmapWithQuietZone(mat qr.Matrix, quiet int) [][]bool {
if quiet < 0 {
quiet = 0
}
src := mat.Bitmap()
h := len(src)
w := 0
if h > 0 {
w = len(src[0])
}
out := make([][]bool, h+quiet*2)
for y := range out {
out[y] = make([]bool, w+quiet*2)
}
for y := range h {
for x := range w {
out[y+quiet][x+quiet] = src[y][x]
}
}
return out
}
func moduleColor(dark bool, fg, bg color.RGBA) color.RGBA {
if dark {
return fg
}
return bg
}
func parseColor(hex string, def color.RGBA) (color.RGBA, error) {
hex = strings.TrimPrefix(strings.TrimSpace(hex), "#")
if hex == "" {
return def, nil
}
if len(hex) == 3 {
hex = fmt.Sprintf("%c%c%c%c%c%c", hex[0], hex[0], hex[1], hex[1], hex[2], hex[2])
}
var r, g, b int
if len(hex) != 6 {
return def, fmt.Errorf("invalid color %q (want #RGB or #RRGGBB)", hex)
}
if _, err := fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b); err != nil {
return def, fmt.Errorf("invalid color %q (want #RGB or #RRGGBB)", hex)
}
return color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255}, nil
}
func loadImage(path string) (image.Image, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return nil, fmt.Errorf("decode %s: %w", path, err)
}
return img, nil
}
type matrixWriter struct{ mat qr.Matrix }
func (w *matrixWriter) Write(m qr.Matrix) error { w.mat = m; return nil }
func (w *matrixWriter) Close() error { return nil }
type nopCloser struct{ *bytes.Buffer }
func (nopCloser) Close() error { return nil }
-5
View File
@@ -178,13 +178,9 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
yInverted := false yInverted := false
var format uint32 var format uint32
scale := 1.0
if r.selection.surface != nil { if r.selection.surface != nil {
yInverted = r.selection.surface.yInverted yInverted = r.selection.surface.yInverted
format = r.selection.surface.screenFormat format = r.selection.surface.screenFormat
if s := r.selection.surface.output.fractionalScale; s > 0 {
scale = s
}
} }
return &CaptureResult{ return &CaptureResult{
@@ -192,7 +188,6 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
Region: r.result, Region: r.result,
YInverted: yInverted, YInverted: yInverted,
Format: format, Format: format,
Scale: scale,
}, false, nil }, false, nil
} }
+21 -34
View File
@@ -28,21 +28,6 @@ type CaptureResult struct {
Region Region Region Region
YInverted bool YInverted bool
Format uint32 Format uint32
Scale float64
}
func (o *WaylandOutput) effectiveScale() float64 {
scale := o.fractionalScale
if scale <= 0 && DetectCompositor() == CompositorHyprland {
scale = GetHyprlandMonitorScale(o.name)
}
if scale <= 0 {
scale = float64(o.scale)
}
if scale <= 0 {
return 1.0
}
return scale
} }
type Screenshoter struct { type Screenshoter struct {
@@ -270,7 +255,6 @@ func (s *Screenshoter) captureMangoWindow(output *WaylandOutput, region Region,
Region: region, Region: region,
YInverted: false, YInverted: false,
Format: result.Format, Format: result.Format,
Scale: scale,
}, nil }, nil
} }
@@ -446,7 +430,6 @@ func (s *Screenshoter) captureAllScreens() (*CaptureResult, error) {
Buffer: composite, Buffer: composite,
Region: Region{X: int32(minX), Y: int32(minY), Width: int32(totalW), Height: int32(totalH)}, Region: Region{X: int32(minX), Y: int32(minY), Width: int32(totalW), Height: int32(totalH)},
Format: format, Format: format,
Scale: maxScale,
}, nil }, nil
} }
@@ -519,7 +502,6 @@ func (s *Screenshoter) captureWholeOutput(output *WaylandOutput) (*CaptureResult
if err != nil { if err != nil {
return nil, err return nil, err
} }
result.Scale = output.effectiveScale()
if result.YInverted { if result.YInverted {
result.Buffer.FlipVertical() result.Buffer.FlipVertical()
@@ -622,7 +604,6 @@ func (s *Screenshoter) captureAndCrop(output *WaylandOutput, region Region) (*Ca
Region: region, Region: region,
YInverted: false, YInverted: false,
Format: result.Format, Format: result.Format,
Scale: scale,
}, nil }, nil
} }
@@ -631,7 +612,16 @@ func (s *Screenshoter) captureRegionOnOutput(output *WaylandOutput, region Regio
return s.captureRegionOnTransformedOutput(output, region) return s.captureRegionOnTransformedOutput(output, region)
} }
scale := output.effectiveScale() scale := output.fractionalScale
if scale <= 0 && DetectCompositor() == CompositorHyprland {
scale = GetHyprlandMonitorScale(output.name)
}
if scale <= 0 {
scale = float64(output.scale)
}
if scale <= 0 {
scale = 1.0
}
localX := int32(float64(region.X-output.x) * scale) localX := int32(float64(region.X-output.x) * scale)
localY := int32(float64(region.Y-output.y) * scale) localY := int32(float64(region.Y-output.y) * scale)
@@ -670,12 +660,7 @@ func (s *Screenshoter) captureRegionOnOutput(output *WaylandOutput, region Regio
return nil, fmt.Errorf("capture region: %w", err) return nil, fmt.Errorf("capture region: %w", err)
} }
result, err := s.processFrame(frame, region) return s.processFrame(frame, region)
if err != nil {
return nil, err
}
result.Scale = scale
return result, nil
} }
func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, region Region) (*CaptureResult, error) { func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, region Region) (*CaptureResult, error) {
@@ -684,7 +669,16 @@ func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, r
return nil, err return nil, err
} }
scale := output.effectiveScale() scale := output.fractionalScale
if scale <= 0 && DetectCompositor() == CompositorHyprland {
scale = GetHyprlandMonitorScale(output.name)
}
if scale <= 0 {
scale = float64(output.scale)
}
if scale <= 0 {
scale = 1.0
}
localX := int(float64(region.X-output.x) * scale) localX := int(float64(region.X-output.x) * scale)
localY := int(float64(region.Y-output.y) * scale) localY := int(float64(region.Y-output.y) * scale)
@@ -736,7 +730,6 @@ func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, r
Region: region, Region: region,
YInverted: false, YInverted: false,
Format: result.Format, Format: result.Format,
Scale: scale,
}, nil }, nil
} }
@@ -754,16 +747,12 @@ func (s *Screenshoter) processFrame(frame *wlr_screencopy.ZwlrScreencopyFrameV1,
bpp := format.BytesPerPixel() bpp := format.BytesPerPixel()
if int(e.Stride) < int(e.Width)*bpp { if int(e.Stride) < int(e.Width)*bpp {
log.Error("invalid stride from compositor", "stride", e.Stride, "width", e.Width, "bpp", bpp) log.Error("invalid stride from compositor", "stride", e.Stride, "width", e.Width, "bpp", bpp)
// bail out here or the dispatch loop waits forever on a ready/failed
// event that never comes (frame.Copy is never called)
failed = true
return return
} }
var err error var err error
buf, err = CreateShmBuffer(int(e.Width), int(e.Height), int(e.Stride)) buf, err = CreateShmBuffer(int(e.Width), int(e.Height), int(e.Stride))
if err != nil { if err != nil {
log.Error("failed to create buffer", "err", err) log.Error("failed to create buffer", "err", err)
failed = true
return return
} }
buf.Format = format buf.Format = format
@@ -782,7 +771,6 @@ func (s *Screenshoter) processFrame(frame *wlr_screencopy.ZwlrScreencopyFrameV1,
pool, err = s.shm.CreatePool(buf.Fd(), int32(buf.Size())) pool, err = s.shm.CreatePool(buf.Fd(), int32(buf.Size()))
if err != nil { if err != nil {
log.Error("failed to create pool", "err", err) log.Error("failed to create pool", "err", err)
failed = true
return return
} }
@@ -791,7 +779,6 @@ func (s *Screenshoter) processFrame(frame *wlr_screencopy.ZwlrScreencopyFrameV1,
pool.Destroy() pool.Destroy()
pool = nil pool = nil
log.Error("failed to create wl_buffer", "err", err) log.Error("failed to create wl_buffer", "err", err)
failed = true
return return
} }
+10 -12
View File
@@ -138,22 +138,20 @@ func handleCopyEntry(conn net.Conn, req models.Request, m *Manager) {
return return
} }
if entry.AltMimeType == "" { filePath := m.EntryToFile(entry)
filePath := m.EntryToFile(entry) if filePath != "" {
if filePath != "" { if err := m.CopyFile(filePath); err != nil {
if err := m.CopyFile(filePath); err != nil { models.RespondError(conn, req.ID, err.Error())
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, map[string]any{
"success": true,
"filePath": filePath,
})
return return
} }
models.Respond(conn, req.ID, map[string]any{
"success": true,
"filePath": filePath,
})
return
} }
if err := m.SetClipboardEntry(entry); err != nil { if err := m.SetClipboard(entry.Data, entry.MimeType); err != nil {
models.RespondError(conn, req.ID, err.Error()) models.RespondError(conn, req.ID, err.Error())
return return
} }
+40 -175
View File
@@ -279,29 +279,7 @@ func (m *Manager) setupDataDeviceSync() {
} }
w.Close() w.Close()
altMime := "" go m.readAndStore(r, preferredMime)
if m.isImageMimeType(preferredMime) && !slices.Contains(mimes, "x-special/gnome-copied-files") {
altMime = selectAltTextMimeType(mimes)
}
if altMime == "" {
go m.readAndStore(r, preferredMime, nil, "")
return
}
altR, altW, err := os.Pipe()
if err != nil {
go m.readAndStore(r, preferredMime, nil, "")
return
}
if err := typedOffer.Receive(altMime, int(altW.Fd())); err != nil {
altR.Close()
altW.Close()
go m.readAndStore(r, preferredMime, nil, "")
return
}
altW.Close()
go m.readAndStore(r, preferredMime, altR, altMime)
}) })
if err := dataMgr.GetDataDeviceWithProxy(dataDevice, m.seat); err != nil { if err := dataMgr.GetDataDeviceWithProxy(dataDevice, m.seat); err != nil {
@@ -346,42 +324,22 @@ func (m *Manager) releaseCurrentSource() {
source.Destroy() source.Destroy()
} }
func readPipeTimeout(r *os.File) []byte { func (m *Manager) readAndStore(r *os.File, mimeType string) {
defer r.Close()
cfg := m.getConfig()
done := make(chan []byte, 1) done := make(chan []byte, 1)
go func() { go func() {
data, _ := io.ReadAll(r) data, _ := io.ReadAll(r)
done <- data done <- data
}() }()
var data []byte
select { select {
case data := <-done: case data = <-done:
return data
case <-time.After(500 * time.Millisecond): case <-time.After(500 * time.Millisecond):
return nil return
}
}
func (m *Manager) readAndStore(r *os.File, mimeType string, altR *os.File, altMime string) {
defer r.Close()
cfg := m.getConfig()
altCh := make(chan []byte, 1)
switch altR {
case nil:
altCh <- nil
default:
go func() {
defer altR.Close()
altCh <- readPipeTimeout(altR)
}()
}
data := readPipeTimeout(r)
altData := <-altCh
if len(bytes.TrimSpace(altData)) == 0 || int64(len(altData)) > cfg.MaxEntrySize {
altData, altMime = nil, ""
} }
if len(data) == 0 || int64(len(data)) > cfg.MaxEntrySize { if len(data) == 0 || int64(len(data)) > cfg.MaxEntrySize {
@@ -392,14 +350,14 @@ func (m *Manager) readAndStore(r *os.File, mimeType string, altR *os.File, altMi
} }
if !cfg.Disabled && m.db != nil { if !cfg.Disabled && m.db != nil {
m.storeClipboardEntry(data, mimeType, altData, altMime) m.storeClipboardEntry(data, mimeType)
} }
m.updateState() m.updateState()
m.notifySubscribers() m.notifySubscribers()
} }
func (m *Manager) storeClipboardEntry(data []byte, mimeType string, altData []byte, altMime string) { func (m *Manager) storeClipboardEntry(data []byte, mimeType string) {
if mimeType == "text/uri-list" { if mimeType == "text/uri-list" {
if imgData, imgMime, ok := m.tryReadImageFromURI(data); ok { if imgData, imgMime, ok := m.tryReadImageFromURI(data); ok {
data = imgData data = imgData
@@ -408,13 +366,11 @@ func (m *Manager) storeClipboardEntry(data []byte, mimeType string, altData []by
} }
entry := Entry{ entry := Entry{
Data: data, Data: data,
MimeType: mimeType, MimeType: mimeType,
Size: len(data), Size: len(data),
Timestamp: time.Now(), Timestamp: time.Now(),
IsImage: m.isImageMimeType(mimeType), IsImage: m.isImageMimeType(mimeType),
AltData: altData,
AltMimeType: altMime,
} }
switch { switch {
@@ -527,12 +483,6 @@ func encodeEntry(e Entry) ([]byte, error) {
} else { } else {
buf.WriteByte(0) buf.WriteByte(0)
} }
if e.AltMimeType != "" {
binary.Write(buf, binary.BigEndian, uint32(len(e.AltMimeType)))
buf.WriteString(e.AltMimeType)
binary.Write(buf, binary.BigEndian, uint32(len(e.AltData)))
buf.Write(e.AltData)
}
return buf.Bytes(), nil return buf.Bytes(), nil
} }
@@ -597,21 +547,6 @@ func decodeEntryFields(data []byte, withData bool) (Entry, error) {
e.Pinned = pinnedByte == 1 e.Pinned = pinnedByte == 1
} }
if buf.Len() >= 4 {
var altMimeLen uint32
binary.Read(buf, binary.BigEndian, &altMimeLen)
altMimeBytes := make([]byte, altMimeLen)
buf.Read(altMimeBytes)
e.AltMimeType = string(altMimeBytes)
var altDataLen uint32
binary.Read(buf, binary.BigEndian, &altDataLen)
if withData {
e.AltData = make([]byte, altDataLen)
buf.Read(e.AltData)
}
}
return e, nil return e, nil
} }
@@ -628,27 +563,10 @@ func computeHash(data []byte) uint64 {
} }
func extractHash(data []byte) uint64 { func extractHash(data []byte) uint64 {
buf := bytes.NewReader(data) if len(data) < 9 {
if _, err := buf.Seek(8, io.SeekStart); err != nil {
return 0 return 0
} }
for range 3 { // data, mime type, preview return binary.BigEndian.Uint64(data[len(data)-9 : len(data)-1])
var length uint32
if binary.Read(buf, binary.BigEndian, &length) != nil {
return 0
}
if _, err := buf.Seek(int64(length), io.SeekCurrent); err != nil {
return 0
}
}
if _, err := buf.Seek(4+8+1, io.SeekCurrent); err != nil { // size, timestamp, isImage
return 0
}
var hash uint64
if binary.Read(buf, binary.BigEndian, &hash) != nil {
return 0
}
return hash
} }
func (m *Manager) hasSensitiveMimeType(mimes []string) bool { func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
@@ -693,23 +611,6 @@ func (m *Manager) selectMimeType(mimes []string) string {
return "" return ""
} }
var altTextMimeTypes = []string{
"text/plain;charset=utf-8",
"text/plain",
"UTF8_STRING",
"STRING",
"TEXT",
}
func selectAltTextMimeType(mimes []string) string {
for _, pref := range altTextMimeTypes {
if slices.Contains(mimes, pref) {
return pref
}
}
return ""
}
func (m *Manager) isImageMimeType(mime string) bool { func (m *Manager) isImageMimeType(mime string) bool {
return strings.HasPrefix(mime, "image/") return strings.HasPrefix(mime, "image/")
} }
@@ -1028,15 +929,13 @@ func (m *Manager) CreateHistoryEntryFromPinned(pinnedEntry *Entry) error {
// Create a new unpinned entry with the same data // Create a new unpinned entry with the same data
newEntry := Entry{ newEntry := Entry{
Data: pinnedEntry.Data, Data: pinnedEntry.Data,
MimeType: pinnedEntry.MimeType, MimeType: pinnedEntry.MimeType,
Size: pinnedEntry.Size, Size: pinnedEntry.Size,
Timestamp: time.Now(), Timestamp: time.Now(),
IsImage: pinnedEntry.IsImage, IsImage: pinnedEntry.IsImage,
Preview: pinnedEntry.Preview, Preview: pinnedEntry.Preview,
Pinned: false, Pinned: false,
AltData: pinnedEntry.AltData,
AltMimeType: pinnedEntry.AltMimeType,
} }
if err := m.storeEntry(newEntry); err != nil { if err := m.storeEntry(newEntry); err != nil {
@@ -1162,23 +1061,6 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
return nil return nil
} }
// SetClipboardEntry takes the selection serving the entry's primary
// representation plus its stored alternate, so history restores keep
// both the text and image sides pasteable.
func (m *Manager) SetClipboardEntry(entry *Entry) error {
if int64(len(entry.Data)) > m.config.MaxEntrySize {
return fmt.Errorf("data too large")
}
offers := clipboardstore.ExpandOffers(slices.Clone(entry.Data), entry.MimeType)
if entry.AltMimeType != "" {
offers = append(offers, clipboardstore.ExpandOffers(slices.Clone(entry.AltData), entry.AltMimeType)...)
}
m.takeSelection(offers)
return nil
}
// takeSelection makes the daemon the selection owner, serving the given // takeSelection makes the daemon the selection owner, serving the given
// offers until another client claims the clipboard. // offers until another client claims the clipboard.
func (m *Manager) takeSelection(offers []clipboardstore.Offer) { func (m *Manager) takeSelection(offers []clipboardstore.Offer) {
@@ -1272,20 +1154,16 @@ func (m *Manager) PasteText() (string, error) {
} }
entry := history[0] entry := history[0]
if entry.IsImage {
return "", fmt.Errorf("clipboard contains image, not text")
}
fullEntry, err := m.GetEntry(entry.ID) fullEntry, err := m.GetEntry(entry.ID)
if err != nil { if err != nil {
return "", err return "", err
} }
switch { return string(fullEntry.Data), nil
case !fullEntry.IsImage:
return string(fullEntry.Data), nil
case fullEntry.AltMimeType != "":
return string(fullEntry.AltData), nil
default:
return "", fmt.Errorf("clipboard contains image, not text")
}
} }
func (m *Manager) Close() { func (m *Manager) Close() {
@@ -1961,34 +1839,21 @@ func (m *Manager) EntryToFile(entry *Entry) string {
return "" return ""
} }
func (m *Manager) dbusConnForFlatpak() (*dbus.Conn, error) {
m.dbusConnMutex.Lock()
defer m.dbusConnMutex.Unlock()
if m.dbusConn != nil {
return m.dbusConn, nil
}
conn, err := dbus.ConnectSessionBus()
if err != nil {
return nil, fmt.Errorf("connect session bus: %w", err)
}
if !conn.SupportsUnixFDs() {
conn.Close()
return nil, fmt.Errorf("D-Bus connection does not support Unix FD passing")
}
m.dbusConn = conn
return conn, nil
}
func (m *Manager) ExportFileForFlatpak(filePath string) (string, error) { func (m *Manager) ExportFileForFlatpak(filePath string) (string, error) {
if _, err := os.Stat(filePath); err != nil { if _, err := os.Stat(filePath); err != nil {
return "", fmt.Errorf("file not found: %w", err) return "", fmt.Errorf("file not found: %w", err)
} }
dbusConn, err := m.dbusConnForFlatpak() if m.dbusConn == nil {
if err != nil { conn, err := dbus.ConnectSessionBus()
return "", err if err != nil {
return "", fmt.Errorf("connect session bus: %w", err)
}
if !conn.SupportsUnixFDs() {
conn.Close()
return "", fmt.Errorf("D-Bus connection does not support Unix FD passing")
}
m.dbusConn = conn
} }
file, err := os.Open(filePath) file, err := os.Open(filePath)
@@ -1997,7 +1862,7 @@ func (m *Manager) ExportFileForFlatpak(filePath string) (string, error) {
} }
fd := int(file.Fd()) fd := int(file.Fd())
portal := dbusConn.Object("org.freedesktop.portal.Documents", "/org/freedesktop/portal/documents") portal := m.dbusConn.Object("org.freedesktop.portal.Documents", "/org/freedesktop/portal/documents")
var docIds []string var docIds []string
var extra map[string]dbus.Variant var extra map[string]dbus.Variant
@@ -144,72 +144,6 @@ func TestEncodeDecodeEntry_LargeData(t *testing.T) {
assert.Equal(t, original.Size, decoded.Size) assert.Equal(t, original.Size, decoded.Size)
} }
func TestEncodeDecodeEntry_AltRepresentation(t *testing.T) {
original := Entry{
ID: 555,
Data: []byte{0x42, 0x4D, 0x01, 0x02},
MimeType: "image/bmp",
Preview: "[[ image 4 B bmp 85x19 ]]",
Size: 4,
Timestamp: time.Now().Truncate(time.Second),
IsImage: true,
Hash: computeHash([]byte{0x42, 0x4D, 0x01, 0x02}),
Pinned: true,
AltData: []byte("real text from OneNote"),
AltMimeType: "text/plain;charset=utf-8",
}
encoded, err := encodeEntry(original)
assert.NoError(t, err)
decoded, err := decodeEntry(encoded)
assert.NoError(t, err)
assert.Equal(t, original.Data, decoded.Data)
assert.Equal(t, original.MimeType, decoded.MimeType)
assert.True(t, decoded.Pinned)
assert.Equal(t, original.AltData, decoded.AltData)
assert.Equal(t, original.AltMimeType, decoded.AltMimeType)
meta, err := decodeEntryMeta(encoded)
assert.NoError(t, err)
assert.Empty(t, meta.Data)
assert.Equal(t, original.AltMimeType, meta.AltMimeType)
assert.Equal(t, original.Hash, extractHash(encoded))
}
func TestExtractHash_NoAlt(t *testing.T) {
entry := Entry{
ID: 1,
Data: []byte("plain entry"),
MimeType: "text/plain",
Preview: "plain entry",
Size: 11,
Timestamp: time.Now().Truncate(time.Second),
Hash: computeHash([]byte("plain entry")),
}
encoded, err := encodeEntry(entry)
assert.NoError(t, err)
assert.Equal(t, entry.Hash, extractHash(encoded))
}
func TestSelectAltTextMimeType(t *testing.T) {
tests := []struct {
mimes []string
expected string
}{
{[]string{"image/bmp", "TEXT", "text/html", "text/plain", "text/plain;charset=utf-8", "UTF8_STRING"}, "text/plain;charset=utf-8"},
{[]string{"image/png", "UTF8_STRING"}, "UTF8_STRING"},
{[]string{"image/png", "text/html"}, ""},
{[]string{"image/png"}, ""},
}
for _, tt := range tests {
assert.Equal(t, tt.expected, selectAltTextMimeType(tt.mimes))
}
}
func TestStateEqual_BothNil(t *testing.T) { func TestStateEqual_BothNil(t *testing.T) {
assert.False(t, stateEqual(nil, nil)) assert.False(t, stateEqual(nil, nil))
} }
+10 -14
View File
@@ -95,17 +95,15 @@ type SearchResult struct {
} }
type Entry struct { type Entry struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
Data []byte `json:"data,omitempty"` Data []byte `json:"data,omitempty"`
MimeType string `json:"mimeType"` MimeType string `json:"mimeType"`
Preview string `json:"preview"` Preview string `json:"preview"`
Size int `json:"size"` Size int `json:"size"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
IsImage bool `json:"isImage"` IsImage bool `json:"isImage"`
Hash uint64 `json:"hash,omitempty"` Hash uint64 `json:"hash,omitempty"`
Pinned bool `json:"pinned"` Pinned bool `json:"pinned"`
AltData []byte `json:"altData,omitempty"`
AltMimeType string `json:"altMimeType,omitempty"`
} }
type State struct { type State struct {
@@ -155,9 +153,7 @@ type Manager struct {
notifierWg sync.WaitGroup notifierWg sync.WaitGroup
lastState *State lastState *State
// lazily created by dbusConnForFlatpak under dbusConnMutex dbusConn *dbus.Conn
dbusConn *dbus.Conn
dbusConnMutex sync.Mutex
} }
func (m *Manager) GetState() State { func (m *Manager) GetState() State {
-12
View File
@@ -37,9 +37,6 @@ func (sm *SubscriptionManager) Start() error {
return fmt.Errorf("subscription manager already running") return fmt.Errorf("subscription manager already running")
} }
sm.running = true sm.running = true
// replace the channel closed by the previous Stop(); doing it here rather
// than in Stop() guarantees a lagging eventHandler still observes the close
sm.eventChan = make(chan SubscriptionEvent, 100)
sm.mu.Unlock() sm.mu.Unlock()
subID, err := sm.createSubscription() subID, err := sm.createSubscription()
@@ -209,8 +206,6 @@ func (sm *SubscriptionManager) parseEvent(attrs ipp.Attributes) SubscriptionEven
} }
func (sm *SubscriptionManager) Events() <-chan SubscriptionEvent { func (sm *SubscriptionManager) Events() <-chan SubscriptionEvent {
sm.mu.Lock()
defer sm.mu.Unlock()
return sm.eventChan return sm.eventChan
} }
@@ -233,13 +228,6 @@ func (sm *SubscriptionManager) Stop() {
} }
sm.stopChan = make(chan struct{}) sm.stopChan = make(chan struct{})
// the writer (notificationLoop) joined above, so closing is safe; without
// this close Manager.eventHandler never returns and Unsubscribe deadlocks
// on eventWG.Wait(). Start() allocates the replacement.
sm.mu.Lock()
close(sm.eventChan)
sm.mu.Unlock()
} }
func (sm *SubscriptionManager) cancelSubscription() { func (sm *SubscriptionManager) cancelSubscription() {
@@ -38,8 +38,6 @@ func (sm *DBusSubscriptionManager) Start() error {
return fmt.Errorf("subscription manager already running") return fmt.Errorf("subscription manager already running")
} }
sm.running = true sm.running = true
// replaced here rather than in Stop(); see SubscriptionManager.Start()
sm.eventChan = make(chan SubscriptionEvent, 100)
sm.mu.Unlock() sm.mu.Unlock()
conn, err := dbus.ConnectSystemBus() conn, err := dbus.ConnectSystemBus()
@@ -254,8 +252,6 @@ func (sm *DBusSubscriptionManager) parseDBusSignal(sig *dbus.Signal) Subscriptio
} }
func (sm *DBusSubscriptionManager) Events() <-chan SubscriptionEvent { func (sm *DBusSubscriptionManager) Events() <-chan SubscriptionEvent {
sm.mu.Lock()
defer sm.mu.Unlock()
return sm.eventChan return sm.eventChan
} }
@@ -282,12 +278,6 @@ func (sm *DBusSubscriptionManager) Stop() {
} }
sm.stopChan = make(chan struct{}) sm.stopChan = make(chan struct{})
// the writer (dbusListenerLoop) joined above, so closing is safe; see
// SubscriptionManager.Stop()
sm.mu.Lock()
close(sm.eventChan)
sm.mu.Unlock()
} }
func (sm *DBusSubscriptionManager) cancelSubscription() { func (sm *DBusSubscriptionManager) cancelSubscription() {
-4
View File
@@ -201,10 +201,6 @@ func handleListNames(conn net.Conn, req models.Request, m *Manager) {
} }
func handleSubscribe(conn net.Conn, req models.Request, m *Manager, clientID string) { func handleSubscribe(conn net.Conn, req models.Request, m *Manager, clientID string) {
if id := params.StringOpt(req.Params, "clientId", ""); id != "" {
clientID = id
}
bus, err := params.String(req.Params, "bus") bus, err := params.String(req.Params, "bus")
if err != nil { if err != nil {
models.RespondError(conn, req.ID, err.Error()) models.RespondError(conn, req.ID, err.Error())
+4 -19
View File
@@ -137,25 +137,22 @@ func (m *Manager) consumeSelfEcho(value uint32) bool {
} }
func (m *Manager) watchSettingsChanges() { func (m *Manager) watchSettingsChanges() {
// reuse the shared session connection; a dedicated one was unreachable conn, err := dbus.ConnectSessionBus()
// from Close() and leaked with this goroutine if err != nil {
if m.sessionConn == nil { log.Warnf("color-scheme watcher: session bus connect: %v", err)
return return
} }
conn := m.sessionConn
if err := conn.AddMatchSignal( if err := conn.AddMatchSignal(
dbus.WithMatchInterface(dbusPortalSettingsInterface), dbus.WithMatchInterface(dbusPortalSettingsInterface),
dbus.WithMatchMember("SettingChanged"), dbus.WithMatchMember("SettingChanged"),
); err != nil { ); err != nil {
log.Warnf("Failed to watch portal settings changes: %v", err) log.Warnf("Failed to watch portal settings changes: %v", err)
conn.Close()
return return
} }
signals := make(chan *dbus.Signal, 64) signals := make(chan *dbus.Signal, 64)
m.stateMutex.Lock()
m.settingsSignals = signals
m.stateMutex.Unlock()
conn.Signal(signals) conn.Signal(signals)
for sig := range signals { for sig := range signals {
@@ -312,18 +309,6 @@ func (m *Manager) Close() {
m.systemConn.Close() m.systemConn.Close()
} }
if m.sessionConn != nil { if m.sessionConn != nil {
m.sessionConn.RemoveMatchSignal(
dbus.WithMatchInterface(dbusPortalSettingsInterface),
dbus.WithMatchMember("SettingChanged"),
)
m.stateMutex.Lock()
signals := m.settingsSignals
m.settingsSignals = nil
m.stateMutex.Unlock()
if signals != nil {
m.sessionConn.RemoveSignal(signals)
close(signals)
}
m.sessionConn.Close() m.sessionConn.Close()
} }
} }
@@ -71,6 +71,4 @@ type Manager struct {
screensaverGnomeClaimed bool screensaverGnomeClaimed bool
selfEchoMu sync.Mutex selfEchoMu sync.Mutex
selfEchoes []colorSchemeEcho selfEchoes []colorSchemeEcho
// registered on sessionConn by watchSettingsChanges; guarded by stateMutex
settingsSignals chan *dbus.Signal
} }
+11 -24
View File
@@ -22,14 +22,13 @@ type ConnectionStateChecker interface {
} }
type IWDAgent struct { type IWDAgent struct {
conn *dbus.Conn conn *dbus.Conn
objPath dbus.ObjectPath objPath dbus.ObjectPath
prompts PromptBroker prompts PromptBroker
onUserCanceled func() onUserCanceled func()
onPromptRetry func(ssid string) onPromptRetry func(ssid string)
takePendingSecret func(ssid string) (string, bool) lastRequestSSID string
lastRequestSSID string stateChecker ConnectionStateChecker
stateChecker ConnectionStateChecker
} }
const iwdAgentIntrospectXML = ` const iwdAgentIntrospectXML = `
@@ -120,13 +119,6 @@ func (a *IWDAgent) RequestPassphrase(network dbus.ObjectPath) (string, *dbus.Err
return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
} }
if a.takePendingSecret != nil {
if psk, ok := a.takePendingSecret(ssid); ok {
a.lastRequestSSID = ssid
return psk, nil
}
}
if a.prompts == nil { if a.prompts == nil {
if a.onUserCanceled != nil { if a.onUserCanceled != nil {
a.onUserCanceled() a.onUserCanceled()
@@ -134,24 +126,19 @@ func (a *IWDAgent) RequestPassphrase(network dbus.ObjectPath) (string, *dbus.Err
return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil) return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
} }
retry := a.lastRequestSSID == ssid if a.lastRequestSSID == ssid {
if retry && a.onPromptRetry != nil { if a.onPromptRetry != nil {
a.onPromptRetry(ssid) a.onPromptRetry(ssid)
}
} }
a.lastRequestSSID = ssid a.lastRequestSSID = ssid
reason := ""
if retry {
reason = "wrong-password"
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel() defer cancel()
token, err := a.prompts.Ask(ctx, PromptRequest{ token, err := a.prompts.Ask(ctx, PromptRequest{
SSID: ssid, SSID: ssid,
Fields: []string{"psk"}, Fields: []string{"psk"},
Reason: reason,
}) })
if err != nil { if err != nil {
if a.onUserCanceled != nil { if a.onUserCanceled != nil {
@@ -24,7 +24,6 @@ const (
type connectAttempt struct { type connectAttempt struct {
ssid string ssid string
netPath dbus.ObjectPath netPath dbus.ObjectPath
saved bool
start time.Time start time.Time
deadline time.Time deadline time.Time
sawAuthish bool sawAuthish bool
@@ -54,37 +53,6 @@ type IWDBackend struct {
attemptMutex sync.RWMutex attemptMutex sync.RWMutex
recentScans map[string]time.Time recentScans map[string]time.Time
recentScansMu sync.Mutex recentScansMu sync.Mutex
pendingPSK *pendingReplacementPSK
pendingPSKMu sync.Mutex
}
type pendingReplacementPSK struct {
ssid string
psk string
expires time.Time
}
func (b *IWDBackend) storePendingPSK(ssid, psk string) {
b.pendingPSKMu.Lock()
b.pendingPSK = &pendingReplacementPSK{
ssid: ssid,
psk: psk,
expires: time.Now().Add(30 * time.Second),
}
b.pendingPSKMu.Unlock()
}
func (b *IWDBackend) takePendingPSK(ssid string) (string, bool) {
b.pendingPSKMu.Lock()
defer b.pendingPSKMu.Unlock()
pending := b.pendingPSK
if pending == nil || pending.ssid != ssid || time.Now().After(pending.expires) {
return "", false
}
b.pendingPSK = nil
return pending.psk, true
} }
func NewIWDBackend() (*IWDBackend, error) { func NewIWDBackend() (*IWDBackend, error) {
@@ -18,7 +18,6 @@ func (b *IWDBackend) StartMonitoring(onStateChange func()) error {
} }
agent.onUserCanceled = b.OnUserCanceledPrompt agent.onUserCanceled = b.OnUserCanceledPrompt
agent.onPromptRetry = b.OnPromptRetry agent.onPromptRetry = b.OnPromptRetry
agent.takePendingSecret = b.takePendingPSK
b.iwdAgent = agent b.iwdAgent = agent
} }
@@ -1,7 +1,6 @@
package network package network
import ( import (
"context"
"testing" "testing"
"time" "time"
@@ -315,133 +314,6 @@ func TestConnectAttempt_Finalization(t *testing.T) {
backend.stateMutex.RUnlock() backend.stateMutex.RUnlock()
} }
func TestIWDBackend_PendingPSK(t *testing.T) {
backend, _ := NewIWDBackend()
_, ok := backend.takePendingPSK("Home")
assert.False(t, ok)
backend.storePendingPSK("Home", "newpass")
_, ok = backend.takePendingPSK("Other")
assert.False(t, ok, "pending PSK should not match a different SSID")
psk, ok := backend.takePendingPSK("Home")
assert.True(t, ok)
assert.Equal(t, "newpass", psk)
_, ok = backend.takePendingPSK("Home")
assert.False(t, ok, "pending PSK should be consumed on take")
backend.storePendingPSK("Home", "newpass")
backend.pendingPSKMu.Lock()
backend.pendingPSK.expires = time.Now().Add(-time.Second)
backend.pendingPSKMu.Unlock()
_, ok = backend.takePendingPSK("Home")
assert.False(t, ok, "expired pending PSK should not be returned")
}
type fakePromptBroker struct {
asked chan PromptRequest
reply PromptReply
replyErr error
}
func (f *fakePromptBroker) Ask(ctx context.Context, req PromptRequest) (string, error) {
f.asked <- req
return "token", nil
}
func (f *fakePromptBroker) Wait(ctx context.Context, token string) (PromptReply, error) {
return f.reply, f.replyErr
}
func (f *fakePromptBroker) Resolve(token string, reply PromptReply) error { return nil }
func (f *fakePromptBroker) Cancel(path string, setting string) error { return nil }
func TestIWDBackend_BadCredentialsSavedNetwork_PromptsReplacement(t *testing.T) {
backend, _ := NewIWDBackend()
backend.state = &BackendState{}
broker := &fakePromptBroker{
asked: make(chan PromptRequest, 1),
reply: PromptReply{Cancel: true},
}
backend.promptBroker = broker
att := &connectAttempt{
ssid: "Home",
netPath: "/test",
saved: true,
start: time.Now(),
deadline: time.Now().Add(15 * time.Second),
}
backend.finalizeAttempt(att, "bad-credentials")
select {
case req := <-broker.asked:
assert.Equal(t, "Home", req.SSID)
assert.Equal(t, "wrong-password", req.Reason)
assert.Equal(t, []string{"psk"}, req.Fields)
case <-time.After(time.Second):
t.Fatal("expected replacement credentials prompt for saved network")
}
}
func TestIWDBackend_BadCredentialsUnsavedNetwork_NoReplacementPrompt(t *testing.T) {
backend, _ := NewIWDBackend()
backend.state = &BackendState{}
broker := &fakePromptBroker{
asked: make(chan PromptRequest, 1),
reply: PromptReply{Cancel: true},
}
backend.promptBroker = broker
att := &connectAttempt{
ssid: "Home",
netPath: "/test",
start: time.Now(),
deadline: time.Now().Add(15 * time.Second),
}
backend.finalizeAttempt(att, "bad-credentials")
select {
case <-broker.asked:
t.Fatal("unsaved network should not trigger a replacement prompt")
case <-time.After(100 * time.Millisecond):
}
}
func TestIWDBackend_BadCredentialsAfterPromptRetry_NoReplacementPrompt(t *testing.T) {
backend, _ := NewIWDBackend()
backend.state = &BackendState{}
broker := &fakePromptBroker{
asked: make(chan PromptRequest, 1),
reply: PromptReply{Cancel: true},
}
backend.promptBroker = broker
att := &connectAttempt{
ssid: "Home",
netPath: "/test",
saved: true,
sawPromptRetry: true,
start: time.Now(),
deadline: time.Now().Add(15 * time.Second),
}
backend.finalizeAttempt(att, "bad-credentials")
select {
case <-broker.asked:
t.Fatal("attempt that already prompted should not trigger a replacement prompt")
case <-time.After(100 * time.Millisecond):
}
}
func TestConnectAttempt_DoubleFinalization(t *testing.T) { func TestConnectAttempt_DoubleFinalization(t *testing.T) {
backend, _ := NewIWDBackend() backend, _ := NewIWDBackend()
backend.state = &BackendState{} backend.state = &BackendState{}
@@ -1,12 +1,10 @@
package network package network
import ( import (
"context"
"fmt" "fmt"
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
) )
@@ -478,73 +476,6 @@ func (b *IWDBackend) finalizeAttempt(att *connectAttempt, code string) {
if b.onStateChange != nil { if b.onStateChange != nil {
b.onStateChange() b.onStateChange()
} }
if code == errdefs.ErrBadCredentials {
b.maybeReplaceSavedPSK(att)
}
}
func (b *IWDBackend) maybeReplaceSavedPSK(att *connectAttempt) {
if b.promptBroker == nil || !att.saved {
return
}
att.mu.Lock()
prompted := att.sawPromptRetry
att.mu.Unlock()
if prompted {
return
}
b.sigWG.Add(1)
go func() {
defer b.sigWG.Done()
b.requestReplacementPSK(att.ssid)
}()
}
func (b *IWDBackend) requestReplacementPSK(ssid string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
go func() {
select {
case <-b.stopChan:
cancel()
case <-ctx.Done():
}
}()
token, err := b.promptBroker.Ask(ctx, PromptRequest{
SSID: ssid,
SettingName: "802-11-wireless-security",
Fields: []string{"psk"},
Reason: "wrong-password",
})
if err != nil {
log.Warnf("failed to request replacement credentials for %s: %v", ssid, err)
return
}
reply, err := b.promptBroker.Wait(ctx, token)
if err != nil || reply.Cancel {
return
}
psk, ok := reply.Secrets["psk"]
if !ok || psk == "" {
return
}
if err := b.ForgetWiFiNetwork(ssid); err != nil {
log.Warnf("failed to forget %s before credential replacement: %v", ssid, err)
}
b.storePendingPSK(ssid, psk)
if err := b.ConnectWiFi(ConnectionRequest{SSID: ssid}); err != nil {
log.Warnf("failed to reconnect %s with replacement credentials: %v", ssid, err)
}
} }
func (b *IWDBackend) startAttemptWatchdog(att *connectAttempt) { func (b *IWDBackend) startAttemptWatchdog(att *connectAttempt) {
@@ -629,7 +560,7 @@ func (b *IWDBackend) ConnectWiFi(req ConnectionRequest) error {
return fmt.Errorf("no WiFi device available") return fmt.Errorf("no WiFi device available")
} }
networkPath, saved, err := b.findNetworkPath(req.SSID) networkPath, err := b.findNetworkPath(req.SSID)
if err != nil { if err != nil {
b.setConnectError(errdefs.ErrNoSuchSSID) b.setConnectError(errdefs.ErrNoSuchSSID)
if b.onStateChange != nil { if b.onStateChange != nil {
@@ -641,7 +572,6 @@ func (b *IWDBackend) ConnectWiFi(req ConnectionRequest) error {
att := &connectAttempt{ att := &connectAttempt{
ssid: req.SSID, ssid: req.SSID,
netPath: networkPath, netPath: networkPath,
saved: saved,
start: time.Now(), start: time.Now(),
deadline: time.Now().Add(15 * time.Second), deadline: time.Now().Add(15 * time.Second),
} }
@@ -689,39 +619,26 @@ func (b *IWDBackend) ConnectWiFi(req ConnectionRequest) error {
return nil return nil
} }
func (b *IWDBackend) findNetworkPath(ssid string) (dbus.ObjectPath, bool, error) { func (b *IWDBackend) findNetworkPath(ssid string) (dbus.ObjectPath, error) {
obj := b.conn.Object(iwdBusName, iwdObjectPath) obj := b.conn.Object(iwdBusName, iwdObjectPath)
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects) err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects)
if err != nil { if err != nil {
return "", false, err return "", err
} }
var netPath dbus.ObjectPath
saved := false
for path, interfaces := range objects { for path, interfaces := range objects {
if netProps, ok := interfaces[iwdNetworkInterface]; ok { if netProps, ok := interfaces[iwdNetworkInterface]; ok {
if nameVar, ok := netProps["Name"]; ok { if nameVar, ok := netProps["Name"]; ok {
if name, ok := nameVar.Value().(string); ok && name == ssid { if name, ok := nameVar.Value().(string); ok && name == ssid {
netPath = path return path, nil
}
}
}
if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok {
if nameVar, ok := knownProps["Name"]; ok {
if name, ok := nameVar.Value().(string); ok && name == ssid {
saved = true
} }
} }
} }
} }
if netPath == "" { return "", fmt.Errorf("network not found")
return "", false, fmt.Errorf("network not found")
}
return netPath, saved, nil
} }
func (b *IWDBackend) DisconnectWiFi() error { func (b *IWDBackend) DisconnectWiFi() error {
@@ -2,7 +2,6 @@ package network
import ( import (
"fmt" "fmt"
"maps"
"sync" "sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
@@ -58,11 +57,6 @@ type NetworkManagerBackend struct {
wifiDev any wifiDev any
wifiDevices map[string]*wifiDeviceInfo wifiDevices map[string]*wifiDeviceInfo
// devMutex guards ethernetDevices/wifiDevices (written by the signal pump,
// read by request handlers). Not reentrant — never hold it across calls
// into other backend methods.
devMutex sync.RWMutex
dbusConn *dbus.Conn dbusConn *dbus.Conn
signals chan *dbus.Signal signals chan *dbus.Signal
sigWG sync.WaitGroup sigWG sync.WaitGroup
@@ -191,12 +185,12 @@ func (b *NetworkManagerBackend) Initialize() error {
} }
hwAddr, _ := w.GetPropertyHwAddress() hwAddr, _ := w.GetPropertyHwAddress()
b.setEthernetDeviceInfo(iface, &ethernetDeviceInfo{ b.ethernetDevices[iface] = &ethernetDeviceInfo{
device: dev, device: dev,
wired: w, wired: w,
name: iface, name: iface,
hwAddress: hwAddr, hwAddress: hwAddr,
}) }
if b.ethernetDevice == nil { if b.ethernetDevice == nil {
b.ethernetDevice = dev b.ethernetDevice = dev
@@ -220,12 +214,12 @@ func (b *NetworkManagerBackend) Initialize() error {
} }
hwAddr, _ := w.GetPropertyHwAddress() hwAddr, _ := w.GetPropertyHwAddress()
b.setWifiDeviceInfo(iface, &wifiDeviceInfo{ b.wifiDevices[iface] = &wifiDeviceInfo{
device: dev, device: dev,
wireless: w, wireless: w,
name: iface, name: iface,
hwAddress: hwAddr, hwAddress: hwAddr,
}) }
if b.wifiDevice == nil { if b.wifiDevice == nil {
b.wifiDevice = dev b.wifiDevice = dev
@@ -273,80 +267,6 @@ func (b *NetworkManagerBackend) Initialize() error {
return nil return nil
} }
func (b *NetworkManagerBackend) ethernetDevicesSnapshot() map[string]*ethernetDeviceInfo {
b.devMutex.RLock()
defer b.devMutex.RUnlock()
out := make(map[string]*ethernetDeviceInfo, len(b.ethernetDevices))
maps.Copy(out, b.ethernetDevices)
return out
}
func (b *NetworkManagerBackend) wifiDevicesSnapshot() map[string]*wifiDeviceInfo {
b.devMutex.RLock()
defer b.devMutex.RUnlock()
out := make(map[string]*wifiDeviceInfo, len(b.wifiDevices))
maps.Copy(out, b.wifiDevices)
return out
}
func (b *NetworkManagerBackend) ethernetDeviceByIface(iface string) (*ethernetDeviceInfo, bool) {
b.devMutex.RLock()
defer b.devMutex.RUnlock()
info, ok := b.ethernetDevices[iface]
return info, ok
}
func (b *NetworkManagerBackend) wifiDeviceByIface(iface string) (*wifiDeviceInfo, bool) {
b.devMutex.RLock()
defer b.devMutex.RUnlock()
info, ok := b.wifiDevices[iface]
return info, ok
}
func (b *NetworkManagerBackend) setEthernetDeviceInfo(iface string, info *ethernetDeviceInfo) {
b.devMutex.Lock()
b.ethernetDevices[iface] = info
b.devMutex.Unlock()
}
func (b *NetworkManagerBackend) setWifiDeviceInfo(iface string, info *wifiDeviceInfo) {
b.devMutex.Lock()
b.wifiDevices[iface] = info
b.devMutex.Unlock()
}
// removeEthernetDeviceByPath deletes the device and returns a snapshot of
// what's left so the caller can pick a replacement without holding devMutex
func (b *NetworkManagerBackend) removeEthernetDeviceByPath(path dbus.ObjectPath) (removed *ethernetDeviceInfo, remaining map[string]*ethernetDeviceInfo, found bool) {
b.devMutex.Lock()
defer b.devMutex.Unlock()
for iface, info := range b.ethernetDevices {
if info.device.GetPath() != path {
continue
}
delete(b.ethernetDevices, iface)
remaining = make(map[string]*ethernetDeviceInfo, len(b.ethernetDevices))
maps.Copy(remaining, b.ethernetDevices)
return info, remaining, true
}
return nil, nil, false
}
func (b *NetworkManagerBackend) removeWifiDeviceByPath(path dbus.ObjectPath) (removed *wifiDeviceInfo, remaining map[string]*wifiDeviceInfo, found bool) {
b.devMutex.Lock()
defer b.devMutex.Unlock()
for iface, info := range b.wifiDevices {
if info.device.GetPath() != path {
continue
}
delete(b.wifiDevices, iface)
remaining = make(map[string]*wifiDeviceInfo, len(b.wifiDevices))
maps.Copy(remaining, b.wifiDevices)
return info, remaining, true
}
return nil, nil, false
}
func (b *NetworkManagerBackend) Close() { func (b *NetworkManagerBackend) Close() {
close(b.stopChan) close(b.stopChan)
b.StopMonitoring() b.StopMonitoring()
@@ -323,7 +323,7 @@ func (b *NetworkManagerBackend) GetEthernetDevices() []EthernetDevice {
} }
func (b *NetworkManagerBackend) DisconnectEthernetDevice(device string) error { func (b *NetworkManagerBackend) DisconnectEthernetDevice(device string) error {
info, ok := b.ethernetDeviceByIface(device) info, ok := b.ethernetDevices[device]
if !ok { if !ok {
return fmt.Errorf("ethernet device %s not found", device) return fmt.Errorf("ethernet device %s not found", device)
} }
@@ -345,10 +345,9 @@ func (b *NetworkManagerBackend) DisconnectEthernetDevice(device string) error {
} }
func (b *NetworkManagerBackend) updateAllEthernetDevices() { func (b *NetworkManagerBackend) updateAllEthernetDevices() {
ethernetDevices := b.ethernetDevicesSnapshot() devices := make([]EthernetDevice, 0, len(b.ethernetDevices))
devices := make([]EthernetDevice, 0, len(ethernetDevices))
for name, info := range ethernetDevices { for name, info := range b.ethernetDevices {
state, _ := info.device.GetPropertyState() state, _ := info.device.GetPropertyState()
connected := state == gonetworkmanager.NmDeviceStateActivated connected := state == gonetworkmanager.NmDeviceStateActivated
driver, _ := info.device.GetPropertyDriver() driver, _ := info.device.GetPropertyDriver()
@@ -112,7 +112,7 @@ func (b *NetworkManagerBackend) startSignalPump() error {
return err return err
} }
for _, info := range b.wifiDevicesSnapshot() { for _, info := range b.wifiDevices {
if err := conn.AddMatchSignal( if err := conn.AddMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface), dbus.WithMatchInterface(dbusPropsInterface),
@@ -124,7 +124,7 @@ func (b *NetworkManagerBackend) startSignalPump() error {
} }
} }
for _, info := range b.ethernetDevicesSnapshot() { for _, info := range b.ethernetDevices {
if err := conn.AddMatchSignal( if err := conn.AddMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface), dbus.WithMatchInterface(dbusPropsInterface),
@@ -227,7 +227,7 @@ func (b *NetworkManagerBackend) stopSignalPump() {
dbus.WithMatchMember("StateChanged"), dbus.WithMatchMember("StateChanged"),
) )
for _, info := range b.wifiDevicesSnapshot() { for _, info := range b.wifiDevices {
b.dbusConn.RemoveMatchSignal( b.dbusConn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface), dbus.WithMatchInterface(dbusPropsInterface),
@@ -235,7 +235,7 @@ func (b *NetworkManagerBackend) stopSignalPump() {
) )
} }
for _, info := range b.ethernetDevicesSnapshot() { for _, info := range b.ethernetDevices {
b.dbusConn.RemoveMatchSignal( b.dbusConn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface), dbus.WithMatchInterface(dbusPropsInterface),
@@ -550,12 +550,12 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
} }
hwAddr, _ := w.GetPropertyHwAddress() hwAddr, _ := w.GetPropertyHwAddress()
b.setEthernetDeviceInfo(iface, &ethernetDeviceInfo{ b.ethernetDevices[iface] = &ethernetDeviceInfo{
device: dev, device: dev,
wired: w, wired: w,
name: iface, name: iface,
hwAddress: hwAddr, hwAddress: hwAddr,
}) }
if b.ethernetDevice == nil { if b.ethernetDevice == nil {
b.ethernetDevice = dev b.ethernetDevice = dev
@@ -573,12 +573,12 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
} }
hwAddr, _ := w.GetPropertyHwAddress() hwAddr, _ := w.GetPropertyHwAddress()
b.setWifiDeviceInfo(iface, &wifiDeviceInfo{ b.wifiDevices[iface] = &wifiDeviceInfo{
device: dev, device: dev,
wireless: w, wireless: w,
name: iface, name: iface,
hwAddress: hwAddr, hwAddress: hwAddr,
}) }
if b.wifiDevice == nil { if b.wifiDevice == nil {
b.wifiDevice = dev b.wifiDevice = dev
@@ -603,49 +603,57 @@ func (b *NetworkManagerBackend) handleDeviceRemoved(devicePath dbus.ObjectPath)
) )
} }
if _, remaining, found := b.removeEthernetDeviceByPath(devicePath); found { for iface, info := range b.ethernetDevices {
if b.ethernetDevice != nil { if info.device.GetPath() == devicePath {
dev := b.ethernetDevice.(gonetworkmanager.Device) delete(b.ethernetDevices, iface)
if dev.GetPath() == devicePath {
b.ethernetDevice = nil if b.ethernetDevice != nil {
for _, r := range remaining { dev := b.ethernetDevice.(gonetworkmanager.Device)
b.ethernetDevice = r.device if dev.GetPath() == devicePath {
break b.ethernetDevice = nil
for _, remaining := range b.ethernetDevices {
b.ethernetDevice = remaining.device
break
}
} }
} }
}
b.updateAllEthernetDevices() b.updateAllEthernetDevices()
b.updateEthernetState() b.updateEthernetState()
b.listEthernetConnections() b.listEthernetConnections()
b.updatePrimaryConnection() b.updatePrimaryConnection()
if b.onStateChange != nil { if b.onStateChange != nil {
b.onStateChange() b.onStateChange()
}
return
} }
return
} }
if _, remaining, found := b.removeWifiDeviceByPath(devicePath); found { for iface, info := range b.wifiDevices {
if b.wifiDevice != nil { if info.device.GetPath() == devicePath {
dev := b.wifiDevice.(gonetworkmanager.Device) delete(b.wifiDevices, iface)
if dev.GetPath() == devicePath {
b.wifiDevice = nil if b.wifiDevice != nil {
b.wifiDev = nil dev := b.wifiDevice.(gonetworkmanager.Device)
for _, r := range remaining { if dev.GetPath() == devicePath {
b.wifiDevice = r.device b.wifiDevice = nil
b.wifiDev = r.wireless b.wifiDev = nil
break for _, remaining := range b.wifiDevices {
b.wifiDevice = remaining.device
b.wifiDev = remaining.wireless
break
}
} }
} }
}
b.updateAllWiFiDevices() b.updateAllWiFiDevices()
b.updateWiFiState() b.updateWiFiState()
if b.onStateChange != nil { if b.onStateChange != nil {
b.onStateChange() b.onStateChange()
}
return
} }
return
} }
} }
@@ -76,7 +76,7 @@ func (b *NetworkManagerBackend) updateEthernetState() error {
var connectedIP string var connectedIP string
var anyConnected bool var anyConnected bool
for name, info := range b.ethernetDevicesSnapshot() { for name, info := range b.ethernetDevices {
state, err := info.device.GetPropertyState() state, err := info.device.GetPropertyState()
if err != nil { if err != nil {
continue continue
@@ -973,7 +973,7 @@ func (b *NetworkManagerBackend) SetWiFiAutoconnect(ssid string, autoconnect bool
} }
func (b *NetworkManagerBackend) ScanWiFiDevice(device string) error { func (b *NetworkManagerBackend) ScanWiFiDevice(device string) error {
devInfo, ok := b.wifiDeviceByIface(device) devInfo, ok := b.wifiDevices[device]
if !ok { if !ok {
return fmt.Errorf("WiFi device not found: %s", device) return fmt.Errorf("WiFi device not found: %s", device)
} }
@@ -995,7 +995,7 @@ func (b *NetworkManagerBackend) ScanWiFiDevice(device string) error {
} }
func (b *NetworkManagerBackend) DisconnectWiFiDevice(device string) error { func (b *NetworkManagerBackend) DisconnectWiFiDevice(device string) error {
devInfo, ok := b.wifiDeviceByIface(device) devInfo, ok := b.wifiDevices[device]
if !ok { if !ok {
return fmt.Errorf("WiFi device not found: %s", device) return fmt.Errorf("WiFi device not found: %s", device)
} }
@@ -1047,7 +1047,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
wifiConnected := b.state.WiFiConnected wifiConnected := b.state.WiFiConnected
b.stateMutex.RUnlock() b.stateMutex.RUnlock()
for name, devInfo := range b.wifiDevicesSnapshot() { for name, devInfo := range b.wifiDevices {
state, _ := devInfo.device.GetPropertyState() state, _ := devInfo.device.GetPropertyState()
connected := state == gonetworkmanager.NmDeviceStateActivated connected := state == gonetworkmanager.NmDeviceStateActivated
@@ -1211,7 +1211,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
func (b *NetworkManagerBackend) getWifiDeviceForConnection(deviceName string) (*wifiDeviceInfo, error) { func (b *NetworkManagerBackend) getWifiDeviceForConnection(deviceName string) (*wifiDeviceInfo, error) {
if deviceName != "" { if deviceName != "" {
devInfo, ok := b.wifiDeviceByIface(deviceName) devInfo, ok := b.wifiDevices[deviceName]
if !ok { if !ok {
return nil, fmt.Errorf("WiFi device not found: %s", deviceName) return nil, fmt.Errorf("WiFi device not found: %s", deviceName)
} }
@@ -1224,7 +1224,7 @@ func (b *NetworkManagerBackend) getWifiDeviceForConnection(deviceName string) (*
dev := b.wifiDevice.(gonetworkmanager.Device) dev := b.wifiDevice.(gonetworkmanager.Device)
iface, _ := dev.GetPropertyInterface() iface, _ := dev.GetPropertyInterface()
if devInfo, ok := b.wifiDeviceByIface(iface); ok { if devInfo, ok := b.wifiDevices[iface]; ok {
return devInfo, nil return devInfo, nil
} }
-18
View File
@@ -43,8 +43,6 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
handleGetNetworkInfo(conn, req, manager) handleGetNetworkInfo(conn, req, manager)
case "network.qrcode": case "network.qrcode":
handleGetNetworkQRCode(conn, req, manager) handleGetNetworkQRCode(conn, req, manager)
case "network.qrcode-content":
handleGetNetworkQRCodeContent(conn, req, manager)
case "network.delete-qrcode": case "network.delete-qrcode":
handleDeleteQRCode(conn, req, manager) handleDeleteQRCode(conn, req, manager)
case "network.ethernet.info": case "network.ethernet.info":
@@ -343,22 +341,6 @@ func handleGetNetworkQRCode(conn net.Conn, req models.Request, manager *Manager)
models.Respond(conn, req.ID, content) models.Respond(conn, req.ID, content)
} }
func handleGetNetworkQRCodeContent(conn net.Conn, req models.Request, manager *Manager) {
ssid, err := params.String(req.Params, "ssid")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
content, err := manager.GetWiFiQRContent(ssid)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, content)
}
func handleDeleteQRCode(conn net.Conn, req models.Request, _ *Manager) { func handleDeleteQRCode(conn net.Conn, req models.Request, _ *Manager) {
path, err := params.String(req.Params, "path") path, err := params.String(req.Params, "path")
if err != nil { if err != nil {
-4
View File
@@ -473,10 +473,6 @@ func (m *Manager) GetNetworkInfoDetailed(ssid string) (*NetworkInfoResponse, err
return m.backend.GetWiFiNetworkDetails(ssid) return m.backend.GetWiFiNetworkDetails(ssid)
} }
func (m *Manager) GetWiFiQRContent(ssid string) (string, error) {
return m.backend.GetWiFiQRCodeContent(ssid)
}
func (m *Manager) GetNetworkQRCode(ssid string) ([2]string, error) { func (m *Manager) GetNetworkQRCode(ssid string) ([2]string, error) {
content, err := m.backend.GetWiFiQRCodeContent(ssid) content, err := m.backend.GetWiFiQRCodeContent(ssid)
if err != nil { if err != nil {
+1 -3
View File
@@ -5,14 +5,12 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/qrcode"
) )
const qrCodeTmpPrefix = "/tmp/dank-wifi-qrcode-" const qrCodeTmpPrefix = "/tmp/dank-wifi-qrcode-"
func FormatWiFiQRString(securityType, ssid, password string) string { func FormatWiFiQRString(securityType, ssid, password string) string {
return qrcode.WiFiString(securityType, ssid, password, false) return fmt.Sprintf("WIFI:T:%s;S:%s;P:%s;;", securityType, ssid, password)
} }
func qrCodePaths(ssid string) (themed, normal string) { func qrCodePaths(ssid string) (themed, normal string) {
+4 -26
View File
@@ -8,7 +8,6 @@ import (
"net" "net"
"os" "os"
"path/filepath" "path/filepath"
"runtime/debug"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -42,7 +41,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
) )
const APIVersion = 28 const APIVersion = 27
var CLIVersion = "dev" var CLIVersion = "dev"
@@ -399,11 +398,6 @@ func InitializeSysUpdateManager() error {
func handleConnection(conn net.Conn) { func handleConnection(conn net.Conn) {
defer conn.Close() defer conn.Close()
defer func() {
if r := recover(); r != nil {
log.Errorf("handleConnection panic recovered: panic=%v\n%s", r, debug.Stack())
}
}()
caps := getCapabilities() caps := getCapabilities()
capsData, _ := json.Marshal(caps) capsData, _ := json.Marshal(caps)
@@ -421,21 +415,10 @@ func handleConnection(conn net.Conn) {
continue continue
} }
go routeRequestRecovered(conn, req) go RouteRequest(conn, req)
} }
} }
// routeRequestRecovered keeps a panicking handler from taking down the whole daemon
func routeRequestRecovered(conn net.Conn, req models.Request) {
defer func() {
if r := recover(); r != nil {
log.Errorf("RouteRequest panic recovered: method=%s panic=%v\n%s", req.Method, r, debug.Stack())
models.RespondError(conn, req.ID, "internal server error")
}
}()
RouteRequest(conn, req)
}
func getCapabilities() Capabilities { func getCapabilities() Capabilities {
caps := []string{"plugins"} caps := []string{"plugins"}
@@ -598,11 +581,6 @@ func notifyCapabilityChange() {
func handleSubscribe(conn net.Conn, req models.Request) { func handleSubscribe(conn net.Conn, req models.Request) {
clientID := fmt.Sprintf("meta-client-%p", conn) clientID := fmt.Sprintf("meta-client-%p", conn)
dbusClient := dbusClientID
if id, ok := models.Get[string](req, "clientId"); ok && id != "" {
dbusClient = id
}
var services []string var services []string
if servicesParam, ok := models.Get[[]any](req, "services"); ok { if servicesParam, ok := models.Get[[]any](req, "services"); ok {
for _, s := range servicesParam { for _, s := range servicesParam {
@@ -1271,10 +1249,10 @@ func handleSubscribe(conn net.Conn, req models.Request) {
if shouldSubscribe("dbus") && dbusManager != nil { if shouldSubscribe("dbus") && dbusManager != nil {
wg.Add(1) wg.Add(1)
dbusChan := dbusManager.SubscribeSignals(dbusClient) dbusChan := dbusManager.SubscribeSignals(dbusClientID)
go func() { go func() {
defer wg.Done() defer wg.Done()
defer dbusManager.UnsubscribeSignals(dbusClient) defer dbusManager.UnsubscribeSignals(dbusClientID)
for { for {
select { select {
+2 -47
View File
@@ -2,7 +2,6 @@ package sysupdate
import ( import (
"context" "context"
"fmt"
"os/exec" "os/exec"
"regexp" "regexp"
"strings" "strings"
@@ -32,36 +31,7 @@ func (aptBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return filterAptHeld(parseAptUpgradable(string(out)), aptHeldPackages(ctx)), nil return parseAptUpgradable(string(out)), nil
}
// aptHeldPackages returns held packages, which apt-get upgrade never applies.
func aptHeldPackages(ctx context.Context) map[string]bool {
out, err := exec.CommandContext(ctx, "apt-mark", "showhold").Output()
if err != nil {
return nil
}
held := make(map[string]bool)
for line := range strings.SplitSeq(string(out), "\n") {
if name := strings.TrimSpace(line); name != "" {
held[name] = true
}
}
return held
}
func filterAptHeld(pkgs []Package, held map[string]bool) []Package {
if len(held) == 0 {
return pkgs
}
out := pkgs[:0]
for _, p := range pkgs {
if held[p.Name] {
continue
}
out = append(out, p)
}
return out
} }
func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error { func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
@@ -82,22 +52,7 @@ func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(
} }
func aptUpgradeArgv(bin string, opts UpgradeOptions) []string { func aptUpgradeArgv(bin string, opts UpgradeOptions) []string {
ignored := shellSafeNames(opts.Ignored) return privilegedArgv(opts, "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "upgrade", "-y")
if len(ignored) == 0 {
return privilegedArgv(opts, "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "upgrade", "-y")
}
return privilegedArgv(opts, "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", "sh", "-c", aptHoldScript(bin, ignored))
}
// aptHoldScript holds ignored packages only for the upgrade, leaving pre-existing user holds untouched.
func aptHoldScript(bin string, ignored []string) string {
names := strings.Join(ignored, " ")
return fmt.Sprintf(
`new=""; for p in %s; do apt-mark showhold | grep -qx "$p" || new="$new $p"; done; `+
`[ -n "$new" ] && apt-mark hold $new; `+
`%s upgrade -y; rc=$?; `+
`[ -n "$new" ] && apt-mark unhold $new; exit $rc`,
names, bin)
} }
func parseAptUpgradable(text string) []Package { func parseAptUpgradable(text string) []Package {
@@ -70,22 +70,3 @@ libsdl2-2.0-0/stable 2.30.0+dfsg-1 amd64 [upgradable from: 2.28.5+dfsg-1]`,
}) })
} }
} }
func TestFilterAptHeld(t *testing.T) {
pkgs := []Package{
{Name: "bash", Repo: RepoSystem, Backend: "apt"},
{Name: "linux-image-generic", Repo: RepoSystem, Backend: "apt"},
{Name: "zsh", Repo: RepoSystem, Backend: "apt"},
}
got := filterAptHeld(append([]Package(nil), pkgs...), map[string]bool{"linux-image-generic": true})
want := []Package{pkgs[0], pkgs[2]}
if !reflect.DeepEqual(got, want) {
t.Errorf("filterAptHeld() = %#v\nwant %#v", got, want)
}
unfiltered := filterAptHeld(append([]Package(nil), pkgs...), nil)
if !reflect.DeepEqual(unfiltered, pkgs) {
t.Errorf("filterAptHeld(nil held) = %#v\nwant %#v", unfiltered, pkgs)
}
}
+1 -19
View File
@@ -3,7 +3,6 @@ package sysupdate
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"os/exec" "os/exec"
"strings" "strings"
) )
@@ -53,11 +52,7 @@ func (b dnfBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine fun
} }
func dnfUpgradeArgv(bin string, opts UpgradeOptions) []string { func dnfUpgradeArgv(bin string, opts UpgradeOptions) []string {
argv := []string{bin, "upgrade", "--refresh", "-y"} return privilegedArgv(opts, bin, "upgrade", "--refresh", "-y")
if len(opts.Ignored) > 0 {
argv = append(argv, "--exclude="+strings.Join(opts.Ignored, ","))
}
return privilegedArgv(opts, argv...)
} }
func dnfListUpgrades(ctx context.Context, bin string) (string, error) { func dnfListUpgrades(ctx context.Context, bin string) (string, error) {
@@ -70,22 +65,9 @@ func dnfListUpgrades(ctx context.Context, bin string) (string, error) {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 100 { if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 100 {
return string(out), nil return string(out), nil
} }
if detail := lastNonEmptyLine(string(out)); detail != "" {
return "", fmt.Errorf("%w: %s", err, detail)
}
return "", err return "", err
} }
func lastNonEmptyLine(text string) string {
lines := strings.Split(text, "\n")
for i := len(lines) - 1; i >= 0; i-- {
if line := strings.TrimSpace(lines[i]); line != "" {
return line
}
}
return ""
}
func dnfCheckUpdatesArgv(bin string) []string { func dnfCheckUpdatesArgv(bin string) []string {
subcommand := "check-update" subcommand := "check-update"
if bin == "dnf5" { if bin == "dnf5" {
@@ -95,21 +95,11 @@ func (flatpakBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine f
if !BackendHasTargets(flatpakBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) { if !BackendHasTargets(flatpakBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
return nil return nil
} }
return Run(ctx, flatpakUpgradeArgv(opts), RunOptions{OnLine: onLine}) return Run(ctx, flatpakUpgradeArgv(), RunOptions{OnLine: onLine})
} }
func flatpakUpgradeArgv(opts UpgradeOptions) []string { func flatpakUpgradeArgv() []string {
argv := []string{"flatpak", "update", "-y", "--noninteractive"} return []string{"flatpak", "update", "-y", "--noninteractive"}
if len(opts.Ignored) == 0 {
return argv
}
// No exclude flag; update the already-filtered refs explicitly.
for _, p := range opts.Targets {
if p.Repo == RepoFlatpak && p.Ref != "" {
argv = append(argv, p.Ref)
}
}
return argv
} }
func parseFlatpakUpdateOutput(text string, installed map[string]flatpakInstalledEntry) []Package { func parseFlatpakUpdateOutput(text string, installed map[string]flatpakInstalledEntry) []Package {
@@ -50,11 +50,7 @@ func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine
} }
func pacmanUpgradeArgv(opts UpgradeOptions) []string { func pacmanUpgradeArgv(opts UpgradeOptions) []string {
argv := []string{"pacman", "-Syu", "--noconfirm", "--needed"} return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed")
if len(opts.Ignored) > 0 {
argv = append(argv, "--ignore", strings.Join(opts.Ignored, ","))
}
return privilegedArgv(opts, argv...)
} }
type archHelperBackend struct { type archHelperBackend struct {
@@ -103,27 +99,23 @@ func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onL
return nil return nil
} }
if os.Getenv("DMS_FORCE_PKEXEC") == "1" { if os.Getenv("DMS_FORCE_PKEXEC") == "1" {
argv := append([]string{"pkexec"}, archHelperUpgradeArgv(b.id, opts.IncludeAUR, opts.Ignored)...) argv := append([]string{"pkexec"}, archHelperUpgradeArgv(b.id, opts.IncludeAUR)...)
return Run(ctx, argv, RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio}) return Run(ctx, argv, RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
} }
term := findTerminal(opts.Terminal) term := findTerminal(opts.Terminal)
if term == "" { if term == "" {
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)") return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
} }
cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR, opts.Ignored), " ") cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR), " ")
title := fmt.Sprintf("DMS — System Update (%s)", b.id) title := fmt.Sprintf("DMS — System Update (%s)", b.id)
return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine}) return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine})
} }
func archHelperUpgradeArgv(id string, includeAUR bool, ignored []string) []string { func archHelperUpgradeArgv(id string, includeAUR bool) []string {
argv := []string{id, "-Syu", "--noconfirm", "--needed"} argv := []string{id, "-Syu", "--noconfirm", "--needed"}
if !includeAUR { if !includeAUR {
argv = append(argv, "--repo") argv = append(argv, "--repo")
} }
ignored = shellSafeNames(ignored)
if len(ignored) > 0 {
argv = append(argv, "--ignore", strings.Join(ignored, ","))
}
return argv return argv
} }
@@ -256,10 +248,6 @@ func parseArchUpdates(text, backendID string, repo RepoKind) []Package {
if line == "" { if line == "" {
continue continue
} }
// pacman -Qu / paru -Qua flag IgnorePkg entries with a trailing marker
if strings.HasSuffix(line, "[ignored]") {
continue
}
m := archUpdateLine.FindStringSubmatch(line) m := archUpdateLine.FindStringSubmatch(line)
if m == nil { if m == nil {
continue continue
@@ -92,17 +92,6 @@ foo`,
{Name: "bat", Repo: RepoSystem, Backend: "pacman", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"}, {Name: "bat", Repo: RepoSystem, Backend: "pacman", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
}, },
}, },
{
name: "skips IgnorePkg entries",
input: `bat 0.26.0-1 -> 0.26.1-2
linux 6.18.0-1 -> 6.18.1-1 [ignored]
discord 0.0.108-1 -> 0.0.109-1 [ignored]`,
backendID: "pacman",
repo: RepoSystem,
want: []Package{
{Name: "bat", Repo: RepoSystem, Backend: "pacman", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
},
},
{ {
name: "extra whitespace tolerated", name: "extra whitespace tolerated",
input: " bat 0.26.0-1 -> 0.26.1-2 ", input: " bat 0.26.0-1 -> 0.26.1-2 ",
@@ -4,9 +4,7 @@ import (
"context" "context"
"encoding/xml" "encoding/xml"
"errors" "errors"
"fmt"
"os/exec" "os/exec"
"strings"
) )
func init() { func init() {
@@ -83,20 +81,5 @@ func (zypperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine fu
} }
func zypperUpgradeArgv(opts UpgradeOptions) []string { func zypperUpgradeArgv(opts UpgradeOptions) []string {
ignored := shellSafeNames(opts.Ignored) return privilegedArgv(opts, "zypper", "--non-interactive", "update")
if len(ignored) == 0 {
return privilegedArgv(opts, "zypper", "--non-interactive", "update")
}
return privilegedArgv(opts, "sh", "-c", zypperLockScript(ignored))
}
// zypperLockScript locks ignored packages only for the update, leaving pre-existing user locks untouched.
func zypperLockScript(ignored []string) string {
names := strings.Join(ignored, " ")
return fmt.Sprintf(
`new=""; for p in %s; do grep -qsE "^solvable_name:[[:space:]]*$p$" /etc/zypp/locks || new="$new $p"; done; `+
`[ -n "$new" ] && zypper --non-interactive al $new; `+
`zypper --non-interactive update; rc=$?; `+
`[ -n "$new" ] && zypper --non-interactive rl $new; exit $rc`,
names)
} }
@@ -46,7 +46,6 @@ func handleUpgrade(conn net.Conn, req models.Request, m *Manager) {
DryRun: params.BoolOpt(req.Params, "dry", false), DryRun: params.BoolOpt(req.Params, "dry", false),
CustomCommand: params.StringOpt(req.Params, "customCommand", ""), CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
Terminal: params.StringOpt(req.Params, "terminal", ""), Terminal: params.StringOpt(req.Params, "terminal", ""),
Ignored: stringSliceOpt(req.Params, "ignored"),
} }
if err := m.Upgrade(opts); err != nil { if err := m.Upgrade(opts); err != nil {
models.RespondError(conn, req.ID, err.Error()) models.RespondError(conn, req.ID, err.Error())
@@ -54,21 +53,3 @@ func handleUpgrade(conn net.Conn, req models.Request, m *Manager) {
} }
models.Respond(conn, req.ID, m.GetState()) models.Respond(conn, req.ID, m.GetState())
} }
func stringSliceOpt(p map[string]any, key string) []string {
val, ok := params.Any(p, key)
if !ok {
return nil
}
arr, ok := val.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, v := range arr {
if s, ok := v.(string); ok && s != "" {
out = append(out, s)
}
}
return out
}
+10 -50
View File
@@ -20,7 +20,6 @@ const (
minIntervalSeconds = 5 * 60 minIntervalSeconds = 5 * 60
recentLogCapacity = 200 recentLogCapacity = 200
checkTimeout = 5 * time.Minute checkTimeout = 5 * time.Minute
retryIntervalSeconds = 5 * 60
upgradeTimeout = 30 * time.Minute upgradeTimeout = 30 * time.Minute
postUpgradeCompleteDelay = 3 * time.Second postUpgradeCompleteDelay = 3 * time.Second
) )
@@ -151,7 +150,7 @@ func (m *Manager) Refresh(opts RefreshOptions) {
m.refreshSerial.Unlock() m.refreshSerial.Unlock()
return return
} }
m.runRefresh(context.Background(), true) m.runRefresh(context.Background())
} }
func (m *Manager) Upgrade(opts UpgradeOptions) error { func (m *Manager) Upgrade(opts UpgradeOptions) error {
@@ -238,12 +237,12 @@ func (m *Manager) scheduler() {
case <-m.wakeSched: case <-m.wakeSched:
t.Stop() t.Stop()
case <-t.C: case <-t.C:
m.runRefresh(context.Background(), false) m.runRefresh(context.Background())
} }
} }
} }
func (m *Manager) runRefresh(parent context.Context, manual bool) { func (m *Manager) runRefresh(parent context.Context) {
m.refreshSerial.Lock() m.refreshSerial.Lock()
defer m.refreshSerial.Unlock() defer m.refreshSerial.Unlock()
@@ -285,43 +284,27 @@ func (m *Manager) runRefresh(parent context.Context, manual bool) {
now := time.Now().Unix() now := time.Now().Unix()
m.mu.Lock() m.mu.Lock()
m.state.LastCheckUnix = now m.state.LastCheckUnix = now
prev := m.state.Packages m.state.Packages = m.state.Packages[:0]
next := make([]Package, 0, len(prev))
var firstErr error var firstErr error
for i, r := range results { for i, r := range results {
if r.err != nil { if r.err != nil {
if firstErr == nil { if firstErr == nil {
firstErr = fmt.Errorf("%s: %w", backends[i].ID(), r.err) firstErr = fmt.Errorf("%s: %w", backends[i].ID(), r.err)
} }
// Retain a failed backend's last known packages so a transient failure doesn't wipe the list.
for _, p := range prev {
if p.Backend == backends[i].ID() {
next = append(next, p)
}
}
continue continue
} }
next = append(next, r.pkgs...) m.state.Packages = append(m.state.Packages, r.pkgs...)
} }
m.state.Packages = next m.state.Count = len(m.state.Packages)
m.state.Count = len(next)
m.state.NextCheckUnix = now + int64(m.state.IntervalSeconds) m.state.NextCheckUnix = now + int64(m.state.IntervalSeconds)
switch { if firstErr != nil {
case firstErr == nil:
m.state.Phase = PhaseIdle
m.state.LastSuccessUnix = now
case manual:
m.state.Phase = PhaseError m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: ErrCodeBackendFailed, Message: firstErr.Error()} m.state.Error = &ErrorInfo{Code: ErrCodeBackendFailed, Message: firstErr.Error()}
default: } else {
// Background checks fail silently and retry sooner; only manual refreshes surface errors.
m.state.Phase = PhaseIdle m.state.Phase = PhaseIdle
retry := min(int64(m.state.IntervalSeconds), retryIntervalSeconds) m.state.LastSuccessUnix = now
m.state.NextCheckUnix = now + retry
log.Warnf("[sysupdate] background check failed, retrying in %ds: %v", retry, firstErr)
} }
m.mu.Unlock() m.mu.Unlock()
m.wake()
m.markDirty() m.markDirty()
} }
@@ -345,15 +328,10 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
opts.Targets = append([]Package(nil), m.state.Packages...) opts.Targets = append([]Package(nil), m.state.Packages...)
m.mu.RUnlock() m.mu.RUnlock()
} }
opts.Targets = dropIgnoredTargets(opts.Targets, opts.Ignored)
backends := upgradeBackends(m.selection, opts) backends := upgradeBackends(m.selection, opts)
if len(backends) == 0 { if len(backends) == 0 {
if len(opts.Targets) > 0 { m.setError(ErrCodeNoBackend, "no backend selected for upgrade")
m.setError(ErrCodeNoBackend, "all pending updates are excluded by current settings (AUR/Flatpak disabled)")
} else {
m.setError(ErrCodeNoBackend, "no backend selected for upgrade")
}
return return
} }
@@ -451,24 +429,6 @@ func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) {
m.markDirty() m.markDirty()
} }
func dropIgnoredTargets(targets []Package, ignored []string) []Package {
if len(ignored) == 0 {
return targets
}
skip := make(map[string]bool, len(ignored))
for _, name := range ignored {
skip[name] = true
}
out := targets[:0]
for _, p := range targets {
if skip[p.Name] {
continue
}
out = append(out, p)
}
return out
}
func upgradeBackends(sel Selection, opts UpgradeOptions) []Backend { func upgradeBackends(sel Selection, opts UpgradeOptions) []Backend {
var out []Backend var out []Backend
if sel.System != nil { if sel.System != nil {
-15
View File
@@ -1,20 +1,5 @@
package sysupdate package sysupdate
import "regexp"
var safePkgName = regexp.MustCompile(`^[A-Za-z0-9@._+:-]+$`)
// shellSafeNames drops names unsafe to interpolate into the apt/zypper sh -c scripts.
func shellSafeNames(names []string) []string {
out := make([]string, 0, len(names))
for _, n := range names {
if safePkgName.MatchString(n) {
out = append(out, n)
}
}
return out
}
func BackendHasTargets(b Backend, targets []Package, includeAUR, includeFlatpak bool) bool { func BackendHasTargets(b Backend, targets []Package, includeAUR, includeFlatpak bool) bool {
if b == nil || len(targets) == 0 { if b == nil || len(targets) == 0 {
return false return false
-1
View File
@@ -81,7 +81,6 @@ type UpgradeOptions struct {
CustomCommand string CustomCommand string
Terminal string Terminal string
Targets []Package Targets []Package
Ignored []string
} }
type RefreshOptions struct { type RefreshOptions struct {
@@ -2,7 +2,6 @@ package sysupdate
import ( import (
"reflect" "reflect"
"strings"
"testing" "testing"
) )
@@ -36,55 +35,19 @@ func TestUpgradeCommandBuilders(t *testing.T) {
}, },
{ {
name: "aur helper full update with aur", name: "aur helper full update with aur",
got: archHelperUpgradeArgv("paru", true, nil), got: archHelperUpgradeArgv("paru", true),
want: []string{"paru", "-Syu", "--noconfirm", "--needed"}, want: []string{"paru", "-Syu", "--noconfirm", "--needed"},
}, },
{ {
name: "aur helper repo-only full update", name: "aur helper repo-only full update",
got: archHelperUpgradeArgv("yay", false, nil), got: archHelperUpgradeArgv("yay", false),
want: []string{"yay", "-Syu", "--noconfirm", "--needed", "--repo"}, want: []string{"yay", "-Syu", "--noconfirm", "--needed", "--repo"},
}, },
{
name: "aur helper with ignored packages",
got: archHelperUpgradeArgv("paru", true, []string{"linux", "bad;name", "discord"}),
want: []string{"paru", "-Syu", "--noconfirm", "--needed", "--ignore", "linux,discord"},
},
{
name: "pacman with ignored packages",
got: pacmanUpgradeArgv(UpgradeOptions{Ignored: []string{"linux"}}),
want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed", "--ignore", "linux"},
},
{
name: "dnf with ignored packages",
got: dnfUpgradeArgv("dnf5", UpgradeOptions{Ignored: []string{"kernel", "mesa"}}),
want: []string{"pkexec", "dnf5", "upgrade", "--refresh", "-y", "--exclude=kernel,mesa"},
},
{
name: "apt without ignored uses plain upgrade",
got: aptUpgradeArgv("apt-get", UpgradeOptions{}),
want: []string{"pkexec", "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", "apt-get", "upgrade", "-y"},
},
{
name: "zypper without ignored uses plain update",
got: zypperUpgradeArgv(UpgradeOptions{}),
want: []string{"pkexec", "zypper", "--non-interactive", "update"},
},
{ {
name: "flatpak full update", name: "flatpak full update",
got: flatpakUpgradeArgv(UpgradeOptions{}), got: flatpakUpgradeArgv(),
want: []string{"flatpak", "update", "-y", "--noninteractive"}, want: []string{"flatpak", "update", "-y", "--noninteractive"},
}, },
{
name: "flatpak update with ignored targets refs",
got: flatpakUpgradeArgv(UpgradeOptions{
Ignored: []string{"org.mozilla.firefox"},
Targets: []Package{
{Name: "Discord", Repo: RepoFlatpak, Ref: "com.discordapp.Discord//stable"},
{Name: "bash", Repo: RepoSystem, Backend: "apt"},
},
}),
want: []string{"flatpak", "update", "-y", "--noninteractive", "com.discordapp.Discord//stable"},
},
{ {
name: "rpm-ostree upgrade", name: "rpm-ostree upgrade",
got: rpmOstreeUpgradeArgv(UpgradeOptions{}), got: rpmOstreeUpgradeArgv(UpgradeOptions{}),
@@ -106,45 +69,6 @@ func TestUpgradeCommandBuilders(t *testing.T) {
} }
} }
func TestAptUpgradeArgvHoldsIgnored(t *testing.T) {
argv := aptUpgradeArgv("apt-get", UpgradeOptions{Ignored: []string{"linux-image-generic", "bad;name"}})
if len(argv) < 2 || argv[len(argv)-2] != "-c" {
t.Fatalf("expected sh -c script, got %#v", argv)
}
script := argv[len(argv)-1]
if !strings.Contains(script, "apt-mark hold") || !strings.Contains(script, "apt-mark unhold") {
t.Fatalf("hold script missing hold/unhold: %q", script)
}
if !strings.Contains(script, "linux-image-generic") {
t.Fatalf("hold script missing ignored package: %q", script)
}
if strings.Contains(script, "bad;name") {
t.Fatalf("hold script must drop unsafe name: %q", script)
}
}
func TestZypperUpgradeArgvLocksIgnored(t *testing.T) {
argv := zypperUpgradeArgv(UpgradeOptions{Ignored: []string{"kernel-default"}})
if len(argv) < 2 || argv[len(argv)-2] != "-c" {
t.Fatalf("expected sh -c script, got %#v", argv)
}
script := argv[len(argv)-1]
if !strings.Contains(script, "zypper --non-interactive al") || !strings.Contains(script, "zypper --non-interactive rl") {
t.Fatalf("lock script missing add/remove lock: %q", script)
}
if !strings.Contains(script, "kernel-default") {
t.Fatalf("lock script missing ignored package: %q", script)
}
}
func TestShellSafeNames(t *testing.T) {
got := shellSafeNames([]string{"linux", "gtk+", "bad name", "rm -rf /", "org.mozilla.firefox", "a;b"})
want := []string{"linux", "gtk+", "org.mozilla.firefox"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("shellSafeNames() = %#v, want %#v", got, want)
}
}
func TestBackendHasTargetsRespectsBackendAndOptions(t *testing.T) { func TestBackendHasTargetsRespectsBackendAndOptions(t *testing.T) {
targets := []Package{ targets := []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf5"}, {Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf5"},
+15 -48
View File
@@ -23,9 +23,6 @@ import (
const animKelvinStep = 25 const animKelvinStep = 25
// Go timers freeze during suspend; cap sleeps so wall-clock deadlines can't be missed.
const maxScheduleWait = 5 * time.Minute
func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error) { func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error) {
if err := config.Validate(); err != nil { if err := config.Validate(); err != nil {
return nil, err return nil, err
@@ -307,8 +304,8 @@ func (m *Manager) setupControlHandlers(state *outputState, control *wlr_gamma_co
out.rampSize = size out.rampSize = size
out.failed = false out.failed = false
out.retryCount = 0 out.retryCount = 0
out.lastTemp = 0
} }
m.lastAppliedTemp = 0
m.applyCurrentTemp("gamma_size") m.applyCurrentTemp("gamma_size")
}) })
}) })
@@ -510,10 +507,6 @@ func (m *Manager) recalcSchedule(now time.Time) {
config.ManualSunrise.Hour(), config.ManualSunrise.Minute(), config.ManualSunrise.Second(), 0, now.Location()) config.ManualSunrise.Hour(), config.ManualSunrise.Minute(), config.ManualSunrise.Second(), 0, now.Location())
sunset := time.Date(now.Year(), now.Month(), now.Day(), sunset := time.Date(now.Year(), now.Month(), now.Day(),
config.ManualSunset.Hour(), config.ManualSunset.Minute(), config.ManualSunset.Second(), 0, now.Location()) config.ManualSunset.Hour(), config.ManualSunset.Minute(), config.ManualSunset.Second(), 0, now.Location())
if !sunset.After(sunrise) {
// night start past midnight belongs to the next day
sunset = sunset.Add(24 * time.Hour)
}
times = SunTimes{ times = SunTimes{
Dawn: sunrise.Add(-dur), Dawn: sunrise.Add(-dur),
Sunrise: sunrise, Sunrise: sunrise,
@@ -525,8 +518,6 @@ func (m *Manager) recalcSchedule(now time.Time) {
lat, lon := m.getLocation() lat, lon := m.getLocation()
if lat == nil || lon == nil { if lat == nil || lon == nil {
m.gammaState = StateStatic m.gammaState = StateStatic
// stale times from a previous config must not drive applies
m.schedule = sunSchedule{}
return return
} }
times, cond = CalculateSunTimesWithTwilight(*lat, *lon, now, config.ElevationTwilight, config.ElevationDaylight) times, cond = CalculateSunTimesWithTwilight(*lat, *lon, now, config.ElevationTwilight, config.ElevationDaylight)
@@ -620,26 +611,7 @@ func (m *Manager) getSunPosition(now time.Time) float64 {
return 1.0 return 1.0
} }
func shiftTimes(times SunTimes, d time.Duration) SunTimes {
return SunTimes{
Dawn: times.Dawn.Add(d),
Sunrise: times.Sunrise.Add(d),
Sunset: times.Sunset.Add(d),
Night: times.Night.Add(d),
}
}
// activeCycle maps early-morning hours back to yesterday's cycle when the
// schedule crosses midnight.
func activeCycle(now time.Time, times SunTimes) SunTimes {
if now.Before(times.Night.Add(-24 * time.Hour)) {
return shiftTimes(times, -24*time.Hour)
}
return times
}
func (m *Manager) getSunPositionNormal(now time.Time, times SunTimes) float64 { func (m *Manager) getSunPositionNormal(now time.Time, times SunTimes) float64 {
times = activeCycle(now, times)
if now.Before(times.Dawn) { if now.Before(times.Dawn) {
return 0.0 return 0.0
} }
@@ -694,7 +666,7 @@ func (m *Manager) getNextDeadline(now time.Time) time.Time {
} }
func (m *Manager) getDeadlineNormal(now time.Time, sched sunSchedule) time.Time { func (m *Manager) getDeadlineNormal(now time.Time, sched sunSchedule) time.Time {
times := activeCycle(now, sched.times) times := sched.times
switch { switch {
case now.Before(times.Dawn): case now.Before(times.Dawn):
return times.Dawn return times.Dawn
@@ -765,11 +737,8 @@ func (m *Manager) schedulerLoop() {
if enabled { if enabled {
deadline := m.getNextDeadline(now) deadline := m.getNextDeadline(now)
waitDur = time.Until(deadline) waitDur = time.Until(deadline)
switch { if waitDur < time.Second {
case waitDur < time.Second:
waitDur = time.Second waitDur = time.Second
case waitDur > maxScheduleWait:
waitDur = maxScheduleWait
} }
} else { } else {
waitDur = 24 * time.Hour waitDur = 24 * time.Hour
@@ -849,6 +818,8 @@ func (m *Manager) applyGamma(temp int) {
return return
case !m.controlsInitialized: case !m.controlsInitialized:
return return
case m.lastAppliedTemp == temp && m.lastAppliedGamma == gamma:
return
} }
var outs []*outputState var outs []*outputState
@@ -874,8 +845,6 @@ func (m *Manager) applyGamma(temp int) {
continue continue
case out.gammaControl == nil: case out.gammaControl == nil:
continue continue
case out.lastTemp == temp && out.lastGamma == gamma:
continue
case !m.outputStillValid(out): case !m.outputStillValid(out):
continue continue
} }
@@ -896,19 +865,19 @@ func (m *Manager) applyGamma(temp int) {
for _, j := range jobs { for _, j := range jobs {
err := m.setGammaBytes(j.out, j.data) err := m.setGammaBytes(j.out, j.data)
if err == nil { if err == nil {
j.out.lastTemp = temp
j.out.lastGamma = gamma
continue continue
} }
log.Warnf("gamma: failed to set output %d: %v", j.out.id, err) log.Warnf("gamma: failed to set output %d: %v", j.out.id, err)
j.out.failed = true j.out.failed = true
j.out.rampSize = 0 j.out.rampSize = 0
j.out.lastTemp = 0
if isConnectionDeadErr(err) { if isConnectionDeadErr(err) {
m.markConnectionDead(err) m.markConnectionDead(err)
return return
} }
} }
m.lastAppliedTemp = temp
m.lastAppliedGamma = gamma
} }
func (m *Manager) setGammaBytes(out *outputState, data []byte) error { func (m *Manager) setGammaBytes(out *outputState, data []byte) error {
@@ -970,8 +939,7 @@ func (m *Manager) updateStateFromSchedule() {
pos = m.getSunPosition(now) pos = m.getSunPosition(now)
temp = m.getTempFromPosition(pos) temp = m.getTempFromPosition(pos)
deadline = m.getNextDeadline(now) deadline = m.getNextDeadline(now)
cycle := activeCycle(now, times) isDay = now.After(times.Sunrise) && now.Before(times.Sunset)
isDay = now.After(cycle.Sunrise) && now.Before(cycle.Sunset)
} }
newState := State{ newState := State{
@@ -1085,15 +1053,14 @@ func (m *Manager) handleResume() {
return return
} }
// Compositor gamma state is unknown after resume; force a resend (#1235) // Compositors (Niri, Hyprland, wlroots-based) re-apply the cached gamma
// and re-arm the scheduler timer, which froze during suspend. // ramp to DRM on resume; gamma_control objects stay valid. We just need
m.outputs.Range(func(_ uint32, out *outputState) bool { // to force a resend so the schedule catches up with the current time of
out.lastTemp = 0 // day — the original #1235 regression was caused by lastAppliedTemp
return true // matching and the send being skipped.
})
m.recalcSchedule(time.Now()) m.recalcSchedule(time.Now())
m.lastAppliedTemp = 0
m.applyCurrentTemp("resume") m.applyCurrentTemp("resume")
m.triggerUpdate()
} }
func (m *Manager) triggerUpdate() { func (m *Manager) triggerUpdate() {
+3 -2
View File
@@ -102,6 +102,9 @@ type Manager struct {
dbusSignal chan *dbus.Signal dbusSignal chan *dbus.Signal
geoClient geolocation.Client geoClient geolocation.Client
lastAppliedTemp int
lastAppliedGamma float64
} }
type outputState struct { type outputState struct {
@@ -114,8 +117,6 @@ type outputState struct {
isVirtual bool isVirtual bool
retryCount int retryCount int
lastFailTime time.Time lastFailTime time.Time
lastTemp int
lastGamma float64
} }
func DefaultConfig() Config { func DefaultConfig() Config {
-12
View File
@@ -139,18 +139,6 @@ func (m Model) deployConfigurations() tea.Cmd {
} }
} }
func (m Model) optionalDepSelected(name string) bool {
if m.disabledItems[name] {
return false
}
for _, dep := range m.dependencies {
if dep.Name == name {
return true
}
}
return false
}
func (m Model) useSystemdConfig() bool { func (m Model) useSystemdConfig() bool {
if m.osInfo == nil { if m.osInfo == nil {
return true return true
+15 -58
View File
@@ -28,21 +28,6 @@ func (m Model) viewDetectingDeps() string {
return b.String() return b.String()
} }
func partitionOptionalLast(dependencies []deps.Dependency) []deps.Dependency {
ordered := make([]deps.Dependency, 0, len(dependencies))
for _, dep := range dependencies {
if dep.Required {
ordered = append(ordered, dep)
}
}
for _, dep := range dependencies {
if !dep.Required {
ordered = append(ordered, dep)
}
}
return ordered
}
func (m Model) viewDependencyReview() string { func (m Model) viewDependencyReview() string {
var b strings.Builder var b strings.Builder
@@ -54,15 +39,7 @@ func (m Model) viewDependencyReview() string {
b.WriteString("\n\n") b.WriteString("\n\n")
if len(m.dependencies) > 0 { if len(m.dependencies) > 0 {
optionalHeaderShown := false
for i, dep := range m.dependencies { for i, dep := range m.dependencies {
if !dep.Required && !optionalHeaderShown {
b.WriteString("\n")
b.WriteString(m.styles.Subtle.Render("Optional (space to enable)"))
b.WriteString("\n")
optionalHeaderShown = true
}
var status string var status string
var reinstallMarker string var reinstallMarker string
var variantMarker string var variantMarker string
@@ -105,13 +82,8 @@ func (m Model) viewDependencyReview() string {
} }
note := "" note := ""
switch dep.Name { if dep.Name == "dms-greeter" {
case "dms-greeter":
note = m.styles.Subtle.Render(" (selection replaces your current display manager)") note = m.styles.Subtle.Render(" (selection replaces your current display manager)")
case "danksearch":
note = m.styles.Subtle.Render(" (file search; enables dsearch.service)")
case "dankcalendar":
note = m.styles.Subtle.Render(" (autostart managed in dankcalendar settings)")
} }
var line string var line string
@@ -148,13 +120,13 @@ func (m Model) updateDetectingDepsState(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = depsMsg.err m.err = depsMsg.err
m.state = StateError m.state = StateError
} else { } else {
m.dependencies = partitionOptionalLast(depsMsg.deps) m.dependencies = depsMsg.deps
// Optional components are opt-in, skipped by default // dms-greeter is opt-in skipped by default
for _, dep := range m.dependencies { for _, dep := range depsMsg.deps {
if dep.Required { if dep.Name == "dms-greeter" {
continue m.disabledItems["dms-greeter"] = true
break
} }
m.disabledItems[dep.Name] = true
} }
m.state = StateDependencyReview m.state = StateDependencyReview
} }
@@ -259,7 +231,14 @@ func (m Model) installPackages() tea.Cmd {
for msg := range installerProgressChan { for msg := range installerProgressChan {
// Run optional greeter setup // Run optional greeter setup
if msg.Phase == distros.PhaseComplete && msg.IsComplete && msg.Error == nil { if msg.Phase == distros.PhaseComplete && msg.IsComplete && msg.Error == nil {
if m.optionalDepSelected("dms-greeter") { greeterSelected := false
for _, dep := range m.dependencies {
if dep.Name == "dms-greeter" && !m.disabledItems["dms-greeter"] {
greeterSelected = true
break
}
}
if greeterSelected {
compositorName := "niri" compositorName := "niri"
switch m.selectedWindowManager() { switch m.selectedWindowManager() {
case deps.WindowManagerHyprland: case deps.WindowManagerHyprland:
@@ -286,28 +265,6 @@ func (m Model) installPackages() tea.Cmd {
logOutput: fmt.Sprintf("⚠ Greeter auto-setup warning (non-fatal): %v", err), logOutput: fmt.Sprintf("⚠ Greeter auto-setup warning (non-fatal): %v", err),
} }
} }
if m.useSystemdConfig() && m.optionalDepSelected("danksearch") {
m.packageProgressChan <- packageInstallProgressMsg{
progress: 0.97,
step: "Enabling danksearch service...",
logOutput: "Setting up dsearch.service...",
}
dsearchLogFunc := func(line string) {
m.packageProgressChan <- packageInstallProgressMsg{
progress: 0.97,
step: "Enabling danksearch service...",
logOutput: line,
}
}
if err := distros.SetupDsearchService(context.Background(), dsearchLogFunc); err != nil {
m.packageProgressChan <- packageInstallProgressMsg{
progress: 0.98,
step: "danksearch service warning",
logOutput: fmt.Sprintf("danksearch service setup warning (non-fatal): %v", err),
}
}
}
} }
} }
tuiMsg := packageInstallProgressMsg{ tuiMsg := packageInstallProgressMsg{
@@ -384,6 +384,10 @@ func applyHyprlandRuleAction(actions *windowrules.Actions, rule, value string) {
if f, err := strconv.ParseFloat(value, 64); err == nil { if f, err := strconv.ParseFloat(value, 64); err == nil {
actions.Opacity = &f actions.Opacity = &f
} }
case "size":
actions.Size = value
case "move":
actions.Move = value
case "monitor": case "monitor":
actions.Monitor = value actions.Monitor = value
case "workspace": case "workspace":
@@ -560,13 +564,6 @@ func hyprLuaBoolStr(b bool) string {
return "false" return "false"
} }
func hyprLuaExprWrap(v string) string {
if _, err := strconv.ParseFloat(v, 64); err == nil {
return v
}
return strconv.Quote(v)
}
func luaAppendMatch(mc windowrules.MatchCriteria, dst *[]string) { func luaAppendMatch(mc windowrules.MatchCriteria, dst *[]string) {
if mc.AppID != "" { if mc.AppID != "" {
*dst = append(*dst, fmt.Sprintf(`class = %s`, strconv.Quote(mc.AppID))) *dst = append(*dst, fmt.Sprintf(`class = %s`, strconv.Quote(mc.AppID)))
@@ -637,11 +634,11 @@ func luaAppendActions(a windowrules.Actions, dst *[]string) {
if a.Opacity != nil { if a.Opacity != nil {
*dst = append(*dst, fmt.Sprintf(`opacity = %s`, strconv.FormatFloat(*a.Opacity, 'g', -1, 64))) *dst = append(*dst, fmt.Sprintf(`opacity = %s`, strconv.FormatFloat(*a.Opacity, 'g', -1, 64)))
} }
if a.SizeWidth != "" && a.SizeHeight != "" { if a.Size != "" {
*dst = append(*dst, fmt.Sprintf(`size = { %s, %s }`, hyprLuaExprWrap(a.SizeWidth), hyprLuaExprWrap(a.SizeHeight))) *dst = append(*dst, fmt.Sprintf(`size = %s`, strconv.Quote(a.Size)))
} }
if a.MoveX != "" && a.MoveY != "" { if a.Move != "" {
*dst = append(*dst, fmt.Sprintf(`move = { %s, %s }`, hyprLuaExprWrap(a.MoveX), hyprLuaExprWrap(a.MoveY))) *dst = append(*dst, fmt.Sprintf(`move = %s`, strconv.Quote(a.Move)))
} }
if a.Monitor != "" { if a.Monitor != "" {
*dst = append(*dst, fmt.Sprintf(`monitor = %s`, strconv.Quote(a.Monitor))) *dst = append(*dst, fmt.Sprintf(`monitor = %s`, strconv.Quote(a.Monitor)))
@@ -1197,11 +1194,7 @@ func luaStringValue(s string) string {
} }
} }
} }
v := strings.Trim(strings.TrimSpace(s), `"'`) return strings.Trim(strings.TrimSpace(s), `"'`)
if len(v) >= 2 && v[0] == '(' && v[len(v)-1] == ')' {
v = strings.TrimSpace(v[1 : len(v)-1])
}
return v
} }
func luaBoolLike(s string) (val bool, ok bool) { func luaBoolLike(s string) (val bool, ok bool) {
@@ -1356,29 +1349,11 @@ func applyLuaActionKey(a *windowrules.Actions, key, raw string) bool {
} }
} }
case "size": case "size":
v := strings.TrimSpace(luaStringValue(raw)) a.Size = strings.TrimSpace(luaStringValue(raw))
if strings.HasPrefix(v, "{") && strings.HasSuffix(v, "}") { return true
inner := trimOuterBraces(v)
parts := splitTopLevelCommaLua(inner)
if len(parts) == 2 {
a.SizeWidth = strings.TrimSpace(luaStringValue(parts[0]))
a.SizeHeight = strings.TrimSpace(luaStringValue(parts[1]))
return true
}
}
return false
case "move": case "move":
v := strings.TrimSpace(luaStringValue(raw)) a.Move = strings.TrimSpace(luaStringValue(raw))
if strings.HasPrefix(v, "{") && strings.HasSuffix(v, "}") { return true
inner := trimOuterBraces(v)
parts := splitTopLevelCommaLua(inner)
if len(parts) == 2 {
a.MoveX = strings.TrimSpace(luaStringValue(parts[0]))
a.MoveY = strings.TrimSpace(luaStringValue(parts[1]))
return true
}
}
return false
case "monitor": case "monitor":
a.Monitor = strings.TrimSpace(luaStringValue(raw)) a.Monitor = strings.TrimSpace(luaStringValue(raw))
return true return true
@@ -400,196 +400,3 @@ func TestBoolToInt(t *testing.T) {
t.Error("boolToInt(false) should be 0") t.Error("boolToInt(false) should be 0")
} }
} }
func TestLuaAppendActionsTableSyntax(t *testing.T) {
actions := windowrules.Actions{
SizeWidth: "800",
SizeHeight: "600",
MoveX: "100",
MoveY: "200",
}
var out []string
luaAppendActions(actions, &out)
joined := strings.Join(out, "\n")
for _, want := range []string{
`size = { 800, 600 }`,
`move = { 100, 200 }`,
} {
if !strings.Contains(joined, want) {
t.Errorf("expected output to contain %q, got:\n%s", want, joined)
}
}
}
func TestLuaAppendActionsExprWrap(t *testing.T) {
actions := windowrules.Actions{
SizeWidth: "window_w * 0.5",
SizeHeight: "window_h - 50",
MoveX: "100",
MoveY: "(monitor_h / 2) + 17",
}
var out []string
luaAppendActions(actions, &out)
joined := strings.Join(out, "\n")
for _, want := range []string{
`size = { "window_w * 0.5", "window_h - 50" }`,
`move = { 100, "(monitor_h / 2) + 17" }`,
} {
if !strings.Contains(joined, want) {
t.Errorf("expected output to contain %q, got:\n%s", want, joined)
}
}
}
func TestApplyLuaActionKeyTableSyntax(t *testing.T) {
tests := []struct {
name string
key string
raw string
wantSizeW string
wantSizeH string
wantMoveX string
wantMoveY string
}{
{
name: "size table syntax",
key: "size",
raw: `{ 800, 600 }`,
wantSizeW: "800",
wantSizeH: "600",
},
{
name: "move table syntax",
key: "move",
raw: `{ 100, 200 }`,
wantMoveX: "100",
wantMoveY: "200",
},
{
name: "size string syntax returns false",
key: "size",
raw: `"800x600"`,
},
{
name: "move string syntax returns false",
key: "move",
raw: `"100 200"`,
},
{
name: "size expressions",
key: "size",
raw: `{ "window_w * 0.5", "window_h - 50" }`,
wantSizeW: "window_w * 0.5",
wantSizeH: "window_h - 50",
},
{
name: "move expressions",
key: "move",
raw: `{ 100, "(monitor_h / 2) + 17" }`,
wantMoveX: "100",
wantMoveY: "(monitor_h / 2) + 17",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var a windowrules.Actions
result := applyLuaActionKey(&a, tt.key, tt.raw)
if tt.wantSizeW == "" && tt.wantSizeH == "" && tt.wantMoveX == "" && tt.wantMoveY == "" {
if result {
t.Errorf("expected applyLuaActionKey to return false for string syntax, got true")
}
return
}
if !result {
t.Fatal("applyLuaActionKey returned false")
}
if tt.wantSizeW != "" && a.SizeWidth != tt.wantSizeW {
t.Errorf("SizeWidth = %q, want %q", a.SizeWidth, tt.wantSizeW)
}
if tt.wantSizeH != "" && a.SizeHeight != tt.wantSizeH {
t.Errorf("SizeHeight = %q, want %q", a.SizeHeight, tt.wantSizeH)
}
if tt.wantMoveX != "" && a.MoveX != tt.wantMoveX {
t.Errorf("MoveX = %q, want %q", a.MoveX, tt.wantMoveX)
}
if tt.wantMoveY != "" && a.MoveY != tt.wantMoveY {
t.Errorf("MoveY = %q, want %q", a.MoveY, tt.wantMoveY)
}
})
}
}
func TestLuaRoundTripTableSyntax(t *testing.T) {
original := windowrules.Actions{
SizeWidth: "800",
SizeHeight: "600",
MoveX: "100",
MoveY: "200",
}
var out []string
luaAppendActions(original, &out)
var parsed windowrules.Actions
for _, line := range out {
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
applyLuaActionKey(&parsed, key, val)
}
if parsed.SizeWidth != original.SizeWidth {
t.Errorf("SizeWidth = %q, want %q", parsed.SizeWidth, original.SizeWidth)
}
if parsed.SizeHeight != original.SizeHeight {
t.Errorf("SizeHeight = %q, want %q", parsed.SizeHeight, original.SizeHeight)
}
if parsed.MoveX != original.MoveX {
t.Errorf("MoveX = %q, want %q", parsed.MoveX, original.MoveX)
}
if parsed.MoveY != original.MoveY {
t.Errorf("MoveY = %q, want %q", parsed.MoveY, original.MoveY)
}
}
func TestLuaRoundTripTableSyntaxExpressions(t *testing.T) {
original := windowrules.Actions{
SizeWidth: "window_w * 0.5",
SizeHeight: "window_h - 50",
MoveX: "100",
MoveY: "(monitor_h / 2) + 17",
}
var out []string
luaAppendActions(original, &out)
var parsed windowrules.Actions
for _, line := range out {
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
applyLuaActionKey(&parsed, key, val)
}
if parsed.SizeWidth != original.SizeWidth {
t.Errorf("SizeWidth = %q, want %q", parsed.SizeWidth, original.SizeWidth)
}
if parsed.SizeHeight != original.SizeHeight {
t.Errorf("SizeHeight = %q, want %q", parsed.SizeHeight, original.SizeHeight)
}
if parsed.MoveX != original.MoveX {
t.Errorf("MoveX = %q, want %q", parsed.MoveX, original.MoveX)
}
if parsed.MoveY != original.MoveY {
t.Errorf("MoveY = %q, want %q", parsed.MoveY, original.MoveY)
}
}
@@ -5,6 +5,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules" "github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
@@ -167,8 +168,7 @@ func ConvertMangoRulesToWindowRules(mangoRules []MangoWindowRule) []windowrules.
} }
if w, ok := f["width"]; ok { if w, ok := f["width"]; ok {
if h, ok2 := f["height"]; ok2 { if h, ok2 := f["height"]; ok2 {
actions.SizeWidth = w actions.Size = w + "x" + h
actions.SizeHeight = h
} }
} }
@@ -200,9 +200,11 @@ func formatMangoRule(rule windowrules.WindowRule) string {
add("tags", rule.Actions.Workspace) add("tags", rule.Actions.Workspace)
add("monitor", rule.Actions.Monitor) add("monitor", rule.Actions.Monitor)
if rule.Actions.SizeWidth != "" && rule.Actions.SizeHeight != "" { if rule.Actions.Size != "" {
add("width", rule.Actions.SizeWidth) if w, h, ok := splitSize(rule.Actions.Size); ok {
add("height", rule.Actions.SizeHeight) add("width", w)
add("height", h)
}
} }
addBool := func(k string, b *bool) { addBool := func(k string, b *bool) {
@@ -221,6 +223,19 @@ func formatMangoRule(rule windowrules.WindowRule) string {
return "windowrule=" + strings.Join(parts, ",") return "windowrule=" + strings.Join(parts, ",")
} }
func splitSize(size string) (w, h string, ok bool) {
for _, sep := range []string{"x", "X", " "} {
if parts := strings.Split(size, sep); len(parts) == 2 {
w = strings.TrimSpace(parts[0])
h = strings.TrimSpace(parts[1])
if _, err := strconv.ParseFloat(w, 64); err == nil {
return w, h, true
}
}
}
return "", "", false
}
type MangoWritableProvider struct { type MangoWritableProvider struct {
configDir string configDir string
} }
@@ -65,8 +65,7 @@ func TestMangoSetAndLoadRoundTrip(t *testing.T) {
Actions: windowrules.Actions{ Actions: windowrules.Actions{
OpenFloating: &floating, OpenFloating: &floating,
Workspace: "9", Workspace: "9",
SizeWidth: "1000", Size: "1000x900",
SizeHeight: "900",
}, },
} }
@@ -99,11 +98,8 @@ func TestMangoSetAndLoadRoundTrip(t *testing.T) {
if got.Actions.Workspace != "9" { if got.Actions.Workspace != "9" {
t.Errorf("Workspace = %q, want 9", got.Actions.Workspace) t.Errorf("Workspace = %q, want 9", got.Actions.Workspace)
} }
if got.Actions.SizeWidth != "1000" { if got.Actions.Size != "1000x900" {
t.Errorf("SizeWidth = %q, want 1000", got.Actions.SizeWidth) t.Errorf("Size = %q, want 1000x900", got.Actions.Size)
}
if got.Actions.SizeHeight != "900" {
t.Errorf("SizeHeight = %q, want 900", got.Actions.SizeHeight)
} }
if got.Actions.OpenFloating == nil || !*got.Actions.OpenFloating { if got.Actions.OpenFloating == nil || !*got.Actions.OpenFloating {
t.Errorf("OpenFloating = %v, want true", got.Actions.OpenFloating) t.Errorf("OpenFloating = %v, want true", got.Actions.OpenFloating)
@@ -118,40 +114,3 @@ func TestMangoSetAndLoadRoundTrip(t *testing.T) {
t.Errorf("after remove got %d rules, want 0", len(loaded)) t.Errorf("after remove got %d rules, want 0", len(loaded))
} }
} }
func TestMangoRoundTripWithSizeWidthHeight(t *testing.T) {
tmpDir := t.TempDir()
provider := NewMangoWritableProvider(tmpDir)
rule := windowrules.WindowRule{
ID: "rule_roundtrip",
Name: "Size Test",
Enabled: true,
MatchCriteria: windowrules.MatchCriteria{
AppID: "testapp",
},
Actions: windowrules.Actions{
SizeWidth: "800",
SizeHeight: "600",
},
}
if err := provider.SetRule(rule); err != nil {
t.Fatalf("SetRule: %v", err)
}
loaded, err := provider.LoadDMSRules()
if err != nil {
t.Fatalf("LoadDMSRules: %v", err)
}
if len(loaded) != 1 {
t.Fatalf("got %d rules, want 1", len(loaded))
}
got := loaded[0]
if got.Actions.SizeWidth != "800" {
t.Errorf("SizeWidth = %q, want 800", got.Actions.SizeWidth)
}
if got.Actions.SizeHeight != "600" {
t.Errorf("SizeHeight = %q, want 600", got.Actions.SizeHeight)
}
}

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