mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-02 11:38:30 -04:00
Compare commits
5 Commits
74896fb87c
..
hover
| Author | SHA1 | Date | |
|---|---|---|---|
| e791337ae9 | |||
| a8c15fcde9 | |||
| 601d4104a3 | |||
| 7979fb2b0e | |||
| 06fa21118e |
@@ -1,23 +0,0 @@
|
||||
# Categories for GitHub's auto-generated release notes
|
||||
changelog:
|
||||
exclude:
|
||||
authors:
|
||||
- github-actions[bot]
|
||||
- dependabot[bot]
|
||||
labels:
|
||||
- internal
|
||||
categories:
|
||||
- title: Breaking Changes
|
||||
labels: [breaking]
|
||||
- title: Features
|
||||
labels: [feature]
|
||||
- title: Fixes
|
||||
labels: [fix]
|
||||
- title: Packaging
|
||||
labels: [packaging]
|
||||
- title: Internationalization
|
||||
labels: [i18n]
|
||||
- title: Documentation
|
||||
labels: [docs]
|
||||
- title: Other Changes
|
||||
labels: ["*"]
|
||||
@@ -1,170 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo "usage: $0 download|publish" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
require_env() {
|
||||
local name
|
||||
for name in "$@"; do
|
||||
if [[ -z "${!name:-}" ]]; then
|
||||
echo "error: $name is required" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
require_env R2_BUCKET R2_ENDPOINT R2_PREFIX REPOSITORY_DIR
|
||||
|
||||
aws_r2() {
|
||||
aws --endpoint-url "$R2_ENDPOINT" "$@"
|
||||
}
|
||||
|
||||
download() {
|
||||
mkdir -p "$REPOSITORY_DIR/current" "$REPOSITORY_DIR/previous-current"
|
||||
|
||||
aws_r2 s3 sync \
|
||||
"s3://${R2_BUCKET}/${R2_PREFIX}/current/" \
|
||||
"$REPOSITORY_DIR/current/" \
|
||||
--only-show-errors
|
||||
|
||||
cp -a "$REPOSITORY_DIR/current/." "$REPOSITORY_DIR/previous-current/"
|
||||
}
|
||||
|
||||
build_manifest() {
|
||||
local packages='[]'
|
||||
local file filename pkgver name version revision sha size
|
||||
|
||||
for file in "$REPOSITORY_DIR"/current/*.xbps; do
|
||||
filename="$(basename "$file")"
|
||||
pkgver="$(xbps-uhelper binpkgver "$filename")"
|
||||
name="$(xbps-uhelper getpkgname "$pkgver")"
|
||||
version="$(xbps-uhelper getpkgversion "$pkgver")"
|
||||
revision="$(xbps-uhelper getpkgrevision "$pkgver")"
|
||||
version="${version%_"${revision}"}"
|
||||
sha="$(sha256sum "$file" | cut -d' ' -f1)"
|
||||
size="$(stat -c '%s' "$file")"
|
||||
packages="$(jq \
|
||||
--arg name "$name" \
|
||||
--arg version "$version" \
|
||||
--arg revision "$revision" \
|
||||
--arg filename "$filename" \
|
||||
--arg sha256 "$sha" \
|
||||
--argjson size "$size" \
|
||||
'. + [{name: $name, version: $version, revision: $revision, filename: $filename, sha256: $sha256, size: $size}]' \
|
||||
<<<"$packages")"
|
||||
done
|
||||
|
||||
jq -n \
|
||||
--arg repository "$R2_PREFIX" \
|
||||
--arg source_commit "$SOURCE_COMMIT" \
|
||||
--arg published_at "$PUBLISHED_AT" \
|
||||
--argjson packages "$packages" \
|
||||
'{schema: 1, repository: $repository, source_commit: $source_commit, published_at: $published_at, packages: $packages}' \
|
||||
> "$REPOSITORY_DIR/current/manifest.json"
|
||||
}
|
||||
|
||||
verify_immutable_packages() {
|
||||
local file previous
|
||||
|
||||
for file in "$REPOSITORY_DIR"/current/*.xbps "$REPOSITORY_DIR"/current/*.sig2; do
|
||||
previous="$REPOSITORY_DIR/previous-current/$(basename "$file")"
|
||||
if [[ -f "$previous" ]] && ! cmp -s "$previous" "$file"; then
|
||||
echo "error: refusing to replace immutable object $(basename "$file")" >&2
|
||||
echo "bump the XBPS revision or version before publishing a changed build" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
archive_retired() {
|
||||
local old filename
|
||||
local archive_prefix="archive/${R2_PREFIX}/${PUBLISHED_AT//:/-}"
|
||||
|
||||
shopt -s nullglob
|
||||
for old in "$REPOSITORY_DIR"/previous-current/*.xbps "$REPOSITORY_DIR"/previous-current/*.sig2; do
|
||||
filename="$(basename "$old")"
|
||||
if [[ ! -e "$REPOSITORY_DIR/current/$filename" ]]; then
|
||||
aws_r2 s3 cp \
|
||||
"$old" \
|
||||
"s3://${R2_BUCKET}/${archive_prefix}/${filename}" \
|
||||
--cache-control 'private,no-store' \
|
||||
--only-show-errors
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
upload_current() {
|
||||
local file filename old
|
||||
|
||||
# Versioned package objects must exist before repodata can reference them.
|
||||
for file in "$REPOSITORY_DIR"/current/*.xbps "$REPOSITORY_DIR"/current/*.sig2; do
|
||||
filename="$(basename "$file")"
|
||||
aws_r2 s3 cp \
|
||||
"$file" \
|
||||
"s3://${R2_BUCKET}/${R2_PREFIX}/current/${filename}" \
|
||||
--cache-control 'public,max-age=31536000,immutable' \
|
||||
--only-show-errors
|
||||
done
|
||||
|
||||
aws_r2 s3 cp \
|
||||
"$REPOSITORY_DIR/current/x86_64-repodata" \
|
||||
"s3://${R2_BUCKET}/${R2_PREFIX}/current/x86_64-repodata" \
|
||||
--cache-control 'no-cache' \
|
||||
--only-show-errors
|
||||
|
||||
# The manifest is the publication marker and is always uploaded last.
|
||||
aws_r2 s3 cp \
|
||||
"$REPOSITORY_DIR/current/manifest.json" \
|
||||
"s3://${R2_BUCKET}/${R2_PREFIX}/current/manifest.json" \
|
||||
--cache-control 'no-cache' \
|
||||
--only-show-errors
|
||||
|
||||
# Once the new index and marker are live, remove objects no longer referenced.
|
||||
shopt -s nullglob
|
||||
for old in "$REPOSITORY_DIR"/previous-current/*; do
|
||||
filename="$(basename "$old")"
|
||||
if [[ ! -e "$REPOSITORY_DIR/current/$filename" ]]; then
|
||||
aws_r2 s3 rm \
|
||||
"s3://${R2_BUCKET}/${R2_PREFIX}/current/${filename}" \
|
||||
--only-show-errors
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
publish() {
|
||||
require_env SOURCE_COMMIT
|
||||
PUBLISHED_AT="${PUBLISHED_AT:-$(date -u +'%Y-%m-%dT%H:%M:%SZ')}"
|
||||
export PUBLISHED_AT
|
||||
|
||||
shopt -s nullglob
|
||||
local packages=("$REPOSITORY_DIR"/current/*.xbps)
|
||||
if (( ${#packages[@]} == 0 )); then
|
||||
echo "error: refusing to publish an empty XBPS repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -s "$REPOSITORY_DIR/current/x86_64-repodata" ]] || {
|
||||
echo "error: x86_64-repodata is missing or empty" >&2
|
||||
exit 1
|
||||
}
|
||||
for file in "${packages[@]}"; do
|
||||
[[ -s "${file}.sig2" ]] || {
|
||||
echo "error: signature is missing for $(basename "$file")" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
verify_immutable_packages
|
||||
build_manifest
|
||||
archive_retired
|
||||
upload_current
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
download) download ;;
|
||||
publish) publish ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
@@ -7,17 +7,14 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
update-stable:
|
||||
# skip prerelease tags
|
||||
if: ${{ !contains(github.ref_name, '-') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create GitHub App token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v3
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
@@ -31,54 +28,4 @@ jobs:
|
||||
- name: Push to stable branch
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# don't roll stable backwards
|
||||
if git fetch origin stable --quiet 2>/dev/null; then
|
||||
stable_tag=$(git describe --tags --abbrev=0 FETCH_HEAD 2>/dev/null || echo "v0.0.0")
|
||||
newest=$(printf '%s\n%s\n' "$stable_tag" "${GITHUB_REF_NAME}" | sort -V | tail -1)
|
||||
if [ "$newest" != "${GITHUB_REF_NAME}" ]; then
|
||||
echo "skipping: ${GITHUB_REF_NAME} is older than stable (${stable_tag})"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" HEAD:refs/heads/stable --force
|
||||
|
||||
cut-release-branch:
|
||||
# create stable-X.Y at each vX.Y.0 tag
|
||||
if: ${{ !contains(github.ref_name, '-') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create GitHub App token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app_token.outputs.token }}
|
||||
|
||||
- name: Create release branch
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${GITHUB_REF_NAME}" =~ ^v([0-9]+)\.([0-9]+)\.0$ ]]; then
|
||||
echo "not a vX.Y.0 tag, no release branch to cut"
|
||||
exit 0
|
||||
fi
|
||||
branch="stable-${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
if git ls-remote --exit-code origin "refs/heads/${branch}" >/dev/null 2>&1; then
|
||||
echo "${branch} already exists"
|
||||
exit 0
|
||||
fi
|
||||
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" "HEAD:refs/heads/${branch}"
|
||||
echo "created ${branch} at ${GITHUB_REF_NAME}"
|
||||
GH_TOKEN="${{ secrets.GITHUB_TOKEN }}" gh label create "port ${branch}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--description "Auto-port merged PR to ${branch}" --color 0e8a16 ||
|
||||
echo "label 'port ${branch}' may already exist"
|
||||
run: git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:refs/heads/stable --force
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
- "core/**"
|
||||
- ".github/workflows/go-ci.yml"
|
||||
pull_request:
|
||||
branches: [master, main, "stable-*"]
|
||||
branches: [master, main]
|
||||
paths:
|
||||
- "core/**"
|
||||
- ".github/workflows/go-ci.yml"
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Nix flake and NixOS tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master, main, "stable-*"]
|
||||
branches: [master, main]
|
||||
paths:
|
||||
- "flake.*"
|
||||
- "distro/nix/**"
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
name: Point release
|
||||
|
||||
# Cuts vX.Y.Z from stable-X.Y: runs the port audit (warn-only), bumps
|
||||
# quickshell/VERSION, tags, and dispatches the Release workflow. Distro
|
||||
# builds are dispatched separately.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Point release version (e.g. 1.5.1)"
|
||||
required: true
|
||||
type: string
|
||||
notify_issues:
|
||||
description: "Comment on open related/fixes issues asking reporters to retest"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
|
||||
concurrency:
|
||||
group: point-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
steps:
|
||||
- name: Validate version and derive branch
|
||||
id: derive
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::version must be X.Y.Z (got '$VERSION')"; exit 1
|
||||
fi
|
||||
echo "branch=stable-${VERSION%.*}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create GitHub App token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout release branch
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ steps.derive.outputs.branch }}
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app_token.outputs.token }}
|
||||
|
||||
- name: Preflight — verify Release workflow is dispatchable
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api "repos/${{ github.repository }}/actions/workflows/release.yml" \
|
||||
--jq '.state' | grep -qx active ||
|
||||
{ echo "::error::release.yml is not dispatchable; aborting before any push"; exit 1; }
|
||||
|
||||
- name: Port audit (informational)
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
run: |
|
||||
bash scripts/port-audit.sh "${{ steps.derive.outputs.branch }}" ||
|
||||
echo "::warning::port audit failed; continuing"
|
||||
|
||||
- name: Bump VERSION, tag, and push
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
TAG: ${{ steps.derive.outputs.tag }}
|
||||
BRANCH: ${{ steps.derive.outputs.branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
|
||||
echo "::error::tag ${TAG} already exists"; exit 1
|
||||
fi
|
||||
git config user.name "dms-ci[bot]"
|
||||
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||
|
||||
echo "${TAG}" > quickshell/VERSION
|
||||
git add quickshell/VERSION
|
||||
git commit -m "bump VERSION to ${TAG}"
|
||||
git tag "${TAG}"
|
||||
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" "HEAD:${BRANCH}" "refs/tags/${TAG}"
|
||||
|
||||
- name: Dispatch Release workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh workflow run release.yml --ref "${{ steps.derive.outputs.tag }}" \
|
||||
-f tag="${{ steps.derive.outputs.tag }}" \
|
||||
-f notify_issues="${{ inputs.notify_issues }}"
|
||||
|
||||
- name: Next steps
|
||||
run: |
|
||||
{
|
||||
echo "## ${{ steps.derive.outputs.tag }} tagged on ${{ steps.derive.outputs.branch }} — Release workflow dispatched"
|
||||
echo ""
|
||||
echo "Distro builds are manual: run the per-distro workflows (COPR/OBS/PPA/XBPS) once the release is published."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,30 +0,0 @@
|
||||
name: Port audit
|
||||
|
||||
# On-demand report of master commits not yet ported to a release branch.
|
||||
# Updates the "Port status: <branch>" tracking issue and the step summary.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target:
|
||||
description: "Release branch to audit (default: newest release/*)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run audit
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: bash scripts/port-audit.sh ${{ inputs.target }} --issue
|
||||
@@ -1,101 +0,0 @@
|
||||
name: Port to release branch
|
||||
|
||||
# Ports flagged commits from master onto stable-X.Y branches:
|
||||
# - "port 1.5" flag in a commit message pushed to master — own line or
|
||||
# mid-line; separators : / - or space; "stable-1.5" also accepted;
|
||||
# comma lists ("Port: 1.5, 1.4") work in the own-line form
|
||||
# - "port stable-1.5" label on a merged PR
|
||||
# Conflicts are reported to the "Port status: <branch>" tracking issue.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request_target:
|
||||
types: [closed, labeled]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: port-engine
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
port:
|
||||
name: Port flagged commits
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create GitHub App token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
# Base-repo code only; PR head code is never checked out or executed.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: master
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app_token.outputs.token }}
|
||||
|
||||
- name: Port trailer-flagged commits
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
COMMITS: ${{ toJSON(github.event.commits) }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "dms-ci[bot]"
|
||||
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||
|
||||
for sha in $(jq -r '.[].id' <<<"$COMMITS"); do
|
||||
git cat-file -e "$sha" 2>/dev/null || continue
|
||||
# skip merge commits (handled by the label path)
|
||||
[ "$(git rev-list --no-walk --count --min-parents=2 "$sha")" -eq 0 ] || continue
|
||||
|
||||
# own-line form (supports comma lists), validated version-shaped
|
||||
t1=$(git log -1 --format=%B "$sha" |
|
||||
{ grep -iE '^Port[:/ -]' || true; } | sed -E 's|^port[-:/ ]+||I' | tr ',' '\n' |
|
||||
sed 's/[[:space:]]//g; /^$/d' | sed -E 's,^stable-,,I' |
|
||||
{ grep -E '^[0-9]+\.[0-9]+(\.[0-9]+)?$' || true; })
|
||||
# mid-line form, e.g. "fix thing fixes #2802 port 1.5"
|
||||
t2=$(git log -1 --format=%B "$sha" |
|
||||
{ grep -oiE '\bport[-: /]+(stable-)?[0-9]+\.[0-9]+(\.[0-9]+)?\b' || true; } |
|
||||
sed -E 's|^port[-:/ ]+||I' | sed -E 's,^stable-,,I')
|
||||
targets=$(printf '%s\n%s\n' "$t1" "$t2" | sed '/^$/d' | sort -u)
|
||||
for ver in $targets; do
|
||||
echo "::group::port $sha -> stable-$ver"
|
||||
bash scripts/port.sh "stable-$ver" "$sha"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
done
|
||||
|
||||
- name: Port label-flagged PR
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
(github.event.action == 'closed' ||
|
||||
(github.event.action == 'labeled' && startsWith(github.event.label.name, 'port ')))
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
|
||||
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
PORT_SOURCE_PR: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "dms-ci[bot]"
|
||||
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||
|
||||
targets=$(jq -r '.[] | select(startswith("port ")) | sub("^port +"; "")' <<<"$LABELS" |
|
||||
sed -E 's,^stable-,,' | sort -u)
|
||||
[ -n "$targets" ] || { echo "no port labels, nothing to do"; exit 0; }
|
||||
[ -n "$MERGE_SHA" ] || { echo "::error::PR has no merge commit sha"; exit 1; }
|
||||
|
||||
for ver in $targets; do
|
||||
echo "::group::port PR #${PORT_SOURCE_PR} ($MERGE_SHA) -> stable-$ver"
|
||||
bash scripts/port.sh "stable-$ver" "$MERGE_SHA"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
@@ -3,7 +3,7 @@ name: Pre-commit Checks
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches: [master, main, "stable-*"]
|
||||
branches: [master, main]
|
||||
jobs:
|
||||
pre-commit-check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -7,16 +7,10 @@ on:
|
||||
description: "Tag to release (e.g., v1.0.1)"
|
||||
required: true
|
||||
type: string
|
||||
notify_issues:
|
||||
description: "Comment on open related/fixes issues asking reporters to retest"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ inputs.tag }}
|
||||
@@ -211,23 +205,13 @@ jobs:
|
||||
|
||||
- name: Generate Changelog
|
||||
id: changelog
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -e
|
||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "")
|
||||
CHANGELOG=""
|
||||
if [ -n "$PREVIOUS_TAG" ]; then
|
||||
# PR-based notes with author credits; falls back to raw git log below
|
||||
CHANGELOG=$(python3 scripts/release-notes.py "${PREVIOUS_TAG}..${TAG}" --format github --bare 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$CHANGELOG" ]; then
|
||||
echo "release-notes.py unavailable or empty, using git log fallback"
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /' | head -50)
|
||||
else
|
||||
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" "${PREVIOUS_TAG}..${TAG}" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /')
|
||||
fi
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /' | head -50)
|
||||
else
|
||||
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" "${PREVIOUS_TAG}..${TAG}" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /')
|
||||
fi
|
||||
|
||||
cat > RELEASE_BODY.md << 'EOF'
|
||||
@@ -433,19 +417,3 @@ jobs:
|
||||
prerelease: ${{ contains(env.TAG, '-') }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Ask reporters on still-open "related/fixes #N" issues to retest.
|
||||
# Opt-in via notify_issues (Point release defaults it on). Skipped for
|
||||
# prereleases. Preview: scripts/notify-issues.sh <prev> <tag> --dry-run
|
||||
- name: Notify open issues to retest
|
||||
if: ${{ inputs.notify_issues && !contains(env.TAG, '-') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
echo "no previous tag; skipping issue notify"
|
||||
exit 0
|
||||
fi
|
||||
bash scripts/notify-issues.sh "$PREVIOUS_TAG" "$TAG"
|
||||
|
||||
@@ -24,7 +24,6 @@ jobs:
|
||||
check-updates:
|
||||
name: Check for updates
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'AvengeMedia/DankMaterialShell'
|
||||
|
||||
outputs:
|
||||
has_updates: ${{ steps.check.outputs.has_updates }}
|
||||
|
||||
@@ -24,7 +24,6 @@ jobs:
|
||||
check-updates:
|
||||
name: Check package/series updates
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'AvengeMedia/DankMaterialShell'
|
||||
|
||||
outputs:
|
||||
has_updates: ${{ steps.check.outputs.has_updates }}
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
name: Void Linux XBPS Repository
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
|
||||
# release trigger disabled; dispatch manually after a release
|
||||
# release:
|
||||
# types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Stable release tag/version to build (e.g. v1.5.0 or 1.5.0). Leave blank to use the selected ref/template version'
|
||||
type: string
|
||||
required: false
|
||||
force_rebuild:
|
||||
description: 'Force rebuilding packages even if they already exist in the repository'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
build_git:
|
||||
description: 'Build dms-git package'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
build_dms:
|
||||
description: 'Build stable dms package'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
build_greeter:
|
||||
description: 'Build stable dms-greeter package'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: void-xbps-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
name: Check for unpublished changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_build: ${{ steps.check.outputs.should_build }}
|
||||
steps:
|
||||
- name: Check published commit
|
||||
id: check
|
||||
env:
|
||||
MANIFEST_URL: https://void.danklinux.com/dms/current/manifest.json
|
||||
run: |
|
||||
SHOULD_BUILD=true
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
PUBLISHED_COMMIT="$(curl -fsSL --retry 3 "$MANIFEST_URL" | jq -r '.source_commit // empty' 2>/dev/null || true)"
|
||||
if [ "$PUBLISHED_COMMIT" = "${{ github.sha }}" ]; then
|
||||
SHOULD_BUILD=false
|
||||
echo "${{ github.sha }} is already published; skipping the scheduled build."
|
||||
fi
|
||||
fi
|
||||
echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-and-deploy:
|
||||
name: Build & Deploy XBPS packages
|
||||
runs-on: ubuntu-latest
|
||||
needs: preflight
|
||||
if: github.repository == 'AvengeMedia/DankMaterialShell' && needs.preflight.outputs.should_build == 'true'
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
AWS_EC2_METADATA_DISABLED: true
|
||||
R2_BUCKET: danklinux-void
|
||||
R2_ENDPOINT: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com
|
||||
R2_PREFIX: dms
|
||||
REPOSITORY_DIR: ${{ github.workspace }}/r2-repo
|
||||
SOURCE_COMMIT: ${{ github.sha }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name || (github.event.inputs.version && (startsWith(github.event.inputs.version, 'v') && github.event.inputs.version || format('v{0}', github.event.inputs.version))) || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Record checked-out source commit
|
||||
run: echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download current R2 repository
|
||||
run: |
|
||||
aws --version
|
||||
bash .github/scripts/publish-void-r2.sh download
|
||||
|
||||
- name: Install XBPS Static Utilities
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/xbps
|
||||
curl -s -L https://repo-default.voidlinux.org/static/xbps-static-latest.x86_64-musl.tar.xz | tar -xJ -C ${{ github.workspace }}/xbps
|
||||
echo "${{ github.workspace }}/xbps/usr/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Clone void-packages
|
||||
run: |
|
||||
git clone --depth=1 https://github.com/void-linux/void-packages.git
|
||||
|
||||
- name: Inject templates
|
||||
run: |
|
||||
cp -R distro/void/srcpkgs/dms void-packages/srcpkgs/
|
||||
cp -R distro/void/srcpkgs/dms-greeter void-packages/srcpkgs/
|
||||
cp -R distro/void/srcpkgs/dms-git void-packages/srcpkgs/
|
||||
|
||||
- name: Enable unprivileged user namespaces (Ubuntu 24.04)
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
|
||||
|
||||
- name: Bootstrap xbps-src
|
||||
run: |
|
||||
cd void-packages
|
||||
./xbps-src binary-bootstrap
|
||||
|
||||
- name: Configure signing keys and trust
|
||||
run: |
|
||||
# Write private key; extract and register the public key
|
||||
echo "${{ secrets.XBPS_PRIVATE_KEY }}" > /tmp/xbps_privkey.pem
|
||||
chmod 600 /tmp/xbps_privkey.pem
|
||||
|
||||
# Extract public key in PEM format
|
||||
openssl rsa -in /tmp/xbps_privkey.pem -pubout -out /tmp/dms-key.pub
|
||||
rm -f /tmp/xbps_privkey.pem
|
||||
|
||||
# Compute MD5 fingerprint in colon-separated hex format
|
||||
FINGERPRINT=$(openssl rsa -pubin -in /tmp/dms-key.pub -outform DER 2>/dev/null | openssl dgst -md5 -c | tr '[:upper:]' '[:lower:]' | awk '{print $NF}')
|
||||
|
||||
# Format key in XML property list (plist) format as expected by xbps
|
||||
mkdir -p /tmp/keys
|
||||
cat <<EOF > "/tmp/keys/${FINGERPRINT}.plist"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>public-key</key>
|
||||
<data>$(base64 -w0 /tmp/dms-key.pub)</data>
|
||||
<key>public-key-size</key>
|
||||
<integer>4096</integer>
|
||||
<key>signature-by</key>
|
||||
<string>AvengeMedia</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
# Copy keys to all host and chroot trust stores
|
||||
sudo mkdir -p /var/db/xbps/keys
|
||||
sudo cp "/tmp/keys/${FINGERPRINT}.plist" "/var/db/xbps/keys/${FINGERPRINT}.plist"
|
||||
|
||||
mkdir -p void-packages/masterdir/var/db/xbps/keys
|
||||
cp "/tmp/keys/${FINGERPRINT}.plist" "void-packages/masterdir/var/db/xbps/keys/${FINGERPRINT}.plist"
|
||||
|
||||
mkdir -p void-packages/etc/xbps.d/keys
|
||||
cp "/tmp/keys/${FINGERPRINT}.plist" "void-packages/etc/xbps.d/keys/${FINGERPRINT}.plist"
|
||||
|
||||
mkdir -p void-packages/common/repo-keys
|
||||
cp "/tmp/keys/${FINGERPRINT}.plist" "void-packages/common/repo-keys/${FINGERPRINT}.plist"
|
||||
|
||||
rm -rf /tmp/keys /tmp/dms-key.pub
|
||||
|
||||
- name: Configure repositories
|
||||
run: |
|
||||
# Append the repository to repos-remote templates so xbps-src translates it automatically
|
||||
echo "repository=https://void.danklinux.com/danklinux/current" >> void-packages/etc/xbps.d/repos-remote.conf
|
||||
echo "repository=https://void.danklinux.com/danklinux/current" >> void-packages/etc/xbps.d/repos-remote-x86_64-multilib.conf
|
||||
|
||||
# Add any existing compiled packages to the build cache directory to avoid rebuilds
|
||||
if [ -d "r2-repo/current" ]; then
|
||||
mkdir -p void-packages/hostdir/binpkgs
|
||||
cp -L r2-repo/current/*.xbps void-packages/hostdir/binpkgs/ 2>/dev/null || true
|
||||
xbps-rindex -a void-packages/hostdir/binpkgs/*.xbps 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Build packages
|
||||
run: |
|
||||
mkdir -p r2-repo/current
|
||||
|
||||
BUILD_DMS="${{ github.event.inputs.build_dms || 'true' }}"
|
||||
BUILD_GREETER="${{ github.event.inputs.build_greeter || 'true' }}"
|
||||
BUILD_GIT="${{ github.event.inputs.build_git || 'true' }}"
|
||||
FORCE_REBUILD="${{ github.event.inputs.force_rebuild || 'false' }}"
|
||||
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
BUILD_DMS="false"
|
||||
BUILD_GREETER="false"
|
||||
BUILD_GIT="true"
|
||||
fi
|
||||
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
BUILD_DMS="true"
|
||||
BUILD_GREETER="true"
|
||||
BUILD_GIT="false"
|
||||
fi
|
||||
|
||||
RELEASE_TAG=""
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
RELEASE_TAG="${{ github.event.release.tag_name }}"
|
||||
elif [ -n "${{ github.event.inputs.version || '' }}" ]; then
|
||||
RELEASE_TAG="${{ github.event.inputs.version }}"
|
||||
elif [ "${{ github.ref_type }}" = "tag" ]; then
|
||||
RELEASE_TAG="${{ github.ref_name }}"
|
||||
fi
|
||||
|
||||
if [ -n "$RELEASE_TAG" ]; then
|
||||
RELEASE_VER="${RELEASE_TAG#v}"
|
||||
ARCHIVE_TAG="v${RELEASE_VER}"
|
||||
echo "Stable release tag: $ARCHIVE_TAG"
|
||||
else
|
||||
RELEASE_VER=""
|
||||
ARCHIVE_TAG=""
|
||||
echo "Stable release tag: (template version)"
|
||||
fi
|
||||
|
||||
echo "=== Starting Builds ==="
|
||||
echo "DMS stable build enabled: $BUILD_DMS"
|
||||
echo "Greeter stable build enabled: $BUILD_GREETER"
|
||||
echo "Git build enabled: $BUILD_GIT"
|
||||
echo "Force rebuild: $FORCE_REBUILD"
|
||||
|
||||
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)
|
||||
if [ "$BUILD_GIT" = "true" ]; then
|
||||
# Calculate dynamic git version (tag.commits.hash)
|
||||
GIT_VER=$(git -C .. describe --tags --always | sed 's/^v//; s/-/./g')
|
||||
echo "🔨 Preparing dms-git version $GIT_VER"
|
||||
|
||||
# Stage source tarball in the xbps-src source cache.
|
||||
# --transform adds a top-level directory so xbps-src can extract
|
||||
# into $wrksrc (create_wrksrc=yes handles the rest).
|
||||
SRC_CACHE="hostdir/sources/dms-git-${GIT_VER}"
|
||||
mkdir -p "$SRC_CACHE"
|
||||
tar -czf "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" \
|
||||
--exclude=void-packages \
|
||||
--exclude=r2-repo \
|
||||
--exclude=.git \
|
||||
--exclude=danklinux \
|
||||
-C .. .
|
||||
|
||||
CHECKSUM=$(sha256sum "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" | cut -d' ' -f1)
|
||||
|
||||
# Dynamically patch template version, checksum, and distfiles
|
||||
sed -i "s/^version=.*/version=${GIT_VER}/" srcpkgs/dms-git/template
|
||||
sed -i "s/^checksum=.*/checksum=${CHECKSUM}/" srcpkgs/dms-git/template
|
||||
sed -i "s|^distfiles=.*|distfiles=\"dms-git-${GIT_VER}.tar.gz\"|" srcpkgs/dms-git/template
|
||||
|
||||
EXPECTED_GIT_FILE="dms-git-${GIT_VER}_1.x86_64.xbps"
|
||||
|
||||
if [ -f "../r2-repo/current/$EXPECTED_GIT_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
|
||||
echo "✅ $EXPECTED_GIT_FILE already exists, skipping build."
|
||||
else
|
||||
echo "🔨 Compiling dms-git..."
|
||||
./xbps-src pkg dms-git
|
||||
rm -f "../r2-repo/current/${EXPECTED_GIT_FILE}"
|
||||
cp -L "hostdir/binpkgs/${EXPECTED_GIT_FILE}" ../r2-repo/current/
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Build stable dms package
|
||||
if [ "$BUILD_DMS" = "true" ]; then
|
||||
STABLE_VER=$(grep -E '^version=' srcpkgs/dms/template | cut -d= -f2 | tr -d '"')
|
||||
STABLE_REV=$(grep -E '^revision=' srcpkgs/dms/template | cut -d= -f2 | tr -d '"')
|
||||
|
||||
EXPECTED_DMS_FILE="dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps"
|
||||
if [ -f "../r2-repo/current/$EXPECTED_DMS_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
|
||||
echo "✅ $EXPECTED_DMS_FILE already exists, skipping build."
|
||||
else
|
||||
echo "🔨 Compiling dms ($STABLE_VER)..."
|
||||
./xbps-src pkg dms
|
||||
rm -f "../r2-repo/current/${EXPECTED_DMS_FILE}"
|
||||
cp -L hostdir/binpkgs/dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps ../r2-repo/current/
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. Build stable dms-greeter package
|
||||
if [ "$BUILD_GREETER" = "true" ]; then
|
||||
GREETER_VER=$(grep -E '^version=' srcpkgs/dms-greeter/template | cut -d= -f2 | tr -d '"')
|
||||
GREETER_REV=$(grep -E '^revision=' srcpkgs/dms-greeter/template | cut -d= -f2 | tr -d '"')
|
||||
|
||||
EXPECTED_GREETER_FILE="dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps"
|
||||
if [ -f "../r2-repo/current/$EXPECTED_GREETER_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
|
||||
echo "✅ $EXPECTED_GREETER_FILE already exists, skipping build."
|
||||
else
|
||||
echo "🔨 Compiling dms-greeter ($GREETER_VER)..."
|
||||
./xbps-src pkg dms-greeter
|
||||
rm -f "../r2-repo/current/${EXPECTED_GREETER_FILE}"
|
||||
cp -L hostdir/binpkgs/dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps ../r2-repo/current/
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Index and sign repository
|
||||
run: |
|
||||
cd r2-repo/current
|
||||
|
||||
rm -f *.sig
|
||||
|
||||
# Guard: nothing to index if no .xbps files exist
|
||||
if ! ls *.xbps 1>/dev/null 2>&1; then
|
||||
echo "⚠️ No .xbps files found to index, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Regenerate repo index
|
||||
xbps-rindex -a $(pwd)/*.xbps
|
||||
xbps-rindex --remove-obsoletes $(pwd)
|
||||
|
||||
# Remove signatures left behind by obsolete packages. Signatures for
|
||||
# unchanged immutable packages are retained byte-for-byte.
|
||||
for sig in *.sig2; do
|
||||
[ -e "$sig" ] || break
|
||||
[ -f "${sig%.sig2}" ] || rm -f "$sig"
|
||||
done
|
||||
|
||||
# Sign repository
|
||||
echo "${{ secrets.XBPS_PRIVATE_KEY }}" > /tmp/xbps_privkey.pem
|
||||
chmod 600 /tmp/xbps_privkey.pem
|
||||
|
||||
xbps-rindex --sign --signedby "AvengeMedia <AvengeMedia.US@gmail.com>" --privkey /tmp/xbps_privkey.pem $(pwd)
|
||||
xbps-rindex --sign-pkg --privkey /tmp/xbps_privkey.pem $(pwd)/*.xbps
|
||||
|
||||
rm -f /tmp/xbps_privkey.pem
|
||||
|
||||
- name: Publish repository to R2
|
||||
run: bash .github/scripts/publish-void-r2.sh publish
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
steps:
|
||||
- name: Create GitHub App token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v3
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
@@ -110,9 +110,6 @@ bin/
|
||||
# Core dumps
|
||||
core.*
|
||||
|
||||
# prek-installed local git hooks (generated from .pre-commit-config.yaml)
|
||||
.githooks/
|
||||
|
||||
# direnv
|
||||
.envrc
|
||||
.direnv/
|
||||
@@ -126,6 +123,3 @@ __pycache__
|
||||
distro/void/temp/
|
||||
distro/void/hostdir/
|
||||
distro/void/masterdir*/
|
||||
|
||||
# Often gets built
|
||||
core/dms
|
||||
|
||||
@@ -28,14 +28,6 @@ repos:
|
||||
language: system
|
||||
files: ^quickshell/(Modules/Settings/.*\.qml|Modals/Settings/SettingsSidebar\.qml|translations/extract_settings_index\.py)$
|
||||
pass_filenames: false
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: i18n-term-freeze
|
||||
name: i18n term freeze (no new I18n.tr/qsTr terms)
|
||||
entry: python3 quickshell/translations/check_term_freeze.py
|
||||
language: system
|
||||
files: ^quickshell/(.*\.qml|translations/(term_freeze\.json|check_term_freeze\.py|extract_translations\.py))$
|
||||
pass_filenames: false
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: no-console-in-qml
|
||||
|
||||
@@ -6,8 +6,8 @@ BINARY_NAME=dms
|
||||
CORE_DIR=core
|
||||
BUILD_DIR=$(CORE_DIR)/bin
|
||||
PREFIX ?= /usr/local
|
||||
INSTALL_DIR=$(DESTDIR)$(PREFIX)/bin
|
||||
DATA_DIR=$(DESTDIR)$(PREFIX)/share
|
||||
INSTALL_DIR=$(PREFIX)/bin
|
||||
DATA_DIR=$(PREFIX)/share
|
||||
ICON_DIR=$(DATA_DIR)/icons/hicolor/scalable/apps
|
||||
|
||||
USER_HOME := $(if $(SUDO_USER),$(shell getent passwd $(SUDO_USER) | cut -d: -f6),$(HOME))
|
||||
@@ -62,7 +62,7 @@ install-systemd:
|
||||
@echo "Installing systemd user service..."
|
||||
@mkdir -p $(SYSTEMD_USER_DIR)
|
||||
@if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR); fi
|
||||
@sed 's|/usr/bin/dms|$(PREFIX)/bin/dms|g' $(ASSETS_DIR)/systemd/dms.service > $(SYSTEMD_USER_DIR)/dms.service
|
||||
@sed 's|/usr/bin/dms|$(INSTALL_DIR)/dms|g' $(ASSETS_DIR)/systemd/dms.service > $(SYSTEMD_USER_DIR)/dms.service
|
||||
@chmod 644 $(SYSTEMD_USER_DIR)/dms.service
|
||||
@if [ -n "$(SUDO_USER)" ]; then chown $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR)/dms.service; fi
|
||||
@echo "Systemd service installed to $(SYSTEMD_USER_DIR)/dms.service"
|
||||
@@ -77,7 +77,6 @@ install-desktop:
|
||||
@echo "Installing desktop entries..."
|
||||
@install -D -m 644 $(ASSETS_DIR)/dms-open.desktop $(APPLICATIONS_DIR)/dms-open.desktop
|
||||
@install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
|
||||
@install -D -m 644 $(ASSETS_DIR)/com.danklinux.dms.notepad.desktop $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
|
||||
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
|
||||
@echo "Desktop entries installed"
|
||||
|
||||
@@ -121,7 +120,6 @@ uninstall-desktop:
|
||||
@echo "Removing desktop entries..."
|
||||
@rm -f $(APPLICATIONS_DIR)/dms-open.desktop
|
||||
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.desktop
|
||||
@rm -f $(APPLICATIONS_DIR)/com.danklinux.dms.notepad.desktop
|
||||
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
|
||||
@echo "Desktop entries removed"
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=DMS Notepad
|
||||
GenericName=Text Editor
|
||||
Comment=Quick notes and text files in the dank desktop shell
|
||||
Exec=dms ipc call notepad openFile %f
|
||||
Icon=danklogo
|
||||
Terminal=false
|
||||
NoDisplay=true
|
||||
Categories=Utility;TextEditor;
|
||||
MimeType=text/plain;text/markdown;
|
||||
StartupNotify=false
|
||||
+3
-6
@@ -10,7 +10,7 @@ Go-based backend for DankMaterialShell providing system integration, IPC, and in
|
||||
Command-line interface and daemon for shell management and system control.
|
||||
|
||||
**dankinstall**
|
||||
Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo, and Void. Supports both an interactive TUI and a headless (unattended) mode via CLI flags.
|
||||
Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo. Supports both an interactive TUI and a headless (unattended) mode via CLI flags.
|
||||
|
||||
## System Integration
|
||||
|
||||
@@ -193,7 +193,7 @@ Set the `DANKINSTALL_LOG_DIR` environment variable to override the log directory
|
||||
|
||||
## Supported Distributions
|
||||
|
||||
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo, Void (and derivatives)
|
||||
Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives)
|
||||
|
||||
**Arch Linux**
|
||||
Uses `pacman` for system packages, builds AUR packages via `makepkg`, no AUR helper dependency.
|
||||
@@ -206,7 +206,7 @@ Uses COPR repositories (`avengemedia/danklinux`, `avengemedia/dms`).
|
||||
Requires PPA support. Most packages built from source (slow first install).
|
||||
|
||||
**Debian**
|
||||
Debian 13+ (Trixie), testing, and sid. Stable quickshell from Debian (trixie-backports on 13). Other companions via OBS.
|
||||
Debian 13+ (Trixie). niri only, no Hyprland support. Builds from source.
|
||||
|
||||
**openSUSE**
|
||||
Most packages available in standard repos. Minimal building required.
|
||||
@@ -214,7 +214,4 @@ Most packages available in standard repos. Minimal building required.
|
||||
**Gentoo**
|
||||
Uses Portage with GURU overlay. Automatically configures USE flags. Variable success depending on system configuration.
|
||||
|
||||
**Void Linux**
|
||||
Uses XBPS with the DMS and DankLinux self-hosted repositories.
|
||||
|
||||
See installer output for distribution-specific details during installation.
|
||||
|
||||
@@ -23,8 +23,6 @@ var (
|
||||
replaceConfigs []string
|
||||
replaceConfigsAll bool
|
||||
yes bool
|
||||
danksearch bool
|
||||
dankcalendar bool
|
||||
)
|
||||
|
||||
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().BoolVar(&replaceConfigsAll, "replace-configs-all", false, "Deploy and replace all configurations")
|
||||
rootCmd.Flags().BoolVarP(&yes, "yes", "y", false, "Auto-confirm all prompts")
|
||||
rootCmd.Flags().BoolVar(&danksearch, "danksearch", false, "Install danksearch and enable its user indexing service")
|
||||
rootCmd.Flags().BoolVar(&dankcalendar, "dankcalendar", false, "Install dankcalendar")
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -78,8 +74,6 @@ func runDankinstall(cmd *cobra.Command, args []string) error {
|
||||
"replace-configs",
|
||||
"replace-configs-all",
|
||||
"yes",
|
||||
"danksearch",
|
||||
"dankcalendar",
|
||||
}
|
||||
var set []string
|
||||
for _, name := range headlessOnly {
|
||||
@@ -115,8 +109,6 @@ func runHeadless() error {
|
||||
ReplaceConfigs: replaceConfigs,
|
||||
ReplaceConfigsAll: replaceConfigsAll,
|
||||
Yes: yes,
|
||||
DankSearch: danksearch,
|
||||
DankCalendar: dankcalendar,
|
||||
}
|
||||
|
||||
runner := headless.NewRunner(cfg)
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
@@ -38,142 +35,9 @@ var authSyncCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var authResolveLockCmd = &cobra.Command{
|
||||
Use: "resolve-lock",
|
||||
Short: "Generate the lock-screen PAM config from the system auth stack",
|
||||
Long: "Resolve the distribution's PAM auth stack into a self-contained lock-screen config under the user state directory.\n" +
|
||||
"Runs unprivileged (reads /etc/pam.d, writes to the user's state dir) and is used by the shell as a fallback when /etc/pam.d/dankshell is not managed.\n" +
|
||||
"Prints the path of the generated file.",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
logFunc := func(msg string) {
|
||||
if !quiet {
|
||||
fmt.Println(msg)
|
||||
}
|
||||
}
|
||||
path, err := sharedpam.WriteUserLockscreenPamConfig(logFunc)
|
||||
if err != nil {
|
||||
log.Fatalf("Error resolving lock-screen PAM config: %v", err)
|
||||
}
|
||||
fmt.Println(path)
|
||||
},
|
||||
}
|
||||
|
||||
var authListServicesCmd = &cobra.Command{
|
||||
Use: "list-services",
|
||||
Short: "List candidate lock-screen PAM services available on this system",
|
||||
Long: "Enumerate the lock-screen PAM services that exist on this system and report their resolved auth stack (whether it has an auth directive and whether fingerprint/U2F modules appear inline).",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
asJSON, _ := cmd.Flags().GetBool("json")
|
||||
services := sharedpam.ListLockscreenPamServices()
|
||||
|
||||
if asJSON {
|
||||
payload := struct {
|
||||
Services []sharedpam.LockscreenPamServiceInfo `json:"services"`
|
||||
}{Services: services}
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("Error encoding services: %v", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
return
|
||||
}
|
||||
|
||||
if len(services) == 0 {
|
||||
fmt.Println("No candidate lock-screen PAM services found.")
|
||||
return
|
||||
}
|
||||
for _, s := range services {
|
||||
fmt.Printf("%-20s %-30s auth=%-5t fingerprint=%-5t u2f=%t\n", s.Name, s.Path, s.HasAuth, s.InlineFingerprint, s.InlineU2f)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var authValidateCmd = &cobra.Command{
|
||||
Use: "validate",
|
||||
Short: "Validate a PAM service file for use by the DMS lock screen",
|
||||
Long: "Validate one PAM service (by --service NAME or --path /abs/file) for use as the DMS lock-screen password or dedicated U2F stack. Exits 1 when the file is not usable.",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
path, _ := cmd.Flags().GetString("path")
|
||||
service, _ := cmd.Flags().GetString("service")
|
||||
purpose, _ := cmd.Flags().GetString("purpose")
|
||||
asJSON, _ := cmd.Flags().GetBool("json")
|
||||
|
||||
if (path == "") == (service == "") {
|
||||
log.Fatalf("Error: exactly one of --path or --service is required")
|
||||
}
|
||||
|
||||
if purpose != "password" && purpose != "u2f" {
|
||||
log.Fatalf("Error: --purpose must be password or u2f")
|
||||
}
|
||||
|
||||
var result sharedpam.LockscreenPamValidation
|
||||
switch {
|
||||
case service != "":
|
||||
if purpose == "u2f" {
|
||||
result = sharedpam.ValidateLockscreenU2fPamService(service)
|
||||
} else {
|
||||
result = sharedpam.ValidateLockscreenPamService(service)
|
||||
}
|
||||
case !filepath.IsAbs(path):
|
||||
result = sharedpam.LockscreenPamValidation{
|
||||
Path: path,
|
||||
MissingModules: []string{},
|
||||
Warnings: []string{},
|
||||
Errors: []string{"--path must be an absolute file path"},
|
||||
}
|
||||
default:
|
||||
if purpose == "u2f" {
|
||||
result = sharedpam.ValidateLockscreenU2fPamPath(path)
|
||||
} else {
|
||||
result = sharedpam.ValidateLockscreenPamPath(path)
|
||||
}
|
||||
}
|
||||
|
||||
if asJSON {
|
||||
data, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("Error encoding validation: %v", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
} else {
|
||||
printLockscreenPamValidation(result)
|
||||
}
|
||||
|
||||
if !result.Valid {
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func printLockscreenPamValidation(result sharedpam.LockscreenPamValidation) {
|
||||
fmt.Printf("Path: %s\n", result.Path)
|
||||
fmt.Printf("Valid: %t\n", result.Valid)
|
||||
fmt.Printf("Has auth: %t\n", result.HasAuth)
|
||||
fmt.Printf("Inline fingerprint: %t\n", result.InlineFingerprint)
|
||||
fmt.Printf("Inline U2F: %t\n", result.InlineU2f)
|
||||
if len(result.MissingModules) > 0 {
|
||||
fmt.Printf("Missing modules: %s\n", strings.Join(result.MissingModules, ", "))
|
||||
}
|
||||
for _, w := range result.Warnings {
|
||||
fmt.Println("⚠ " + w)
|
||||
}
|
||||
for _, e := range result.Errors {
|
||||
fmt.Println("✗ " + e)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts")
|
||||
authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)")
|
||||
authResolveLockCmd.Flags().BoolP("quiet", "q", false, "Only print the resulting file path")
|
||||
|
||||
authListServicesCmd.Flags().Bool("json", false, "Output as JSON")
|
||||
|
||||
authValidateCmd.Flags().String("path", "", "Absolute path to a PAM service file to validate")
|
||||
authValidateCmd.Flags().String("service", "", "Name of a PAM service to resolve across the system PAM dirs")
|
||||
authValidateCmd.Flags().String("purpose", "password", "Validation purpose: password or u2f")
|
||||
authValidateCmd.Flags().Bool("json", false, "Output as JSON")
|
||||
}
|
||||
|
||||
func syncAuth(nonInteractive bool) error {
|
||||
|
||||
@@ -63,15 +63,6 @@ var clipPasteCmd = &cobra.Command{
|
||||
Run: runClipPaste,
|
||||
}
|
||||
|
||||
var clipSendPasteCmd = &cobra.Command{
|
||||
Use: "send-paste",
|
||||
Short: "Send a paste keystroke to the focused window",
|
||||
Long: "Emulate ctrl+v (or ctrl+shift+v with --shift) via a virtual keyboard. Works without server.",
|
||||
Run: runClipSendPaste,
|
||||
}
|
||||
|
||||
var clipSendPasteShift bool
|
||||
|
||||
var clipWatchCmd = &cobra.Command{
|
||||
Use: "watch [command]",
|
||||
Short: "Watch clipboard for changes",
|
||||
@@ -225,10 +216,8 @@ func init() {
|
||||
|
||||
clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration")
|
||||
|
||||
clipSendPasteCmd.Flags().BoolVarP(&clipSendPasteShift, "shift", "s", false, "Send ctrl+shift+v (terminal paste)")
|
||||
|
||||
clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd)
|
||||
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipSendPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd)
|
||||
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd)
|
||||
}
|
||||
|
||||
func runClipCopy(cmd *cobra.Command, args []string) {
|
||||
@@ -325,12 +314,6 @@ func runClipPaste(cmd *cobra.Command, args []string) {
|
||||
os.Stdout.Write(data)
|
||||
}
|
||||
|
||||
func runClipSendPaste(cmd *cobra.Command, args []string) {
|
||||
if err := clipboard.SendPasteKeystroke(clipSendPasteShift); err != nil {
|
||||
log.Fatalf("send-paste: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runClipWatch(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
@@ -106,8 +106,6 @@ func init() {
|
||||
ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
printIPCHelp()
|
||||
})
|
||||
pluginsUpdateCmd.Flags().BoolP("all", "a", false, "Update all installed plugins")
|
||||
pluginsUpdateCmd.Flags().Bool("check", false, "Check for available updates without applying them")
|
||||
}
|
||||
|
||||
var debugSrvCmd = &cobra.Command{
|
||||
@@ -186,22 +184,10 @@ var pluginsUninstallCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
var pluginsUpdateCmd = &cobra.Command{
|
||||
Use: "update [plugin-id]",
|
||||
Short: "Update a plugin by ID, or all plugins",
|
||||
Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). If --all or -a is specified, all installed plugins will be updated.",
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
updateAll, _ := cmd.Flags().GetBool("all")
|
||||
if updateAll {
|
||||
if len(args) > 0 {
|
||||
return fmt.Errorf("cannot specify plugin ID when using --all/-a")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("requires exactly 1 arg (plugin ID) or use --all/-a")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Use: "update <plugin-id>",
|
||||
Short: "Update a plugin by ID",
|
||||
Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). Plugin names are also supported.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) != 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
@@ -209,26 +195,6 @@ var pluginsUpdateCmd = &cobra.Command{
|
||||
return getInstalledPluginIDs(), cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
checkOnly, _ := cmd.Flags().GetBool("check")
|
||||
updateAll, _ := cmd.Flags().GetBool("all")
|
||||
if checkOnly {
|
||||
if updateAll {
|
||||
if err := checkAllPluginsCLI(); err != nil {
|
||||
log.Fatalf("Error checking updates: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := checkPluginCLI(args[0]); err != nil {
|
||||
log.Fatalf("Error checking update: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if updateAll {
|
||||
if err := updateAllPluginsCLI(); err != nil {
|
||||
log.Fatalf("Error updating plugins: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := updatePluginCLI(args[0]); err != nil {
|
||||
log.Fatalf("Error updating plugin: %v", err)
|
||||
}
|
||||
@@ -404,11 +370,7 @@ func listInstalledPlugins() error {
|
||||
fmt.Printf("\nInstalled Plugins (%d):\n\n", len(installedNames))
|
||||
for _, id := range installedNames {
|
||||
if plugin, ok := pluginMap[id]; ok {
|
||||
hasUpdateStr := ""
|
||||
if hasUpdates, _, err := manager.HasUpdates(id, plugin); err == nil && hasUpdates {
|
||||
hasUpdateStr = " (update available)"
|
||||
}
|
||||
fmt.Printf(" %s%s\n", plugin.Name, hasUpdateStr)
|
||||
fmt.Printf(" %s\n", plugin.Name)
|
||||
fmt.Printf(" ID: %s\n", plugin.ID)
|
||||
fmt.Printf(" Category: %s\n", plugin.Category)
|
||||
fmt.Printf(" Author: %s\n", plugin.Author)
|
||||
@@ -588,160 +550,6 @@ func updatePluginCLI(idOrName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateAllPluginsCLI() error {
|
||||
manager, err := plugins.NewManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create manager: %w", err)
|
||||
}
|
||||
|
||||
registry, err := plugins.NewRegistry()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create registry: %w", err)
|
||||
}
|
||||
|
||||
installed, err := manager.ListInstalled()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list installed plugins: %w", err)
|
||||
}
|
||||
|
||||
pluginList, _ := registry.List()
|
||||
|
||||
var errs []error
|
||||
for _, pluginID := range installed {
|
||||
plugin := plugins.FindByIDOrName(pluginID, pluginList)
|
||||
if plugin != nil {
|
||||
fmt.Printf("Updating plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
|
||||
if err := manager.Update(*plugin); err != nil {
|
||||
if strings.Contains(err.Error(), "cannot update system plugin") {
|
||||
fmt.Printf("Skipping system plugin: %s\n", plugin.Name)
|
||||
} else {
|
||||
errs = append(errs, fmt.Errorf("failed to update %s: %w", plugin.Name, err))
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Plugin updated successfully: %s\n", plugin.Name)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Updating plugin: %s\n", pluginID)
|
||||
if err := manager.UpdateByIDOrName(pluginID); err != nil {
|
||||
if strings.Contains(err.Error(), "cannot update system plugin") {
|
||||
fmt.Printf("Skipping system plugin: %s\n", pluginID)
|
||||
} else {
|
||||
errs = append(errs, fmt.Errorf("failed to update %s: %w", pluginID, err))
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Plugin updated successfully: %s\n", pluginID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
for _, err := range errs {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
}
|
||||
return fmt.Errorf("failed to update some plugins")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkPluginCLI(idOrName string) error {
|
||||
manager, err := plugins.NewManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create manager: %w", err)
|
||||
}
|
||||
|
||||
registry, err := plugins.NewRegistry()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create registry: %w", err)
|
||||
}
|
||||
|
||||
pluginList, _ := registry.List()
|
||||
plugin := plugins.FindByIDOrName(idOrName, pluginList)
|
||||
|
||||
if plugin != nil {
|
||||
installed, err := manager.IsInstalled(*plugin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check install status: %w", err)
|
||||
}
|
||||
if !installed {
|
||||
return fmt.Errorf("plugin not installed: %s", plugin.Name)
|
||||
}
|
||||
|
||||
hasUpdates, _, err := manager.HasUpdates(plugin.ID, *plugin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check updates: %w", err)
|
||||
}
|
||||
|
||||
if hasUpdates {
|
||||
fmt.Printf("Update available for plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
|
||||
} else {
|
||||
fmt.Printf("Plugin is up to date: %s\n", plugin.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
dummyPlugin := plugins.Plugin{ID: idOrName}
|
||||
hasUpdates, _, err := manager.HasUpdates(idOrName, dummyPlugin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check updates: %w", err)
|
||||
}
|
||||
|
||||
if hasUpdates {
|
||||
fmt.Printf("Update available for plugin: %s\n", idOrName)
|
||||
} else {
|
||||
fmt.Printf("Plugin is up to date: %s\n", idOrName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkAllPluginsCLI() error {
|
||||
manager, err := plugins.NewManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create manager: %w", err)
|
||||
}
|
||||
|
||||
registry, err := plugins.NewRegistry()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create registry: %w", err)
|
||||
}
|
||||
|
||||
installed, err := manager.ListInstalled()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list installed plugins: %w", err)
|
||||
}
|
||||
|
||||
pluginList, _ := registry.List()
|
||||
|
||||
var count int
|
||||
for _, pluginID := range installed {
|
||||
plugin := plugins.FindByIDOrName(pluginID, pluginList)
|
||||
var hasUpdates bool
|
||||
var name string
|
||||
|
||||
if plugin != nil {
|
||||
name = plugin.Name
|
||||
hasUpdates, _, _ = manager.HasUpdates(pluginID, *plugin)
|
||||
} else {
|
||||
name = pluginID
|
||||
dummyPlugin := plugins.Plugin{ID: pluginID}
|
||||
hasUpdates, _, _ = manager.HasUpdates(pluginID, dummyPlugin)
|
||||
}
|
||||
|
||||
if hasUpdates {
|
||||
fmt.Printf("Update available for plugin: %s (ID: %s)\n", name, pluginID)
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
fmt.Printf("\nFound %d plugin(s) with available updates.\n", count)
|
||||
} else {
|
||||
fmt.Println("All plugins are up to date.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getCommonCommands() []*cobra.Command {
|
||||
return []*cobra.Command{
|
||||
versionCmd,
|
||||
@@ -759,7 +567,6 @@ func getCommonCommands() []*cobra.Command {
|
||||
greeterCmd,
|
||||
setupCmd,
|
||||
colorCmd,
|
||||
qrCmd,
|
||||
screenshotCmd,
|
||||
notifyActionCmd,
|
||||
notifyCmd,
|
||||
|
||||
@@ -37,11 +37,9 @@ var resolveIncludeCmd = &cobra.Command{
|
||||
"cursor.lua",
|
||||
"windowrules.lua",
|
||||
"cursor.kdl",
|
||||
"layout.kdl",
|
||||
"outputs.kdl",
|
||||
"binds.kdl",
|
||||
"cursor.conf",
|
||||
"layout.conf",
|
||||
"outputs.conf",
|
||||
"binds.conf",
|
||||
}, cobra.ShellCompDirectiveNoFileComp
|
||||
|
||||
@@ -320,7 +320,6 @@ func checkEnvironmentVars() []checkResult {
|
||||
var results []checkResult
|
||||
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME")...)
|
||||
results = append(results, checkEnvVar("QS_ICON_THEME")...)
|
||||
results = append(results, checkXDGMenuPrefix()...)
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -335,29 +334,6 @@ func checkEnvVar(name string) []checkResult {
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkXDGMenuPrefix() []checkResult {
|
||||
menuPrefix := os.Getenv("XDG_MENU_PREFIX")
|
||||
if menuPrefix != "" {
|
||||
if checkXDGMenuFile(menuPrefix) {
|
||||
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusInfo, menuPrefix, "", doctorDocsURL + "#xdg-menu-prefix"}}
|
||||
}
|
||||
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusWarn, fmt.Sprintf("%s (menu file not found)", menuPrefix), fmt.Sprintf("Dolphin 'Open with…' dialog may be empty. Ensure /etc/xdg/menus/%sapplications.menu exists.", menuPrefix), doctorDocsURL + "#xdg-menu-prefix"}}
|
||||
}
|
||||
if _, err := exec.LookPath("keditfiletype"); err == nil {
|
||||
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusWarn, "Not set", "Dolphin file associations and 'Open with…' dialog may be empty. Set XDG_MENU_PREFIX=plasma- in your compositor's environment block.", doctorDocsURL + "#xdg-menu-prefix"}}
|
||||
}
|
||||
if doctorVerbose {
|
||||
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusInfo, "Not set", "", doctorDocsURL + "#xdg-menu-prefix"}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkXDGMenuFile(prefix string) bool {
|
||||
menuPath := fmt.Sprintf("/etc/xdg/menus/%sapplications.menu", prefix)
|
||||
_, err := os.Stat(menuPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func readOSRelease() map[string]string {
|
||||
result := make(map[string]string)
|
||||
data, err := os.ReadFile("/etc/os-release")
|
||||
@@ -910,7 +886,6 @@ func checkOptionalDependencies() []checkResult {
|
||||
{"cava", "cava", "Audio visualizer", true},
|
||||
{"khal", "khal", "Calendar events", false},
|
||||
{"danksearch", "dsearch", "File search", false},
|
||||
{"dankcalendar", "dcal", "Calendar app", false},
|
||||
{"fprintd", "fprintd-list", "Fingerprint auth", false},
|
||||
}
|
||||
|
||||
|
||||
@@ -32,14 +32,13 @@ var greeterCmd = &cobra.Command{
|
||||
var (
|
||||
greeterConfigSyncFn = greeter.SyncDMSConfigs
|
||||
sharedAuthSyncFn = sharedpam.SyncAuthConfig
|
||||
greeterIsNixOSFn = greeter.IsNixOS
|
||||
)
|
||||
|
||||
var greeterInstallCmd = &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Install and configure DMS greeter",
|
||||
Long: "Install greetd and configure it to use DMS as the greeter interface",
|
||||
PreRunE: preRunGreeterMutation,
|
||||
PreRunE: preRunPrivileged,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
term, _ := cmd.Flags().GetBool("terminal")
|
||||
@@ -65,9 +64,6 @@ var greeterSyncCmd = &cobra.Command{
|
||||
Short: "Sync DMS theme and settings with greeter",
|
||||
Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen. Also updates a per-user cache slot at users/<username>/ for multi-account greeter theme preview.\n\nUse --profile on secondary accounts to sync only your own users/<username>/ slot without sudo or greetd changes.",
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := rejectNixOSGreeterMutation(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
profile, _ := cmd.Flags().GetBool("profile")
|
||||
if profile {
|
||||
return nil
|
||||
@@ -144,7 +140,7 @@ var greeterEnableCmd = &cobra.Command{
|
||||
Use: "enable",
|
||||
Short: "Enable DMS greeter in greetd config",
|
||||
Long: "Configure greetd to use DMS as the greeter",
|
||||
PreRunE: preRunGreeterMutation,
|
||||
PreRunE: preRunPrivileged,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
term, _ := cmd.Flags().GetBool("terminal")
|
||||
@@ -180,7 +176,7 @@ var greeterUninstallCmd = &cobra.Command{
|
||||
Use: "uninstall",
|
||||
Short: "Remove DMS greeter configuration and restore previous display manager",
|
||||
Long: "Disable greetd, remove DMS managed configs, and restore the system to its pre-DMS-greeter state",
|
||||
PreRunE: preRunGreeterMutation,
|
||||
PreRunE: preRunPrivileged,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
term, _ := cmd.Flags().GetBool("terminal")
|
||||
@@ -210,21 +206,6 @@ func init() {
|
||||
greeterUninstallCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)")
|
||||
}
|
||||
|
||||
func rejectNixOSGreeterMutation(cmd *cobra.Command) error {
|
||||
if !greeterIsNixOSFn() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("dms %s is disabled on NixOS because the greeter is managed declaratively\nConfigure the DMS greeter in your NixOS module, then apply the change with your normal nixos-rebuild workflow", normalizeCommandSpec(cmd.CommandPath()))
|
||||
}
|
||||
|
||||
func preRunGreeterMutation(cmd *cobra.Command, args []string) error {
|
||||
if err := rejectNixOSGreeterMutation(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
return preRunPrivileged(cmd, args)
|
||||
}
|
||||
|
||||
func syncGreeterConfigsAndAuth(dmsPath, compositor string, logFunc func(string), options sharedpam.SyncAuthOptions, beforeAuth func()) error {
|
||||
if err := greeterConfigSyncFn(dmsPath, compositor, logFunc, ""); err != nil {
|
||||
return err
|
||||
@@ -1081,11 +1062,8 @@ func ensureGreetdEnabled() error {
|
||||
if !runitServiceInstalled("greetd") {
|
||||
return fmt.Errorf("greetd service not found in %s. Please install greetd first", runitSvDir)
|
||||
}
|
||||
if greeter.IsVoidLinux() {
|
||||
ensureVoidLogindGreeter("_greeter")
|
||||
} else {
|
||||
ensureRunitSeat("_greeter")
|
||||
}
|
||||
// Seat + runtime-dir setup that logind handles automatically on systemd.
|
||||
ensureRunitSeat("_greeter")
|
||||
ensureGreetdPamRundir()
|
||||
if err := enableRunitService("greetd"); err != nil {
|
||||
return fmt.Errorf("failed to enable greetd: %w", err)
|
||||
@@ -1264,9 +1242,6 @@ func enableGreeter(nonInteractive bool) error {
|
||||
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)
|
||||
}
|
||||
if err := greeter.EnsureVoidLogindGreetdCommand(logFunc, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ensureGraphicalTarget(); err != nil {
|
||||
return err
|
||||
@@ -1440,36 +1415,24 @@ func readDefaultSessionCommand(configPath string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func explicitGreeterCacheDirFromCommand(command string) (string, bool) {
|
||||
func extractGreeterCacheDirFromCommand(command string) string {
|
||||
if command == "" {
|
||||
return greeter.GreeterCacheDir
|
||||
}
|
||||
tokens := strings.Fields(command)
|
||||
for i := 0; i < len(tokens); i++ {
|
||||
token := strings.Trim(tokens[i], "\"")
|
||||
if token == "--cache-dir" && i+1 < len(tokens) {
|
||||
value := strings.Trim(tokens[i+1], "\"")
|
||||
if value != "" {
|
||||
return value, true
|
||||
}
|
||||
return strings.Trim(tokens[i+1], "\"")
|
||||
}
|
||||
if strings.HasPrefix(token, "--cache-dir=") {
|
||||
value := strings.TrimPrefix(token, "--cache-dir=")
|
||||
value = strings.Trim(value, "\"")
|
||||
if value != "" {
|
||||
return value, true
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
const nixOSGreeterStateDir = "/var/lib/dms-greeter"
|
||||
|
||||
func greeterStatusStateDir(command string, isNixOS bool) string {
|
||||
if cacheDir, ok := explicitGreeterCacheDirFromCommand(command); ok {
|
||||
return cacheDir
|
||||
}
|
||||
if isNixOS {
|
||||
return nixOSGreeterStateDir
|
||||
}
|
||||
return greeter.GreeterCacheDir
|
||||
}
|
||||
|
||||
@@ -1481,23 +1444,12 @@ func extractGreeterWrapperFromCommand(command string) string {
|
||||
if len(tokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
wrapperIndex := 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], "\"")
|
||||
wrapper := strings.Trim(tokens[0], "\"")
|
||||
if wrapper == "" {
|
||||
return ""
|
||||
}
|
||||
if wrapperIndex+1 < len(tokens) {
|
||||
next := strings.Trim(tokens[wrapperIndex+1], "\"")
|
||||
if len(tokens) > 1 {
|
||||
next := strings.Trim(tokens[1], "\"")
|
||||
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)
|
||||
}
|
||||
@@ -1551,8 +1503,6 @@ func packageInstallHint() string {
|
||||
return "Install with 'sudo dnf install dms-greeter' (requires COPR: sudo dnf copr enable avengemedia/danklinux)"
|
||||
case distros.FamilyArch:
|
||||
return "Install from AUR with 'paru -S greetd-dms-greeter-git' or 'yay -S greetd-dms-greeter-git'"
|
||||
case distros.FamilyVoid:
|
||||
return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=https://void.danklinux.com/dms/current' | sudo tee /etc/xbps.d/dms.conf)"
|
||||
default:
|
||||
return "Run 'dms greeter install' to install greeter"
|
||||
}
|
||||
@@ -1591,8 +1541,7 @@ func isPackageOnlyGreeterDistro() bool {
|
||||
config.Family == distros.FamilySUSE ||
|
||||
config.Family == distros.FamilyUbuntu ||
|
||||
config.Family == distros.FamilyFedora ||
|
||||
config.Family == distros.FamilyArch ||
|
||||
config.Family == distros.FamilyVoid
|
||||
config.Family == distros.FamilyArch
|
||||
}
|
||||
|
||||
func promptCompositorChoice(compositors []string) (string, error) {
|
||||
@@ -1620,10 +1569,6 @@ func checkGreeterStatus() error {
|
||||
fmt.Println("=== DMS Greeter Status ===")
|
||||
fmt.Println()
|
||||
|
||||
if greeterIsNixOSFn() {
|
||||
return checkNixOSGreeterStatus()
|
||||
}
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||
@@ -1687,7 +1632,7 @@ func checkGreeterStatus() error {
|
||||
fmt.Println(" Run 'dms greeter sync' to set up group membership and permissions")
|
||||
}
|
||||
|
||||
cacheDir := greeterStatusStateDir(configuredCommand, false)
|
||||
cacheDir := extractGreeterCacheDirFromCommand(configuredCommand)
|
||||
fmt.Println("\nGreeter Cache Directory:")
|
||||
fmt.Printf(" Effective cache dir: %s\n", cacheDir)
|
||||
if cacheDir != greeter.GreeterCacheDir {
|
||||
@@ -1950,85 +1895,6 @@ func checkGreeterStatus() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkNixOSGreeterStatus() error {
|
||||
const configPath = "/etc/greetd/config.toml"
|
||||
|
||||
configuredCommand := readDefaultSessionCommand(configPath)
|
||||
allGood := true
|
||||
|
||||
fmt.Println("Greeter Configuration:")
|
||||
switch {
|
||||
case strings.Contains(configuredCommand, "dms-greeter"):
|
||||
fmt.Println(" ✓ DMS greeter command found")
|
||||
if wrapper := extractGreeterWrapperFromCommand(configuredCommand); wrapper != "" {
|
||||
fmt.Printf(" Wrapper: %s\n", wrapper)
|
||||
}
|
||||
case configuredCommand != "":
|
||||
fmt.Println(" ⚠ greetd default session does not reference dms-greeter")
|
||||
allGood = false
|
||||
default:
|
||||
fmt.Printf(" ℹ No readable DMS command found in %s\n", configPath)
|
||||
}
|
||||
fmt.Println(" ℹ NixOS manages greeter configuration declaratively; apply changes through your NixOS module.")
|
||||
|
||||
stateDir := greeterStatusStateDir(configuredCommand, true)
|
||||
fmt.Println("\nGreeter State Directory:")
|
||||
fmt.Printf(" Effective state dir: %s\n", stateDir)
|
||||
if stateDir == nixOSGreeterStateDir {
|
||||
fmt.Println(" ✓ Using the NixOS module state path")
|
||||
}
|
||||
if stat, err := os.Stat(stateDir); err == nil && stat.IsDir() {
|
||||
fmt.Printf(" ✓ %s exists\n", stateDir)
|
||||
} else if os.IsNotExist(err) {
|
||||
fmt.Printf(" ✗ %s not found\n", stateDir)
|
||||
fmt.Println(" Rebuild your NixOS configuration after enabling the DMS greeter module.")
|
||||
allGood = false
|
||||
} else if err != nil {
|
||||
fmt.Printf(" ✗ Could not inspect %s: %v\n", stateDir, err)
|
||||
allGood = false
|
||||
} else {
|
||||
fmt.Printf(" ✗ %s is not a directory\n", stateDir)
|
||||
allGood = false
|
||||
}
|
||||
|
||||
fmt.Println("\nDeclarative Configuration Files:")
|
||||
configFiles := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "Settings", path: filepath.Join(stateDir, "settings.json")},
|
||||
{name: "Session state", path: filepath.Join(stateDir, "session.json")},
|
||||
{name: "Color theme", path: filepath.Join(stateDir, "colors.json")},
|
||||
}
|
||||
for _, configFile := range configFiles {
|
||||
if stat, err := os.Stat(configFile.path); err == nil && !stat.IsDir() {
|
||||
fmt.Printf(" ✓ %s: %s\n", configFile.name, configFile.path)
|
||||
} else if os.IsNotExist(err) {
|
||||
fmt.Printf(" ℹ %s not present (optional; configure configHome/configFiles in the NixOS module)\n", configFile.name)
|
||||
} else if err != nil {
|
||||
fmt.Printf(" ⚠ %s could not be inspected: %v\n", configFile.name, err)
|
||||
} else {
|
||||
fmt.Printf(" ⚠ %s path is not a regular file: %s\n", configFile.name, configFile.path)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\nGroup Membership:")
|
||||
fmt.Println(" ℹ User group membership is managed by NixOS and is not required for declarative theme copies.")
|
||||
|
||||
fmt.Println("\nGreeter PAM Authentication:")
|
||||
fmt.Println(" ℹ PAM is managed by NixOS modules.")
|
||||
fmt.Println(" Configure fingerprint/U2F through security.pam.services.greetd.")
|
||||
|
||||
fmt.Println()
|
||||
if allGood {
|
||||
fmt.Println("✓ NixOS greeter state looks healthy and is managed declaratively.")
|
||||
} else {
|
||||
fmt.Println("⚠ Some issues detected. Update the DMS greeter module and rebuild NixOS; do not run 'dms greeter sync'.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func recentAppArmorGreeterDenials(sampleLimit int) (int, []string, error) {
|
||||
if sampleLimit <= 0 {
|
||||
sampleLimit = 3
|
||||
|
||||
@@ -3,11 +3,9 @@ package main
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestSyncGreeterConfigsAndAuthDelegatesSharedAuth(t *testing.T) {
|
||||
@@ -87,64 +85,3 @@ func TestSyncGreeterConfigsAndAuthStopsOnConfigError(t *testing.T) {
|
||||
t.Fatal("expected auth sync not to run after config sync failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGreeterStatusStateDirUsesNixOSDefault(t *testing.T) {
|
||||
if got := greeterStatusStateDir("", true); got != nixOSGreeterStateDir {
|
||||
t.Fatalf("greeterStatusStateDir() = %q, want %q", got, nixOSGreeterStateDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGreeterStatusStateDirHonorsExplicitOverrideOnNixOS(t *testing.T) {
|
||||
command := "dms-greeter --cache-dir /srv/dms-greeter --command niri"
|
||||
if got := greeterStatusStateDir(command, true); got != "/srv/dms-greeter" {
|
||||
t.Fatalf("greeterStatusStateDir() = %q, want %q", got, "/srv/dms-greeter")
|
||||
}
|
||||
}
|
||||
|
||||
func 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) {
|
||||
origGreeterIsNixOSFn := greeterIsNixOSFn
|
||||
greeterIsNixOSFn = func() bool { return true }
|
||||
t.Cleanup(func() {
|
||||
greeterIsNixOSFn = origGreeterIsNixOSFn
|
||||
})
|
||||
|
||||
for _, commandName := range []string{"install", "enable", "sync", "uninstall"} {
|
||||
t.Run(commandName, func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dms"}
|
||||
greeterCommand := &cobra.Command{Use: "greeter"}
|
||||
mutationCommand := &cobra.Command{Use: commandName}
|
||||
root.AddCommand(greeterCommand)
|
||||
greeterCommand.AddCommand(mutationCommand)
|
||||
|
||||
err := rejectNixOSGreeterMutation(mutationCommand)
|
||||
if err == nil {
|
||||
t.Fatalf("expected NixOS greeter %s to be rejected", commandName)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "dms greeter "+commandName+" is disabled on NixOS") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "/var/cache/dms-greeter") {
|
||||
t.Fatalf("NixOS remediation should not recommend the non-NixOS cache path: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectNixOSGreeterMutationAllowsOtherDistros(t *testing.T) {
|
||||
origGreeterIsNixOSFn := greeterIsNixOSFn
|
||||
greeterIsNixOSFn = func() bool { return false }
|
||||
t.Cleanup(func() {
|
||||
greeterIsNixOSFn = origGreeterIsNixOSFn
|
||||
})
|
||||
|
||||
if err := rejectNixOSGreeterMutation(&cobra.Command{Use: "sync"}); err != nil {
|
||||
t.Fatalf("expected non-NixOS greeter command to be allowed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,17 +38,10 @@ var matugenCheckCmd = &cobra.Command{
|
||||
Run: runMatugenCheck,
|
||||
}
|
||||
|
||||
var matugenPreviewCmd = &cobra.Command{
|
||||
Use: "preview",
|
||||
Short: "Preview Matugen scheme colors without applying them",
|
||||
Run: runMatugenPreview,
|
||||
}
|
||||
|
||||
func init() {
|
||||
matugenCmd.AddCommand(matugenGenerateCmd)
|
||||
matugenCmd.AddCommand(matugenQueueCmd)
|
||||
matugenCmd.AddCommand(matugenCheckCmd)
|
||||
matugenCmd.AddCommand(matugenPreviewCmd)
|
||||
|
||||
for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} {
|
||||
cmd.Flags().String("state-dir", "", "State directory for cache files")
|
||||
@@ -69,8 +62,6 @@ func init() {
|
||||
|
||||
matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion")
|
||||
matugenQueueCmd.Flags().Duration("timeout", 90*time.Second, "Timeout for waiting")
|
||||
matugenPreviewCmd.Flags().String("source-color", "", "Source color used to generate previews")
|
||||
matugenPreviewCmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
|
||||
}
|
||||
|
||||
func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
|
||||
@@ -209,17 +200,3 @@ func runMatugenCheck(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
|
||||
func runMatugenPreview(cmd *cobra.Command, args []string) {
|
||||
sourceColor, _ := cmd.Flags().GetString("source-color")
|
||||
contrast, _ := cmd.Flags().GetFloat64("contrast")
|
||||
previews, err := matugen.PreviewSchemes(sourceColor, contrast)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to generate Matugen previews: %v", err)
|
||||
}
|
||||
data, err := json.Marshal(previews)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to marshal Matugen previews: %v", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -28,19 +27,8 @@ var (
|
||||
ssNoConfirm bool
|
||||
ssReset bool
|
||||
ssStdout bool
|
||||
ssJSON bool
|
||||
)
|
||||
|
||||
type screenshotMetadata struct {
|
||||
Status string `json:"status"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Scale float64 `json:"scale,omitempty"`
|
||||
Mime string `json:"mime,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
var screenshotCmd = &cobra.Command{
|
||||
Use: "screenshot",
|
||||
Short: "Capture screenshots",
|
||||
@@ -51,9 +39,8 @@ Modes:
|
||||
full - Capture the focused output
|
||||
all - Capture all outputs combined
|
||||
output - Capture a specific output by name
|
||||
window - Capture the focused window (Hyprland/Mango/niri)
|
||||
window - Capture the focused window (Hyprland/Mango)
|
||||
last - Capture the last selected region
|
||||
scroll - Select a region, then scroll to capture a stitched tall image
|
||||
|
||||
Output format (--format):
|
||||
png - PNG format (default)
|
||||
@@ -72,10 +59,7 @@ Examples:
|
||||
dms screenshot --no-file # Clipboard only
|
||||
dms screenshot --no-confirm # Region capture on mouse release
|
||||
dms screenshot --cursor=on # Include cursor
|
||||
dms screenshot -f jpg -q 85 # JPEG with quality 85
|
||||
dms screenshot --json # Print capture metadata as JSON
|
||||
dms screenshot scroll # Scroll capture, Enter finishes / Esc cancels
|
||||
dms screenshot scroll --interval 250`,
|
||||
dms screenshot -f jpg -q 85 # JPEG with quality 85`,
|
||||
}
|
||||
|
||||
var ssRegionCmd = &cobra.Command{
|
||||
@@ -113,33 +97,10 @@ If no previous region exists, falls back to interactive selection.`,
|
||||
var ssWindowCmd = &cobra.Command{
|
||||
Use: "window",
|
||||
Short: "Capture the focused window",
|
||||
Long: `Capture the currently focused window. Supported on Hyprland, Mango, and niri.`,
|
||||
Long: `Capture the currently focused window. Supported on Hyprland and Mango.`,
|
||||
Run: runScreenshotWindow,
|
||||
}
|
||||
|
||||
var ssScrollInterval int
|
||||
|
||||
var ssScrollCmd = &cobra.Command{
|
||||
Use: "scroll",
|
||||
Short: "Capture a scrolling region stitched into one tall image",
|
||||
Long: `Select a region, then scroll the content beneath with the mouse wheel or
|
||||
touchpad while frames are captured and stitched vertically. Finish with the
|
||||
on-screen done button; cancel with the cancel button. Enter and Esc work
|
||||
everywhere: most compositors hold the keyboard on the overlay (keyboard
|
||||
scrolling does not reach the app there), while Hyprland leaves the keyboard
|
||||
with the application — keyboard scrolling works, and Enter/Esc act through
|
||||
temporary global binds for the session. The cursor is never included in
|
||||
frames.
|
||||
|
||||
Frames are stitched continuously while scrolling, and revisited content is
|
||||
never duplicated — scrolling up past the starting point extends the image
|
||||
upward. Content jumped past faster than capture can follow is skipped rather
|
||||
than stitched incorrectly.
|
||||
|
||||
Rotated outputs are not supported.`,
|
||||
Run: runScreenshotScroll,
|
||||
}
|
||||
|
||||
var ssListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List available outputs",
|
||||
@@ -167,12 +128,8 @@ func init() {
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssNoConfirm, "no-confirm", false, "Region mode: capture on mouse release without Enter/Space confirmation")
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssReset, "reset", false, "Reset saved last-region preselection before capturing")
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
|
||||
screenshotCmd.PersistentFlags().BoolVar(&ssJSON, "json", false, "Print capture metadata as JSON")
|
||||
|
||||
ssScrollCmd.Flags().IntVar(&ssScrollInterval, "interval", 45, "Capture interval in milliseconds (30-1000)")
|
||||
|
||||
screenshotCmd.AddCommand(ssRegionCmd)
|
||||
screenshotCmd.AddCommand(ssScrollCmd)
|
||||
screenshotCmd.AddCommand(ssFullCmd)
|
||||
screenshotCmd.AddCommand(ssAllCmd)
|
||||
screenshotCmd.AddCommand(ssOutputCmd)
|
||||
@@ -246,39 +203,10 @@ func setPopoutScreenshotMode(begin bool) {
|
||||
_ = exec.Command("qs", cmdArgs...).Run()
|
||||
}
|
||||
|
||||
func writeScreenshotJSON(meta screenshotMetadata) {
|
||||
_ = json.NewEncoder(os.Stdout).Encode(meta)
|
||||
}
|
||||
|
||||
func exitScreenshotError(context string, err error) {
|
||||
if ssJSON {
|
||||
writeScreenshotJSON(screenshotMetadata{Status: "error", Error: err.Error()})
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Error%s: %v\n", context, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func formatMime(format screenshot.Format) string {
|
||||
switch format {
|
||||
case screenshot.FormatJPEG:
|
||||
return "image/jpeg"
|
||||
case screenshot.FormatPPM:
|
||||
return "image/x-portable-pixmap"
|
||||
default:
|
||||
return "image/png"
|
||||
}
|
||||
}
|
||||
|
||||
func runScreenshot(config screenshot.Config) {
|
||||
if ssJSON && config.Stdout {
|
||||
fmt.Fprintln(os.Stderr, "Error: --json cannot be combined with --stdout")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Region select needs the keyboard; drop popout grabs for its duration.
|
||||
result, err := func() (*screenshot.CaptureResult, error) {
|
||||
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion || config.Mode == screenshot.ModeScroll
|
||||
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion
|
||||
if interactive {
|
||||
setPopoutScreenshotMode(true)
|
||||
defer setPopoutScreenshotMode(false)
|
||||
@@ -287,13 +215,11 @@ func runScreenshot(config screenshot.Config) {
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
exitScreenshotError("", err)
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
if ssJSON {
|
||||
writeScreenshotJSON(screenshotMetadata{Status: "aborted", Error: "User cancelled selection"})
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
@@ -305,7 +231,8 @@ func runScreenshot(config screenshot.Config) {
|
||||
|
||||
if config.Stdout {
|
||||
if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
|
||||
exitScreenshotError(" writing to stdout", err)
|
||||
fmt.Fprintf(os.Stderr, "Error writing to stdout: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -325,37 +252,22 @@ func runScreenshot(config screenshot.Config) {
|
||||
|
||||
filePath = filepath.Join(outputDir, filename)
|
||||
if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format); err != nil {
|
||||
exitScreenshotError(" writing file", err)
|
||||
}
|
||||
if !ssJSON {
|
||||
fmt.Println(filePath)
|
||||
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(filePath)
|
||||
}
|
||||
|
||||
if config.Clipboard {
|
||||
if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
|
||||
exitScreenshotError(" copying to clipboard", err)
|
||||
fmt.Fprintf(os.Stderr, "Error copying to clipboard: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if !ssJSON && !config.SaveFile {
|
||||
if !config.SaveFile {
|
||||
fmt.Println("Copied to clipboard")
|
||||
}
|
||||
}
|
||||
|
||||
if ssJSON {
|
||||
scale := result.Scale
|
||||
if scale <= 0 {
|
||||
scale = 1.0
|
||||
}
|
||||
writeScreenshotJSON(screenshotMetadata{
|
||||
Status: "success",
|
||||
Path: filePath,
|
||||
Width: result.Buffer.Width,
|
||||
Height: result.Buffer.Height,
|
||||
Scale: scale,
|
||||
Mime: formatMime(config.Format),
|
||||
})
|
||||
}
|
||||
|
||||
if config.Notify {
|
||||
thumbData, thumbW, thumbH := bufferToRGBThumbnail(result.Buffer, 256, result.Format)
|
||||
screenshot.SendNotification(screenshot.NotifyResult{
|
||||
@@ -466,12 +378,6 @@ func runScreenshotRegion(cmd *cobra.Command, args []string) {
|
||||
runScreenshot(config)
|
||||
}
|
||||
|
||||
func runScreenshotScroll(cmd *cobra.Command, args []string) {
|
||||
config := getScreenshotConfig(screenshot.ModeScroll)
|
||||
config.IntervalMs = min(max(ssScrollInterval, 30), 1000)
|
||||
runScreenshot(config)
|
||||
}
|
||||
|
||||
func runScreenshotFull(cmd *cobra.Command, args []string) {
|
||||
config := getScreenshotConfig(screenshot.ModeFullScreen)
|
||||
runScreenshot(config)
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
@@ -299,9 +298,6 @@ func runSetup() error {
|
||||
if wmSelected {
|
||||
if wm == deps.WindowManagerMango {
|
||||
useSystemd = false
|
||||
} else if isVoidSetup() {
|
||||
useSystemd = false
|
||||
fmt.Println("\nVoid Linux detected; deploying non-systemd session config.")
|
||||
} else {
|
||||
useSystemd = promptSystemd()
|
||||
}
|
||||
@@ -376,15 +372,6 @@ func runSetup() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func isVoidSetup() bool {
|
||||
osInfo, err := distros.GetOSInfo()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
config, exists := distros.Registry[osInfo.Distribution.ID]
|
||||
return exists && config.Family == distros.FamilyVoid
|
||||
}
|
||||
|
||||
// Add user to the input group for the evdev manager for inut state tracking.
|
||||
// Caps Lock OSD and the Caps Lock bar indicator.
|
||||
func ensureInputGroup() {
|
||||
|
||||
@@ -47,7 +47,6 @@ var (
|
||||
sysUpdateJSON bool
|
||||
sysUpdateNoFlatpak bool
|
||||
sysUpdateNoAUR bool
|
||||
sysUpdateIgnore []string
|
||||
sysUpdateIntervalS int
|
||||
sysUpdateListPmTime = 5 * time.Minute
|
||||
)
|
||||
@@ -59,7 +58,6 @@ func init() {
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
|
||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
|
||||
systemUpdateCmd.Flags().StringSliceVar(&sysUpdateIgnore, "ignore", nil, "Skip specific packages (repeatable or comma-separated)")
|
||||
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
|
||||
|
||||
systemCmd.AddCommand(systemUpdateCmd)
|
||||
@@ -194,7 +192,6 @@ func runSystemUpdateApply() {
|
||||
Targets: pkgs,
|
||||
IncludeFlatpak: !sysUpdateNoFlatpak,
|
||||
IncludeAUR: !sysUpdateNoAUR,
|
||||
Ignored: sysUpdateIgnore,
|
||||
DryRun: sysUpdateDry,
|
||||
UseSudo: true,
|
||||
}
|
||||
@@ -237,19 +234,12 @@ func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupd
|
||||
}
|
||||
|
||||
func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
|
||||
if !sysUpdateNoAUR && len(sysUpdateIgnore) == 0 {
|
||||
if !sysUpdateNoAUR {
|
||||
return pkgs
|
||||
}
|
||||
ignored := make(map[string]bool, len(sysUpdateIgnore))
|
||||
for _, name := range sysUpdateIgnore {
|
||||
ignored[name] = true
|
||||
}
|
||||
out := pkgs[:0]
|
||||
for _, p := range pkgs {
|
||||
if sysUpdateNoAUR && p.Repo == sysupdate.RepoAUR {
|
||||
continue
|
||||
}
|
||||
if ignored[p.Name] {
|
||||
if p.Repo == sysupdate.RepoAUR {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultImmutablePolicyAllowsSyncButBlocksEnable(t *testing.T) {
|
||||
var policyFile cliPolicyFile
|
||||
if err := json.Unmarshal(defaultCLIPolicyJSON, &policyFile); err != nil {
|
||||
t.Fatalf("failed to parse embedded CLI policy: %v", err)
|
||||
}
|
||||
if policyFile.BlockedCommands == nil {
|
||||
t.Fatal("embedded CLI policy has no blocked_commands")
|
||||
}
|
||||
|
||||
blocked := normalizeBlockedCommands(*policyFile.BlockedCommands)
|
||||
if !commandBlockedByPolicy("greeter enable", blocked) {
|
||||
t.Fatal("expected greeter enable to remain blocked on immutable/image-based systems")
|
||||
}
|
||||
if commandBlockedByPolicy("greeter sync", blocked) {
|
||||
t.Fatal("expected greeter sync to remain available on immutable/image-based systems")
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
)
|
||||
|
||||
@@ -84,31 +83,9 @@ func ensureRunitSeat(greeterUser string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ensureVoidLogindGreeter configures the elogind-backed greeter on Void.
|
||||
func ensureVoidLogindGreeter(greeterUser string) {
|
||||
for _, service := range []string{"dbus", "elogind"} {
|
||||
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.
|
||||
// ensureGreetdPamRundir adds pam_rundir to the greetd PAM stack so the post-login
|
||||
// session gets an XDG_RUNTIME_DIR on systems without logind (Void with seatd).
|
||||
// Appended outside DMS's managed auth block so it survives `dms greeter sync`.
|
||||
func ensureGreetdPamRundir() {
|
||||
const pamPath = "/etc/pam.d/greetd"
|
||||
data, err := os.ReadFile(pamPath)
|
||||
|
||||
@@ -20,7 +20,7 @@ func init() {
|
||||
runCmd.Flags().MarkHidden("daemon-child")
|
||||
|
||||
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)
|
||||
updateCmd.AddCommand(updateCheckCmd)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
|
||||
@@ -20,7 +20,7 @@ func init() {
|
||||
runCmd.Flags().MarkHidden("daemon-child")
|
||||
|
||||
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)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
rootCmd.AddCommand(getCommonCommands()...)
|
||||
|
||||
+4
-19
@@ -92,14 +92,6 @@ func appendLogEnv(env []string) []string {
|
||||
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 {
|
||||
_, err := exec.LookPath("systemd-run")
|
||||
return err == nil
|
||||
@@ -215,7 +207,7 @@ func runShellInteractive(session bool) {
|
||||
log.Infof("Spawning quickshell with -p %s", 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 qtRules := log.GetQtLoggingRules(); 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)
|
||||
|
||||
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 qtRules := log.GetQtLoggingRules(); qtRules != "" {
|
||||
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
|
||||
@@ -590,11 +582,7 @@ func runShellDaemon(session bool) {
|
||||
}
|
||||
|
||||
var qsHasAnyDisplay = sync.OnceValue(func() bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "qs", "ipc", "--help")
|
||||
cmd.WaitDelay = 500 * time.Millisecond
|
||||
out, err := cmd.Output()
|
||||
out, err := exec.Command("qs", "ipc", "--help").Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -654,10 +642,7 @@ func getShellIPCCompletions(args []string, _ string) []string {
|
||||
return nil
|
||||
}
|
||||
cmdArgs := append(baseArgs, "show")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "qs", cmdArgs...)
|
||||
cmd.WaitDelay = 500 * time.Millisecond
|
||||
cmd := exec.Command("qs", cmdArgs...)
|
||||
var targets ipcTargets
|
||||
|
||||
if output, err := cmd.Output(); err == nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"syscall"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
const envServe = "_DMS_CLIPBOARD_SERVE"
|
||||
@@ -16,37 +17,6 @@ const envMime = "_DMS_CLIPBOARD_MIME"
|
||||
const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE"
|
||||
const envCacheFile = "_DMS_CLIPBOARD_CACHE"
|
||||
|
||||
type Offer struct {
|
||||
MimeType string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// textMimeAliases are offered alongside plain-text content so legacy X11
|
||||
// clients bridged through XWayland find a target they can convert.
|
||||
var textMimeAliases = []string{
|
||||
"text/plain",
|
||||
"text/plain;charset=utf-8",
|
||||
"UTF8_STRING",
|
||||
"STRING",
|
||||
"TEXT",
|
||||
}
|
||||
|
||||
// ExpandOffers turns raw clipboard data into the full offer list to serve,
|
||||
// adding the standard alias set for text content.
|
||||
func ExpandOffers(data []byte, mimeType string) []Offer {
|
||||
offers := []Offer{{MimeType: mimeType, Data: data}}
|
||||
if mimeType != "text/plain" && mimeType != "text/plain;charset=utf-8" {
|
||||
return offers
|
||||
}
|
||||
for _, alias := range textMimeAliases {
|
||||
if alias == mimeType {
|
||||
continue
|
||||
}
|
||||
offers = append(offers, Offer{MimeType: alias, Data: data})
|
||||
}
|
||||
return offers
|
||||
}
|
||||
|
||||
// MaybeServeAndExit intercepts before cobra when re-exec'd as a clipboard
|
||||
// child. Reads source data into memory, deletes any cache file, then serves.
|
||||
func MaybeServeAndExit() {
|
||||
@@ -74,7 +44,7 @@ func MaybeServeAndExit() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := serveOffers(ExpandOffers(data, mimeType), pasteOnce); err != nil {
|
||||
if err := serveClipboard(data, mimeType, pasteOnce); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -85,33 +55,22 @@ func Copy(data []byte, mimeType string) error {
|
||||
return copyForkCached(data, mimeType, false)
|
||||
}
|
||||
|
||||
func CopyText(text string) error {
|
||||
return Copy([]byte(text), "text/plain;charset=utf-8")
|
||||
}
|
||||
|
||||
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
|
||||
if foreground {
|
||||
return serveOffers(ExpandOffers(data, mimeType), pasteOnce)
|
||||
return serveClipboard(data, mimeType, pasteOnce)
|
||||
}
|
||||
return copyForkCached(data, mimeType, pasteOnce)
|
||||
}
|
||||
|
||||
func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error {
|
||||
if !foreground {
|
||||
return copyFork(data, mimeType, pasteOnce)
|
||||
}
|
||||
buf, err := io.ReadAll(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read source: %w", err)
|
||||
}
|
||||
return serveOffers(ExpandOffers(buf, mimeType), pasteOnce)
|
||||
}
|
||||
|
||||
func CopyMulti(offers []Offer, foreground, pasteOnce bool) error {
|
||||
if foreground {
|
||||
return serveOffers(offers, pasteOnce)
|
||||
buf, err := io.ReadAll(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read source: %w", err)
|
||||
}
|
||||
return serveClipboard(buf, mimeType, pasteOnce)
|
||||
}
|
||||
return copyMultiFork(offers, pasteOnce)
|
||||
return copyFork(data, mimeType, pasteOnce)
|
||||
}
|
||||
|
||||
func newForkCmd(mimeType string, pasteOnce bool, extra ...string) *exec.Cmd {
|
||||
@@ -173,70 +132,39 @@ func copyForkCached(data []byte, mimeType string, pasteOnce bool) error {
|
||||
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
|
||||
cmd := newForkCmd(mimeType, pasteOnce)
|
||||
|
||||
if src, ok := data.(*os.File); ok {
|
||||
switch src := data.(type) {
|
||||
case *os.File:
|
||||
cmd.Stdin = src
|
||||
return waitReady(cmd)
|
||||
}
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(stdin, data); err != nil {
|
||||
stdin.Close()
|
||||
return fmt.Errorf("write stdin: %w", err)
|
||||
}
|
||||
if err := stdin.Close(); err != nil {
|
||||
return fmt.Errorf("close stdin: %w", err)
|
||||
}
|
||||
|
||||
var buf [1]byte
|
||||
if _, err := stdout.Read(buf[:]); err != nil {
|
||||
return fmt.Errorf("waiting for clipboard ready: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyMultiFork(offers []Offer, pasteOnce bool) error {
|
||||
args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"}
|
||||
if pasteOnce {
|
||||
args = append(args, "--paste-once")
|
||||
}
|
||||
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start: %w", err)
|
||||
}
|
||||
|
||||
for _, offer := range offers {
|
||||
fmt.Fprintf(stdin, "%s\x00%d\x00", offer.MimeType, len(offer.Data))
|
||||
if _, err := stdin.Write(offer.Data); err != nil {
|
||||
stdin.Close()
|
||||
return fmt.Errorf("write offer data: %w", err)
|
||||
default:
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
}
|
||||
stdin.Close()
|
||||
|
||||
return nil
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(stdin, data); err != nil {
|
||||
stdin.Close()
|
||||
return fmt.Errorf("write stdin: %w", err)
|
||||
}
|
||||
if err := stdin.Close(); err != nil {
|
||||
return fmt.Errorf("close stdin: %w", err)
|
||||
}
|
||||
|
||||
var buf [1]byte
|
||||
if _, err := stdout.Read(buf[:]); err != nil {
|
||||
return fmt.Errorf("waiting for clipboard ready: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func signalReady() {
|
||||
@@ -266,25 +194,57 @@ func createClipboardCacheFile() (*os.File, error) {
|
||||
return os.CreateTemp("", "dms-clipboard-*")
|
||||
}
|
||||
|
||||
// serveOffers owns the Wayland selection until cancelled (or first paste when
|
||||
// pasteOnce is set), answering every offered mime type with its data.
|
||||
func serveOffers(offers []Offer, pasteOnce bool) error {
|
||||
if len(offers) == 0 {
|
||||
return fmt.Errorf("no offers to serve")
|
||||
}
|
||||
|
||||
s, err := connectSession()
|
||||
func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
||||
display, err := wlclient.Connect("")
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("wayland connect: %w", err)
|
||||
}
|
||||
defer s.Close()
|
||||
defer display.Destroy()
|
||||
|
||||
dataControlMgr, err := s.requireDataControl()
|
||||
ctx := display.Context()
|
||||
registry, err := display.GetRegistry()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("get registry: %w", err)
|
||||
}
|
||||
defer registry.Destroy()
|
||||
|
||||
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
|
||||
var seat *wlclient.Seat
|
||||
var bindErr error
|
||||
|
||||
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
||||
switch e.Interface {
|
||||
case "ext_data_control_manager_v1":
|
||||
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
|
||||
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
|
||||
bindErr = err
|
||||
}
|
||||
case "wl_seat":
|
||||
if seat != nil {
|
||||
return
|
||||
}
|
||||
seat = wlclient.NewSeat(ctx)
|
||||
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
|
||||
bindErr = err
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
|
||||
if bindErr != nil {
|
||||
return fmt.Errorf("registry bind: %w", bindErr)
|
||||
}
|
||||
if dataControlMgr == nil {
|
||||
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
||||
}
|
||||
defer dataControlMgr.Destroy()
|
||||
if seat == nil {
|
||||
return fmt.Errorf("no seat available")
|
||||
}
|
||||
|
||||
device, err := dataControlMgr.GetDataDevice(s.seat)
|
||||
device, err := dataControlMgr.GetDataDevice(seat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get data device: %w", err)
|
||||
}
|
||||
@@ -295,12 +255,25 @@ func serveOffers(offers []Offer, pasteOnce bool) error {
|
||||
return fmt.Errorf("create data source: %w", err)
|
||||
}
|
||||
|
||||
offerData := make(map[string][]byte, len(offers))
|
||||
for _, offer := range offers {
|
||||
if err := source.Offer(offer.MimeType); err != nil {
|
||||
return fmt.Errorf("offer %s: %w", offer.MimeType, err)
|
||||
if err := source.Offer(mimeType); err != nil {
|
||||
return fmt.Errorf("offer mime type: %w", err)
|
||||
}
|
||||
if mimeType == "text/plain;charset=utf-8" || mimeType == "text/plain" {
|
||||
if err := source.Offer("text/plain"); err != nil {
|
||||
return fmt.Errorf("offer text/plain: %w", err)
|
||||
}
|
||||
if err := source.Offer("text/plain;charset=utf-8"); err != nil {
|
||||
return fmt.Errorf("offer text/plain;charset=utf-8: %w", err)
|
||||
}
|
||||
if err := source.Offer("UTF8_STRING"); err != nil {
|
||||
return fmt.Errorf("offer UTF8_STRING: %w", err)
|
||||
}
|
||||
if err := source.Offer("STRING"); err != nil {
|
||||
return fmt.Errorf("offer STRING: %w", err)
|
||||
}
|
||||
if err := source.Offer("TEXT"); err != nil {
|
||||
return fmt.Errorf("offer TEXT: %w", err)
|
||||
}
|
||||
offerData[offer.MimeType] = offer.Data
|
||||
}
|
||||
|
||||
cancelled := make(chan struct{})
|
||||
@@ -310,11 +283,7 @@ func serveOffers(offers []Offer, pasteOnce bool) error {
|
||||
_ = syscall.SetNonblock(e.Fd, false)
|
||||
file := os.NewFile(uintptr(e.Fd), "pipe")
|
||||
defer file.Close()
|
||||
|
||||
if data, ok := offerData[e.MimeType]; ok {
|
||||
_, _ = file.Write(data)
|
||||
}
|
||||
|
||||
_, _ = file.Write(data)
|
||||
select {
|
||||
case pasted <- struct{}{}:
|
||||
default:
|
||||
@@ -329,7 +298,7 @@ func serveOffers(offers []Offer, pasteOnce bool) error {
|
||||
return fmt.Errorf("set selection: %w", err)
|
||||
}
|
||||
|
||||
s.display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
signalReady()
|
||||
|
||||
for {
|
||||
@@ -341,26 +310,70 @@ func serveOffers(offers []Offer, pasteOnce bool) error {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
if err := s.ctx.Dispatch(); err != nil {
|
||||
if err := ctx.Dispatch(); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func CopyText(text string) error {
|
||||
return Copy([]byte(text), "text/plain;charset=utf-8")
|
||||
}
|
||||
|
||||
func Paste() ([]byte, string, error) {
|
||||
s, err := connectSession()
|
||||
display, err := wlclient.Connect("")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, "", fmt.Errorf("wayland connect: %w", err)
|
||||
}
|
||||
defer s.Close()
|
||||
defer display.Destroy()
|
||||
|
||||
dataControlMgr, err := s.requireDataControl()
|
||||
ctx := display.Context()
|
||||
registry, err := display.GetRegistry()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, "", fmt.Errorf("get registry: %w", err)
|
||||
}
|
||||
defer registry.Destroy()
|
||||
|
||||
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
|
||||
var seat *wlclient.Seat
|
||||
var bindErr error
|
||||
|
||||
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
||||
switch e.Interface {
|
||||
case "ext_data_control_manager_v1":
|
||||
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
|
||||
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
|
||||
bindErr = err
|
||||
}
|
||||
case "wl_seat":
|
||||
if seat != nil {
|
||||
return
|
||||
}
|
||||
seat = wlclient.NewSeat(ctx)
|
||||
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
|
||||
bindErr = err
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
|
||||
if bindErr != nil {
|
||||
return nil, "", fmt.Errorf("registry bind: %w", bindErr)
|
||||
}
|
||||
|
||||
device, err := dataControlMgr.GetDataDevice(s.seat)
|
||||
if dataControlMgr == nil {
|
||||
return nil, "", fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
||||
}
|
||||
defer dataControlMgr.Destroy()
|
||||
|
||||
if seat == nil {
|
||||
return nil, "", fmt.Errorf("no seat available")
|
||||
}
|
||||
|
||||
device, err := dataControlMgr.GetDataDevice(seat)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("get data device: %w", err)
|
||||
}
|
||||
@@ -386,14 +399,15 @@ func Paste() ([]byte, string, error) {
|
||||
gotSelection = true
|
||||
})
|
||||
|
||||
s.display.Roundtrip()
|
||||
s.display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
|
||||
if !gotSelection || selectionOffer == nil {
|
||||
return nil, "", fmt.Errorf("no clipboard data")
|
||||
}
|
||||
|
||||
selectedMime := selectPreferredMimeType(offerMimeTypes[selectionOffer])
|
||||
mimeTypes := offerMimeTypes[selectionOffer]
|
||||
selectedMime := selectPreferredMimeType(mimeTypes)
|
||||
if selectedMime == "" {
|
||||
return nil, "", fmt.Errorf("no supported mime type")
|
||||
}
|
||||
@@ -410,7 +424,7 @@ func Paste() ([]byte, string, error) {
|
||||
}
|
||||
w.Close()
|
||||
|
||||
s.display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
@@ -456,3 +470,161 @@ func selectPreferredMimeType(mimes []string) string {
|
||||
func IsImageMimeType(mime string) bool {
|
||||
return len(mime) > 6 && mime[:6] == "image/"
|
||||
}
|
||||
|
||||
type Offer struct {
|
||||
MimeType string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
func CopyMulti(offers []Offer, foreground, pasteOnce bool) error {
|
||||
if !foreground {
|
||||
return copyMultiFork(offers, pasteOnce)
|
||||
}
|
||||
return copyMultiServe(offers, pasteOnce)
|
||||
}
|
||||
|
||||
func copyMultiFork(offers []Offer, pasteOnce bool) error {
|
||||
args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"}
|
||||
if pasteOnce {
|
||||
args = append(args, "--paste-once")
|
||||
}
|
||||
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start: %w", err)
|
||||
}
|
||||
|
||||
for _, offer := range offers {
|
||||
fmt.Fprintf(stdin, "%s\x00%d\x00", offer.MimeType, len(offer.Data))
|
||||
if _, err := stdin.Write(offer.Data); err != nil {
|
||||
stdin.Close()
|
||||
return fmt.Errorf("write offer data: %w", err)
|
||||
}
|
||||
}
|
||||
stdin.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyMultiServe(offers []Offer, pasteOnce bool) error {
|
||||
display, err := wlclient.Connect("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("wayland connect: %w", err)
|
||||
}
|
||||
defer display.Destroy()
|
||||
|
||||
ctx := display.Context()
|
||||
registry, err := display.GetRegistry()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get registry: %w", err)
|
||||
}
|
||||
defer registry.Destroy()
|
||||
|
||||
var dataControlMgr *ext_data_control.ExtDataControlManagerV1
|
||||
var seat *wlclient.Seat
|
||||
var bindErr error
|
||||
|
||||
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
||||
switch e.Interface {
|
||||
case "ext_data_control_manager_v1":
|
||||
dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx)
|
||||
if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil {
|
||||
bindErr = err
|
||||
}
|
||||
case "wl_seat":
|
||||
if seat != nil {
|
||||
return
|
||||
}
|
||||
seat = wlclient.NewSeat(ctx)
|
||||
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
|
||||
bindErr = err
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
|
||||
if bindErr != nil {
|
||||
return fmt.Errorf("registry bind: %w", bindErr)
|
||||
}
|
||||
if dataControlMgr == nil {
|
||||
return fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
||||
}
|
||||
defer dataControlMgr.Destroy()
|
||||
if seat == nil {
|
||||
return fmt.Errorf("no seat available")
|
||||
}
|
||||
|
||||
device, err := dataControlMgr.GetDataDevice(seat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get data device: %w", err)
|
||||
}
|
||||
defer device.Destroy()
|
||||
|
||||
source, err := dataControlMgr.CreateDataSource()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create data source: %w", err)
|
||||
}
|
||||
|
||||
offerMap := make(map[string][]byte)
|
||||
for _, offer := range offers {
|
||||
if err := source.Offer(offer.MimeType); err != nil {
|
||||
return fmt.Errorf("offer %s: %w", offer.MimeType, err)
|
||||
}
|
||||
offerMap[offer.MimeType] = offer.Data
|
||||
}
|
||||
|
||||
cancelled := make(chan struct{})
|
||||
pasted := make(chan struct{}, 1)
|
||||
|
||||
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
|
||||
_ = syscall.SetNonblock(e.Fd, false)
|
||||
file := os.NewFile(uintptr(e.Fd), "pipe")
|
||||
defer file.Close()
|
||||
|
||||
if data, ok := offerMap[e.MimeType]; ok {
|
||||
_, _ = file.Write(data)
|
||||
}
|
||||
|
||||
select {
|
||||
case pasted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
|
||||
close(cancelled)
|
||||
})
|
||||
|
||||
if err := device.SetSelection(source); err != nil {
|
||||
return fmt.Errorf("set selection: %w", err)
|
||||
}
|
||||
|
||||
display.Roundtrip()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-cancelled:
|
||||
return nil
|
||||
case <-pasted:
|
||||
if pasteOnce {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
if err := ctx.Dispatch(); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const (
|
||||
xkbKeymapFormatV1 = 1
|
||||
|
||||
keyStateReleased = 0
|
||||
keyStatePressed = 1
|
||||
|
||||
// xkb real modifier bit positions are fixed: Shift=0, Lock=1, Control=2
|
||||
shiftModMask = 1 << 0
|
||||
ctrlModMask = 1 << 2
|
||||
|
||||
// evdev fallbacks for a standard pc105 map
|
||||
fallbackCtrlKey = 29 // KEY_LEFTCTRL
|
||||
fallbackShiftKey = 42 // KEY_LEFTSHIFT
|
||||
fallbackVKey = 47 // KEY_V
|
||||
)
|
||||
|
||||
// SendPasteKeystroke emulates a paste shortcut via zwp_virtual_keyboard_v1
|
||||
// using the seat's own keymap, so keycodes stay valid for XWayland clients
|
||||
// (a synthetic wtype-style keymap breaks X11 apps like Steam). withShift
|
||||
// selects ctrl+shift+v for terminal targets.
|
||||
func SendPasteKeystroke(withShift bool) error {
|
||||
s, err := connectSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if s.virtualKeyboardMgr == nil {
|
||||
return fmt.Errorf("compositor does not support zwp_virtual_keyboard_manager_v1")
|
||||
}
|
||||
if s.seat == nil {
|
||||
return fmt.Errorf("no seat available")
|
||||
}
|
||||
|
||||
keyboard, err := s.seat.GetKeyboard()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get keyboard: %w", err)
|
||||
}
|
||||
defer keyboard.Release()
|
||||
|
||||
var keymap *wlclient.KeyboardKeymapEvent
|
||||
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
|
||||
if keymap == nil {
|
||||
keymap = &e
|
||||
}
|
||||
})
|
||||
|
||||
s.display.Roundtrip()
|
||||
|
||||
if keymap == nil || keymap.Format != xkbKeymapFormatV1 {
|
||||
return fmt.Errorf("no xkb keymap from seat")
|
||||
}
|
||||
defer unix.Close(keymap.Fd)
|
||||
|
||||
keymapText, err := readKeymap(keymap.Fd, keymap.Size)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read keymap: %w", err)
|
||||
}
|
||||
keys := resolveKeycodes(keymapText)
|
||||
|
||||
vk, err := s.virtualKeyboardMgr.CreateVirtualKeyboard(s.seat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create virtual keyboard: %w", err)
|
||||
}
|
||||
defer vk.Destroy()
|
||||
|
||||
if err := vk.Keymap(xkbKeymapFormatV1, keymap.Fd, keymap.Size); err != nil {
|
||||
return fmt.Errorf("set keymap: %w", err)
|
||||
}
|
||||
|
||||
mods := uint32(ctrlModMask)
|
||||
held := []uint32{keys.ctrl}
|
||||
if withShift {
|
||||
mods |= shiftModMask
|
||||
held = append(held, keys.shift)
|
||||
}
|
||||
|
||||
t := uint32(0)
|
||||
press := func(key, state uint32) error {
|
||||
t++
|
||||
return vk.Key(t, key, state)
|
||||
}
|
||||
|
||||
for _, key := range held {
|
||||
if err := press(key, keyStatePressed); err != nil {
|
||||
return fmt.Errorf("key press: %w", err)
|
||||
}
|
||||
}
|
||||
if err := vk.Modifiers(mods, 0, 0, 0); err != nil {
|
||||
return fmt.Errorf("set modifiers: %w", err)
|
||||
}
|
||||
if err := press(keys.v, keyStatePressed); err != nil {
|
||||
return fmt.Errorf("key press: %w", err)
|
||||
}
|
||||
if err := press(keys.v, keyStateReleased); err != nil {
|
||||
return fmt.Errorf("key release: %w", err)
|
||||
}
|
||||
for i := len(held) - 1; i >= 0; i-- {
|
||||
if err := press(held[i], keyStateReleased); err != nil {
|
||||
return fmt.Errorf("key release: %w", err)
|
||||
}
|
||||
}
|
||||
if err := vk.Modifiers(0, 0, 0, 0); err != nil {
|
||||
return fmt.Errorf("clear modifiers: %w", err)
|
||||
}
|
||||
|
||||
s.display.Roundtrip()
|
||||
return nil
|
||||
}
|
||||
|
||||
func readKeymap(fd int, size uint32) (string, error) {
|
||||
data, err := unix.Mmap(fd, 0, int(size), unix.PROT_READ, unix.MAP_PRIVATE)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
text := strings.TrimRight(string(data), "\x00")
|
||||
return text, unix.Munmap(data)
|
||||
}
|
||||
|
||||
type pasteKeycodes struct {
|
||||
ctrl uint32
|
||||
shift uint32
|
||||
v uint32
|
||||
}
|
||||
|
||||
var (
|
||||
keycodeDefRe = regexp.MustCompile(`<([A-Za-z0-9+_-]+)>\s*=\s*(\d+)`)
|
||||
keySymbolsRe = regexp.MustCompile(`key\s*<([A-Za-z0-9+_-]+)>\s*\{([^}]*)\}`)
|
||||
groupIndexRe = regexp.MustCompile(`\w+\[\d+\]\s*=`)
|
||||
symbolListRe = regexp.MustCompile(`\[([^\]]*)\]`)
|
||||
)
|
||||
|
||||
// xkbcommon may serialize keysyms as hex escapes instead of names
|
||||
// (e.g. "0x76" for v, "0xffe3" for Control_L).
|
||||
var keysymNames = map[uint32]string{
|
||||
0x76: "v",
|
||||
0xffe3: "Control_L",
|
||||
0xffe1: "Shift_L",
|
||||
}
|
||||
|
||||
func canonicalKeysym(sym string) string {
|
||||
if !strings.HasPrefix(sym, "0x") && !strings.HasPrefix(sym, "0X") {
|
||||
return sym
|
||||
}
|
||||
value, err := strconv.ParseUint(sym[2:], 16, 32)
|
||||
if err != nil {
|
||||
return sym
|
||||
}
|
||||
if name, ok := keysymNames[uint32(value)]; ok {
|
||||
return name
|
||||
}
|
||||
return sym
|
||||
}
|
||||
|
||||
// resolveKeycodes finds the evdev keycodes producing the keysyms we need in
|
||||
// the seat keymap's first group, falling back to pc105 positions.
|
||||
func resolveKeycodes(keymap string) pasteKeycodes {
|
||||
codes := map[string]uint32{}
|
||||
for _, m := range keycodeDefRe.FindAllStringSubmatch(keymap, -1) {
|
||||
if code, err := strconv.Atoi(m[2]); err == nil {
|
||||
codes[m[1]] = uint32(code)
|
||||
}
|
||||
}
|
||||
|
||||
keys := pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}
|
||||
want := map[string]*uint32{
|
||||
"Control_L": &keys.ctrl,
|
||||
"Shift_L": &keys.shift,
|
||||
"v": &keys.v,
|
||||
}
|
||||
|
||||
for _, m := range keySymbolsRe.FindAllStringSubmatch(keymap, -1) {
|
||||
group := symbolListRe.FindStringSubmatch(groupIndexRe.ReplaceAllString(m[2], ""))
|
||||
if group == nil {
|
||||
continue
|
||||
}
|
||||
xkbCode, ok := codes[m[1]]
|
||||
if !ok || xkbCode < 8 {
|
||||
continue
|
||||
}
|
||||
level1 := canonicalKeysym(strings.TrimSpace(strings.Split(group[1], ",")[0]))
|
||||
target, wanted := want[level1]
|
||||
if !wanted {
|
||||
continue
|
||||
}
|
||||
*target = xkbCode - 8
|
||||
delete(want, level1)
|
||||
if len(want) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func TestLiveSeatKeymapResolution(t *testing.T) {
|
||||
if os.Getenv("DMS_LIVE_TEST") == "" {
|
||||
t.Skip("set DMS_LIVE_TEST=1 to run against the live compositor")
|
||||
}
|
||||
|
||||
s, err := connectSession()
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if s.virtualKeyboardMgr == nil {
|
||||
t.Fatal("compositor does not advertise zwp_virtual_keyboard_manager_v1")
|
||||
}
|
||||
if s.seat == nil {
|
||||
t.Fatal("no seat")
|
||||
}
|
||||
|
||||
keyboard, err := s.seat.GetKeyboard()
|
||||
if err != nil {
|
||||
t.Fatalf("get keyboard: %v", err)
|
||||
}
|
||||
defer keyboard.Release()
|
||||
|
||||
var keymap *wlclient.KeyboardKeymapEvent
|
||||
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
|
||||
if keymap == nil {
|
||||
keymap = &e
|
||||
}
|
||||
})
|
||||
s.display.Roundtrip()
|
||||
|
||||
if keymap == nil {
|
||||
t.Fatal("no keymap event")
|
||||
}
|
||||
defer unix.Close(keymap.Fd)
|
||||
|
||||
text, err := readKeymap(keymap.Fd, keymap.Size)
|
||||
if err != nil {
|
||||
t.Fatalf("read keymap: %v", err)
|
||||
}
|
||||
|
||||
if dump := os.Getenv("DMS_LIVE_DUMP"); dump != "" {
|
||||
if err := os.WriteFile(dump, []byte(text), 0o644); err != nil {
|
||||
t.Fatalf("dump keymap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
keys := resolveKeycodes(text)
|
||||
t.Logf("keymap size=%d resolved ctrl=%d shift=%d v=%d", keymap.Size, keys.ctrl, keys.shift, keys.v)
|
||||
|
||||
if keys.ctrl == fallbackCtrlKey && keys.shift == fallbackShiftKey && keys.v == fallbackVKey {
|
||||
t.Log("all keycodes are fallbacks - parsing may not have matched the live keymap")
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package clipboard
|
||||
|
||||
import "testing"
|
||||
|
||||
const qwertyKeymap = `xkb_keymap {
|
||||
xkb_keycodes "(unnamed)" {
|
||||
minimum = 8;
|
||||
maximum = 708;
|
||||
<ESC> = 9;
|
||||
<AB04> = 55;
|
||||
<LCTL> = 37;
|
||||
<LFSH> = 50;
|
||||
alias <AL01> = <AC01>;
|
||||
indicator 1 = "Caps Lock";
|
||||
};
|
||||
xkb_types "(unnamed)" {
|
||||
type "ALPHABETIC" {
|
||||
modifiers = Shift+Lock;
|
||||
map[Shift] = Level2;
|
||||
level_name[Level1] = "Base";
|
||||
};
|
||||
};
|
||||
xkb_symbols "(unnamed)" {
|
||||
key <ESC> { [ Escape ] };
|
||||
key <AB04> { type= "ALPHABETIC", [ v, V ] };
|
||||
key <LCTL> { [ Control_L ] };
|
||||
key <LFSH> { [ Shift_L ] };
|
||||
};
|
||||
};`
|
||||
|
||||
const hexKeymap = `xkb_keymap {
|
||||
xkb_keycodes "(unnamed)" {
|
||||
<AB04> = 56;
|
||||
<LCTL> = 38;
|
||||
<LFSH> = 51;
|
||||
};
|
||||
xkb_symbols "(unnamed)" {
|
||||
key <LCTL> { [ 0xffe3 ] };
|
||||
key <LFSH> {
|
||||
type= "PC_ALT_LEVEL2",
|
||||
symbols[1]= [ 0xffe1, 0xfe08 ]
|
||||
};
|
||||
key <AB04> { [ 0x76, 0x56 ] };
|
||||
};
|
||||
};`
|
||||
|
||||
const dvorakKeymap = `xkb_keymap {
|
||||
xkb_keycodes "(unnamed)" {
|
||||
<AB09> = 60;
|
||||
<LCTL> = 37;
|
||||
<LFSH> = 50;
|
||||
};
|
||||
xkb_symbols "(unnamed)" {
|
||||
key <AB09> { [ v, V ] };
|
||||
key <LCTL> { [ Control_L ] };
|
||||
key <LFSH> { [ Shift_L ] };
|
||||
};
|
||||
};`
|
||||
|
||||
func TestResolveKeycodes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
keymap string
|
||||
want pasteKeycodes
|
||||
}{
|
||||
{"qwerty", qwertyKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 47}},
|
||||
{"dvorak", dvorakKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 52}},
|
||||
{"hex keysyms", hexKeymap, pasteKeycodes{ctrl: 30, shift: 43, v: 48}},
|
||||
{"empty falls back", "", pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := resolveKeycodes(tt.keymap)
|
||||
if got != tt.want {
|
||||
t.Errorf("resolveKeycodes() = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandOffers(t *testing.T) {
|
||||
text := ExpandOffers([]byte("hi"), "text/plain;charset=utf-8")
|
||||
if len(text) != 5 {
|
||||
t.Fatalf("expected 5 text offers, got %d", len(text))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, o := range text {
|
||||
if string(o.Data) != "hi" {
|
||||
t.Errorf("offer %s has wrong data", o.MimeType)
|
||||
}
|
||||
if seen[o.MimeType] {
|
||||
t.Errorf("duplicate offer %s", o.MimeType)
|
||||
}
|
||||
seen[o.MimeType] = true
|
||||
}
|
||||
if !seen["UTF8_STRING"] || !seen["STRING"] || !seen["TEXT"] || !seen["text/plain"] {
|
||||
t.Errorf("missing X11 alias offers: %v", seen)
|
||||
}
|
||||
|
||||
img := ExpandOffers([]byte{1}, "image/png")
|
||||
if len(img) != 1 || img[0].MimeType != "image/png" {
|
||||
t.Errorf("non-text mime should not expand, got %+v", img)
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/virtual_keyboard"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
type session struct {
|
||||
display *wlclient.Display
|
||||
ctx *wlclient.Context
|
||||
registry *wlclient.Registry
|
||||
seat *wlclient.Seat
|
||||
dataControlMgr *ext_data_control.ExtDataControlManagerV1
|
||||
virtualKeyboardMgr *virtual_keyboard.ZwpVirtualKeyboardManagerV1
|
||||
}
|
||||
|
||||
// connectSession opens a short-lived Wayland connection and binds the seat
|
||||
// plus whichever clipboard-related globals the compositor advertises.
|
||||
func connectSession() (*session, error) {
|
||||
display, err := wlclient.Connect("")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wayland connect: %w", err)
|
||||
}
|
||||
|
||||
s := &session{display: display, ctx: display.Context()}
|
||||
|
||||
registry, err := display.GetRegistry()
|
||||
if err != nil {
|
||||
display.Destroy()
|
||||
return nil, fmt.Errorf("get registry: %w", err)
|
||||
}
|
||||
s.registry = registry
|
||||
|
||||
var bindErr error
|
||||
bind := func(name uint32, iface string, version uint32, proxy wlclient.Proxy) {
|
||||
if err := registry.Bind(name, iface, version, proxy); err != nil {
|
||||
bindErr = fmt.Errorf("bind %s: %w", iface, err)
|
||||
}
|
||||
}
|
||||
|
||||
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
||||
switch e.Interface {
|
||||
case ext_data_control.ExtDataControlManagerV1InterfaceName:
|
||||
mgr := ext_data_control.NewExtDataControlManagerV1(s.ctx)
|
||||
bind(e.Name, e.Interface, e.Version, mgr)
|
||||
s.dataControlMgr = mgr
|
||||
case virtual_keyboard.ZwpVirtualKeyboardManagerV1InterfaceName:
|
||||
mgr := virtual_keyboard.NewZwpVirtualKeyboardManagerV1(s.ctx)
|
||||
bind(e.Name, e.Interface, e.Version, mgr)
|
||||
s.virtualKeyboardMgr = mgr
|
||||
case "wl_seat":
|
||||
if s.seat != nil {
|
||||
return
|
||||
}
|
||||
seat := wlclient.NewSeat(s.ctx)
|
||||
bind(e.Name, e.Interface, e.Version, seat)
|
||||
s.seat = seat
|
||||
}
|
||||
})
|
||||
|
||||
display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
|
||||
if bindErr != nil {
|
||||
s.Close()
|
||||
return nil, bindErr
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *session) requireDataControl() (*ext_data_control.ExtDataControlManagerV1, error) {
|
||||
switch {
|
||||
case s.dataControlMgr == nil:
|
||||
return nil, fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
||||
case s.seat == nil:
|
||||
return nil, fmt.Errorf("no seat available")
|
||||
default:
|
||||
return s.dataControlMgr, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) Close() {
|
||||
if s.dataControlMgr != nil {
|
||||
s.dataControlMgr.Destroy()
|
||||
}
|
||||
if s.registry != nil {
|
||||
s.registry.Destroy()
|
||||
}
|
||||
s.display.Destroy()
|
||||
}
|
||||
@@ -61,10 +61,6 @@ func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstalls(ctx contex
|
||||
return cd.deployConfigurationsInternal(ctx, wm, terminal, installedDeps, replaceConfigs, reinstallItems, true)
|
||||
}
|
||||
|
||||
func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstallsAndSystemd(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) {
|
||||
return cd.deployConfigurationsInternal(ctx, wm, terminal, installedDeps, replaceConfigs, reinstallItems, useSystemd)
|
||||
}
|
||||
|
||||
func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) {
|
||||
var results []DeploymentResult
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ mouse-hide-while-typing = true
|
||||
copy-on-select = false
|
||||
confirm-close-surface = false
|
||||
|
||||
# Disable in-app Ghostty toast notifications
|
||||
app-notifications = false
|
||||
# Disable annoying copied to clipboard
|
||||
app-notifications = no-clipboard-copy,no-config-reload
|
||||
|
||||
# Key bindings for common actions
|
||||
#keybind = ctrl+c=copy_to_clipboard
|
||||
|
||||
@@ -9,7 +9,7 @@ hl.bind("SUPER + M", hl.dsp.exec_cmd("dms ipc call processlist focusOrToggle"))
|
||||
hl.bind("SUPER + comma", hl.dsp.exec_cmd("dms ipc call settings focusOrToggle"))
|
||||
hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notifications toggle"))
|
||||
hl.bind("SUPER + SHIFT + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
|
||||
hl.bind("SUPER + Y", hl.dsp.exec_cmd("dms ipc call dash toggle wallpaper"))
|
||||
hl.bind("SUPER + Y", hl.dsp.exec_cmd("dms ipc call dankdash wallpaper"))
|
||||
hl.bind("SUPER + TAB", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
|
||||
hl.bind("SUPER + O", hl.dsp.exec_cmd("dms ipc call hypr toggleOverview"))
|
||||
hl.bind("SUPER + X", hl.dsp.exec_cmd("dms ipc call powermenu toggle"))
|
||||
|
||||
@@ -11,10 +11,8 @@ end)
|
||||
|
||||
hl.config({
|
||||
input = {
|
||||
-- empty inherits XKB_DEFAULT_LAYOUT (libxkbcommon), falls back to "us"
|
||||
kb_layout = "",
|
||||
kb_layout = "us",
|
||||
numlock_by_default = true,
|
||||
follow_mouse = 0,
|
||||
touchpad = {
|
||||
tap_to_click = true,
|
||||
natural_scroll = true,
|
||||
|
||||
@@ -22,7 +22,7 @@ bind=SUPER,n,spawn,dms ipc call notifications toggle
|
||||
# Notepad
|
||||
bind=SUPER+SHIFT,n,spawn,dms ipc call notepad toggle
|
||||
# Browse Wallpapers
|
||||
bind=SUPER,y,spawn,dms ipc call dash toggle wallpaper
|
||||
bind=SUPER,y,spawn,dms ipc call dankdash wallpaper
|
||||
# Power Menu
|
||||
bind=SUPER,x,spawn,dms ipc call powermenu toggle
|
||||
# Cycle Display Profile
|
||||
|
||||
@@ -24,7 +24,7 @@ binds {
|
||||
spawn "dms" "ipc" "call" "settings" "focusOrToggle";
|
||||
}
|
||||
Mod+Y hotkey-overlay-title="Browse Wallpapers" {
|
||||
spawn "dms" "ipc" "call" "dash" "toggle" "wallpaper";
|
||||
spawn "dms" "ipc" "call" "dankdash" "wallpaper";
|
||||
}
|
||||
Mod+N hotkey-overlay-title="Notification Center" { spawn "dms" "ipc" "call" "notifications" "toggle"; }
|
||||
Mod+Shift+N hotkey-overlay-title="Notepad" { spawn "dms" "ipc" "call" "notepad" "toggle"; }
|
||||
@@ -51,24 +51,24 @@ binds {
|
||||
XF86AudioMicMute allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "audio" "micmute";
|
||||
}
|
||||
XF86AudioPause allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "playPause";
|
||||
}
|
||||
XF86AudioPlay allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "playPause";
|
||||
}
|
||||
XF86AudioPrev allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "previous";
|
||||
}
|
||||
XF86AudioNext allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "next";
|
||||
}
|
||||
Ctrl+XF86AudioRaiseVolume allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "increment" "3";
|
||||
}
|
||||
Ctrl+XF86AudioLowerVolume allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "decrement" "3";
|
||||
}
|
||||
XF86AudioPause allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "playPause";
|
||||
}
|
||||
XF86AudioPlay allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "playPause";
|
||||
}
|
||||
XF86AudioPrev allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "previous";
|
||||
}
|
||||
XF86AudioNext allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "next";
|
||||
}
|
||||
Ctrl+XF86AudioRaiseVolume allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "increment" "3";
|
||||
}
|
||||
Ctrl+XF86AudioLowerVolume allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "decrement" "3";
|
||||
}
|
||||
|
||||
// === Brightness Controls ===
|
||||
XF86MonBrightnessUp allow-when-locked=true {
|
||||
|
||||
@@ -20,10 +20,3 @@ window-rule {
|
||||
tiled-state true
|
||||
draw-border-with-background false
|
||||
}
|
||||
|
||||
layer-rule {
|
||||
exclude namespace="^dms:bar$"
|
||||
background-effect {
|
||||
xray false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,9 +265,9 @@ recent-windows {
|
||||
}
|
||||
|
||||
// Include dms files
|
||||
include optional=true "dms/colors.kdl"
|
||||
include optional=true "dms/layout.kdl"
|
||||
include optional=true "dms/alttab.kdl"
|
||||
include optional=true "dms/binds.kdl"
|
||||
include optional=true "dms/outputs.kdl"
|
||||
include optional=true "dms/cursor.kdl"
|
||||
include "dms/colors.kdl"
|
||||
include "dms/layout.kdl"
|
||||
include "dms/alttab.kdl"
|
||||
include "dms/binds.kdl"
|
||||
include "dms/outputs.kdl"
|
||||
include "dms/cursor.kdl"
|
||||
|
||||
@@ -133,11 +133,6 @@ func mergedAssociations() *MimeAssociations {
|
||||
return merged
|
||||
}
|
||||
|
||||
// isSafeIniField rejects values that would corrupt a key=value line in mimeapps.list
|
||||
func isSafeIniField(s string) bool {
|
||||
return !strings.ContainsAny(s, "\n\r[]")
|
||||
}
|
||||
|
||||
func writeUserMimeapps(update func(*MimeAssociations)) error {
|
||||
mimeappsWriteMu.Lock()
|
||||
defer mimeappsWriteMu.Unlock()
|
||||
@@ -157,7 +152,6 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
|
||||
var buf bytes.Buffer
|
||||
w := bufio.NewWriter(&buf)
|
||||
|
||||
var writeErr error
|
||||
writeSection := func(name string, entries map[string]string) {
|
||||
fmt.Fprintf(w, "[%s]\n", name)
|
||||
keys := make([]string, 0, len(entries))
|
||||
@@ -166,14 +160,7 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
v := entries[k]
|
||||
if !isSafeIniField(k) || !isSafeIniField(v) {
|
||||
if writeErr == nil {
|
||||
writeErr = fmt.Errorf("invalid mimeapps.list field %q=%q", k, v)
|
||||
}
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "%s=%s\n", k, v)
|
||||
fmt.Fprintf(w, "%s=%s\n", k, entries[k])
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
@@ -190,10 +177,6 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
|
||||
writeSection(groupAdded, flatten(assoc.Added))
|
||||
writeSection(groupRemoved, flatten(assoc.Removed))
|
||||
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -119,30 +119,10 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
|
||||
|
||||
dependencies = append(dependencies, a.detectMatugen())
|
||||
dependencies = append(dependencies, a.detectDgop())
|
||||
dependencies = append(dependencies, a.detectDanksearch())
|
||||
dependencies = append(dependencies, a.detectDankCalendar())
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) detectDanksearch() deps.Dependency {
|
||||
dep := a.BaseDistribution.detectDanksearch()
|
||||
dep.CanToggle = true
|
||||
if a.packageInstalled("dsearch-git") {
|
||||
dep.Variant = deps.VariantGit
|
||||
}
|
||||
return dep
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) detectDankCalendar() deps.Dependency {
|
||||
dep := a.BaseDistribution.detectDankCalendar()
|
||||
dep.CanToggle = true
|
||||
if a.packageInstalled("dankcalendar-git") {
|
||||
dep.Variant = deps.VariantGit
|
||||
}
|
||||
return dep
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) detectXDGPortal() deps.Dependency {
|
||||
return a.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", a.packageInstalled("xdg-desktop-portal-gtk"))
|
||||
}
|
||||
@@ -152,13 +132,7 @@ func (a *ArchDistribution) detectAccountsService() deps.Dependency {
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) detectDMSGreeter() deps.Dependency {
|
||||
installed := a.packageInstalled("greetd-dms-greeter-git") || a.packageInstalled("greetd-dms-greeter-bin")
|
||||
dep := a.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", installed)
|
||||
dep.CanToggle = true
|
||||
if a.packageInstalled("greetd-dms-greeter-git") {
|
||||
dep.Variant = deps.VariantGit
|
||||
}
|
||||
return dep
|
||||
return a.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", a.packageInstalled("greetd-dms-greeter-git"))
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) packageInstalled(pkg string) bool {
|
||||
@@ -217,7 +191,7 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
|
||||
"dms (DankMaterialShell)": a.getDMSMapping(variants["dms (DankMaterialShell)"]),
|
||||
"git": {Name: "git", Repository: RepoTypeSystem},
|
||||
"quickshell": a.getQuickshellMapping(variants["quickshell"]),
|
||||
"dms-greeter": a.getDMSGreeterMapping(variants["dms-greeter"]),
|
||||
"dms-greeter": {Name: "greetd-dms-greeter-git", Repository: RepoTypeAUR},
|
||||
"matugen": a.getMatugenMapping(variants["matugen"]),
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeSystem},
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
|
||||
@@ -225,8 +199,6 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
|
||||
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
|
||||
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
|
||||
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
|
||||
"danksearch": a.getDanksearchMapping(variants["danksearch"]),
|
||||
"dankcalendar": a.getDankCalendarMapping(variants["dankcalendar"]),
|
||||
}
|
||||
|
||||
switch wm {
|
||||
@@ -281,27 +253,6 @@ func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) Packag
|
||||
return PackageMapping{Name: "matugen", Repository: RepoTypeSystem}
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) getDanksearchMapping(variant deps.PackageVariant) PackageMapping {
|
||||
if variant == deps.VariantGit {
|
||||
return PackageMapping{Name: "dsearch-git", Repository: RepoTypeAUR}
|
||||
}
|
||||
return PackageMapping{Name: "dsearch-bin", Repository: RepoTypeAUR}
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) getDankCalendarMapping(variant deps.PackageVariant) PackageMapping {
|
||||
if variant == deps.VariantGit {
|
||||
return PackageMapping{Name: "dankcalendar-git", Repository: RepoTypeAUR}
|
||||
}
|
||||
return PackageMapping{Name: "dankcalendar-bin", Repository: RepoTypeAUR}
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) getDMSGreeterMapping(variant deps.PackageVariant) PackageMapping {
|
||||
if variant == deps.VariantGit {
|
||||
return PackageMapping{Name: "greetd-dms-greeter-git", Repository: RepoTypeAUR}
|
||||
}
|
||||
return PackageMapping{Name: "greetd-dms-greeter-bin", Repository: RepoTypeAUR}
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMapping {
|
||||
if forceDMSGit || variant == deps.VariantGit {
|
||||
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
|
||||
|
||||
@@ -107,14 +107,6 @@ func (b *BaseDistribution) detectDgop() deps.Dependency {
|
||||
return b.detectCommand("dgop", "Desktop portal management tool")
|
||||
}
|
||||
|
||||
func (b *BaseDistribution) detectDanksearch() deps.Dependency {
|
||||
return b.detectOptionalPackage("danksearch", "File indexing and search service", b.commandExists("dsearch") || b.commandExists("danksearch"))
|
||||
}
|
||||
|
||||
func (b *BaseDistribution) detectDankCalendar() deps.Dependency {
|
||||
return b.detectOptionalPackage("dankcalendar", "Calendar application", b.commandExists("dcal") || b.commandExists("dankcalendar"))
|
||||
}
|
||||
|
||||
func (b *BaseDistribution) detectDMS() deps.Dependency {
|
||||
dmsPath := filepath.Join(os.Getenv("HOME"), ".config/quickshell/dms")
|
||||
|
||||
|
||||
@@ -71,8 +71,6 @@ func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
||||
|
||||
dependencies = append(dependencies, d.detectMatugen())
|
||||
dependencies = append(dependencies, d.detectDgop())
|
||||
dependencies = append(dependencies, d.detectDanksearch())
|
||||
dependencies = append(dependencies, d.detectDankCalendar())
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
@@ -137,8 +135,6 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
}
|
||||
|
||||
if wm == deps.WindowManagerNiri {
|
||||
@@ -161,60 +157,7 @@ func (d *DebianDistribution) getQuickshellMapping(variant deps.PackageVariant) P
|
||||
if forceQuickshellGit || variant == deps.VariantGit {
|
||||
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}
|
||||
}
|
||||
// Debian 13 ships stable quickshell in trixie-backports only.
|
||||
if d.debianNeedsQuickshellBackports() {
|
||||
return PackageMapping{Name: "quickshell/trixie-backports", Repository: RepoTypeSystem}
|
||||
}
|
||||
return PackageMapping{Name: "quickshell", Repository: RepoTypeSystem}
|
||||
}
|
||||
|
||||
func (d *DebianDistribution) debianNeedsQuickshellBackports() bool {
|
||||
osInfo, err := GetOSInfo()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return osInfo.VersionID == "13" || strings.EqualFold(osInfo.VersionCodename, "trixie")
|
||||
}
|
||||
|
||||
func (d *DebianDistribution) ensureQuickshellBackports(ctx context.Context, systemPkgs []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
needsBackports := false
|
||||
for _, pkg := range systemPkgs {
|
||||
if strings.Contains(pkg, "trixie-backports") {
|
||||
needsBackports = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !needsBackports {
|
||||
return nil
|
||||
}
|
||||
|
||||
policyOut, err := exec.CommandContext(ctx, "apt-cache", "policy").Output()
|
||||
if err == nil && strings.Contains(string(policyOut), "trixie-backports") {
|
||||
d.log("trixie-backports already configured")
|
||||
return nil
|
||||
}
|
||||
|
||||
listFile := "/etc/apt/sources.list.d/trixie-backports.list"
|
||||
repoLine := "deb http://deb.debian.org/debian trixie-backports main contrib non-free non-free-firmware"
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.30,
|
||||
Step: "Enabling trixie-backports for quickshell...",
|
||||
IsComplete: false,
|
||||
NeedsSudo: true,
|
||||
CommandInfo: fmt.Sprintf("echo '%s' | sudo tee %s", repoLine, listFile),
|
||||
LogOutput: "Debian 13 ships quickshell in trixie-backports",
|
||||
}
|
||||
|
||||
addCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||
fmt.Sprintf("bash -c \"echo '%s' | tee %s\"", repoLine, listFile))
|
||||
if err := d.runWithProgress(addCmd, progressChan, PhaseSystemPackages, 0.30, 0.31); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update")
|
||||
return d.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.31, 0.33)
|
||||
return PackageMapping{Name: "quickshell", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}
|
||||
}
|
||||
|
||||
func (d *DebianDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
|
||||
@@ -319,10 +262,6 @@ func (d *DebianDistribution) InstallPackages(ctx context.Context, dependencies [
|
||||
}
|
||||
}
|
||||
|
||||
if err := d.ensureQuickshellBackports(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to enable trixie-backports for quickshell: %w", err)
|
||||
}
|
||||
|
||||
// System Packages
|
||||
if len(systemPkgs) > 0 {
|
||||
progressChan <- InstallProgressMsg{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -104,8 +104,6 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
||||
|
||||
dependencies = append(dependencies, f.detectMatugen())
|
||||
dependencies = append(dependencies, f.detectDgop())
|
||||
dependencies = append(dependencies, f.detectDanksearch())
|
||||
dependencies = append(dependencies, f.detectDankCalendar())
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
@@ -140,8 +138,6 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
"dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
"danksearch": {Name: "danksearch", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
}
|
||||
|
||||
switch wm {
|
||||
|
||||
@@ -113,7 +113,6 @@ func (g *GentooDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
||||
|
||||
dependencies = append(dependencies, g.detectMatugen())
|
||||
dependencies = append(dependencies, g.detectDgop())
|
||||
dependencies = append(dependencies, g.detectDanksearch())
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
@@ -172,7 +171,6 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
"matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
||||
"dms (DankMaterialShell)": g.getDmsMapping(),
|
||||
"dgop": {Name: "gui-apps/dgop", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
||||
"danksearch": {Name: "gui-apps/danksearch", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
||||
}
|
||||
|
||||
switch wm {
|
||||
|
||||
@@ -17,7 +17,6 @@ const (
|
||||
FamilyDebian DistroFamily = "debian"
|
||||
FamilyNix DistroFamily = "nix"
|
||||
FamilyGentoo DistroFamily = "gentoo"
|
||||
FamilyVoid DistroFamily = "void"
|
||||
)
|
||||
|
||||
// PackageManagerType defines the package manager a distro uses
|
||||
@@ -30,7 +29,6 @@ const (
|
||||
PackageManagerZypper PackageManagerType = "zypper"
|
||||
PackageManagerNix PackageManagerType = "nix"
|
||||
PackageManagerPortage PackageManagerType = "portage"
|
||||
PackageManagerXBPS PackageManagerType = "xbps"
|
||||
)
|
||||
|
||||
// RepositoryType defines the type of repository for a package
|
||||
@@ -44,7 +42,6 @@ const (
|
||||
RepoTypeOBS RepositoryType = "obs" // OpenBuild Service (Debian/OpenSUSE)
|
||||
RepoTypeFlake RepositoryType = "flake" // Nix flake
|
||||
RepoTypeGURU RepositoryType = "guru" // Gentoo GURU
|
||||
RepoTypeXBPS RepositoryType = "xbps" // Custom XBPS repository
|
||||
RepoTypeManual RepositoryType = "manual" // Manual build from source
|
||||
)
|
||||
|
||||
|
||||
@@ -91,8 +91,6 @@ func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Contex
|
||||
|
||||
dependencies = append(dependencies, o.detectMatugen())
|
||||
dependencies = append(dependencies, o.detectDgop())
|
||||
dependencies = append(dependencies, o.detectDanksearch())
|
||||
dependencies = append(dependencies, o.detectDankCalendar())
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
@@ -131,8 +129,6 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
}
|
||||
|
||||
switch wm {
|
||||
|
||||
@@ -80,8 +80,6 @@ func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
||||
|
||||
dependencies = append(dependencies, u.detectMatugen())
|
||||
dependencies = append(dependencies, u.detectDgop())
|
||||
dependencies = append(dependencies, u.detectDanksearch())
|
||||
dependencies = append(dependencies, u.detectDankCalendar())
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
@@ -126,8 +124,6 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
"matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"dgop": {Name: "dgop", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"danksearch": {Name: "danksearch", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
}
|
||||
|
||||
switch wm {
|
||||
|
||||
@@ -1,541 +0,0 @@
|
||||
package distros
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
)
|
||||
|
||||
const (
|
||||
VoidDMSRepo = "https://void.danklinux.com/dms/current"
|
||||
VoidDankLinuxRepo = "https://void.danklinux.com/danklinux/current"
|
||||
VoidHyprlandRepo = "https://mirror.black-hole.dev/x86_64"
|
||||
|
||||
voidRunitSvDir = "/etc/sv"
|
||||
voidRunitServiceDir = "/var/service"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("void", "#478061", FamilyVoid, func(config DistroConfig, logChan chan<- string) Distribution {
|
||||
return NewVoidDistribution(config, logChan)
|
||||
})
|
||||
}
|
||||
|
||||
type VoidDistribution struct {
|
||||
*BaseDistribution
|
||||
config DistroConfig
|
||||
}
|
||||
|
||||
func NewVoidDistribution(config DistroConfig, logChan chan<- string) *VoidDistribution {
|
||||
return &VoidDistribution{
|
||||
BaseDistribution: NewBaseDistribution(logChan),
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) GetID() string {
|
||||
return v.config.ID
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) GetColorHex() string {
|
||||
return v.config.ColorHex
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) GetFamily() DistroFamily {
|
||||
return v.config.Family
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) GetPackageManager() PackageManagerType {
|
||||
return PackageManagerXBPS
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) {
|
||||
return v.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty)
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) {
|
||||
var dependencies []deps.Dependency
|
||||
|
||||
dependencies = append(dependencies, v.detectDMS())
|
||||
dependencies = append(dependencies, v.detectSpecificTerminal(terminal))
|
||||
dependencies = append(dependencies, v.detectGit())
|
||||
dependencies = append(dependencies, v.detectWindowManager(wm))
|
||||
dependencies = append(dependencies, v.detectQuickshell())
|
||||
dependencies = append(dependencies, v.detectDMSGreeter())
|
||||
dependencies = append(dependencies, v.detectXDGPortal())
|
||||
dependencies = append(dependencies, v.detectAccountsService())
|
||||
dependencies = append(dependencies, v.detectDBus())
|
||||
dependencies = append(dependencies, v.detectElogind())
|
||||
dependencies = append(dependencies, v.detectMesaDri())
|
||||
|
||||
if wm == deps.WindowManagerHyprland {
|
||||
dependencies = append(dependencies, v.detectHyprlandTools()...)
|
||||
}
|
||||
|
||||
if wm == deps.WindowManagerNiri || wm == deps.WindowManagerMango {
|
||||
dependencies = append(dependencies, v.detectXwaylandSatellite())
|
||||
}
|
||||
|
||||
dependencies = append(dependencies, v.detectMatugen())
|
||||
dependencies = append(dependencies, v.detectDgop())
|
||||
dependencies = append(dependencies, v.detectDanksearch())
|
||||
dependencies = append(dependencies, v.detectDankCalendar())
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) detectDMS() deps.Dependency {
|
||||
status := deps.StatusMissing
|
||||
version := ""
|
||||
variant := deps.VariantStable
|
||||
|
||||
if v.packageInstalled("dms-git") {
|
||||
status = deps.StatusInstalled
|
||||
version = v.packageVersion("dms-git")
|
||||
variant = deps.VariantGit
|
||||
} else if v.packageInstalled("dms") {
|
||||
status = deps.StatusInstalled
|
||||
version = v.packageVersion("dms")
|
||||
} else if v.commandExists("dms") {
|
||||
status = deps.StatusInstalled
|
||||
}
|
||||
|
||||
return deps.Dependency{
|
||||
Name: "dms (DankMaterialShell)",
|
||||
Status: status,
|
||||
Version: version,
|
||||
Description: "Desktop Management System package",
|
||||
Required: true,
|
||||
Variant: variant,
|
||||
CanToggle: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) detectQuickshell() deps.Dependency {
|
||||
dep := v.BaseDistribution.detectQuickshell()
|
||||
dep.CanToggle = false
|
||||
return dep
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) detectXDGPortal() deps.Dependency {
|
||||
return v.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", v.packageInstalled("xdg-desktop-portal-gtk"))
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) detectDMSGreeter() deps.Dependency {
|
||||
return v.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", v.packageInstalled("dms-greeter"))
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) detectAccountsService() deps.Dependency {
|
||||
return v.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", v.packageInstalled("accountsservice"))
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) detectDBus() deps.Dependency {
|
||||
return v.detectPackage("dbus", "D-Bus system and session message bus", v.packageInstalled("dbus"))
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) detectElogind() deps.Dependency {
|
||||
return v.detectPackage("elogind", "loginctl/logind provider for power management and session tracking", v.packageInstalled("elogind") || v.commandExists("loginctl"))
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) detectMesaDri() deps.Dependency {
|
||||
return v.detectPackage("mesa-dri", "Mesa DRI/EGL drivers (GPU rendering; compositors find no outputs without it)", v.packageInstalled("mesa-dri"))
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) detectXwaylandSatellite() deps.Dependency {
|
||||
return v.detectPackage("xwayland-satellite", "Xwayland support", v.packageInstalled("xwayland-satellite"))
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) packageInstalled(pkg string) bool {
|
||||
return exec.Command("xbps-query", pkg).Run() == nil
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) packageVersion(pkg string) string {
|
||||
output, err := exec.Command("xbps-query", "-p", "pkgver", pkg).Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
|
||||
return v.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping {
|
||||
packages := map[string]PackageMapping{
|
||||
"git": {Name: "git", Repository: RepoTypeSystem},
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
|
||||
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
|
||||
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
|
||||
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
|
||||
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
|
||||
"dbus": {Name: "dbus", Repository: RepoTypeSystem},
|
||||
"elogind": {Name: "elogind", Repository: RepoTypeSystem},
|
||||
"mesa-dri": {Name: "mesa-dri", Repository: RepoTypeSystem},
|
||||
|
||||
"quickshell": {Name: "quickshell", Repository: RepoTypeSystem},
|
||||
"matugen": {Name: "matugen", Repository: RepoTypeSystem},
|
||||
"dms (DankMaterialShell)": v.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeXBPS, RepoURL: VoidDMSRepo},
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
|
||||
"danksearch": {Name: "danksearch", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
|
||||
"dankcalendar": {Name: "dankcalendar", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
|
||||
}
|
||||
|
||||
switch wm {
|
||||
case deps.WindowManagerHyprland:
|
||||
packages["hyprland"] = PackageMapping{Name: "hyprland", Repository: RepoTypeXBPS, RepoURL: VoidHyprlandRepo}
|
||||
packages["hyprctl"] = PackageMapping{Name: "hyprland", Repository: RepoTypeXBPS, RepoURL: VoidHyprlandRepo}
|
||||
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
|
||||
case deps.WindowManagerNiri:
|
||||
packages["niri"] = PackageMapping{Name: "niri", Repository: RepoTypeSystem}
|
||||
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
|
||||
case deps.WindowManagerMango:
|
||||
packages["mango"] = PackageMapping{Name: "mangowc", Repository: RepoTypeSystem}
|
||||
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
|
||||
}
|
||||
|
||||
return packages
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping {
|
||||
if variant == deps.VariantStable {
|
||||
return PackageMapping{Name: "dms", Repository: RepoTypeXBPS, RepoURL: VoidDMSRepo}
|
||||
}
|
||||
return PackageMapping{Name: "dms-git", Repository: RepoTypeXBPS, RepoURL: VoidDMSRepo}
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhasePrerequisites,
|
||||
Progress: 0.06,
|
||||
Step: "Checking XBPS...",
|
||||
IsComplete: false,
|
||||
LogOutput: "Checking for xbps-install",
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("xbps-install"); err != nil {
|
||||
return fmt.Errorf("xbps-install not found; Void Linux package tools are required: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error {
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhasePrerequisites,
|
||||
Progress: 0.05,
|
||||
Step: "Checking system prerequisites...",
|
||||
IsComplete: false,
|
||||
LogOutput: "Starting prerequisite check...",
|
||||
}
|
||||
|
||||
if wm == deps.WindowManagerHyprland {
|
||||
arch, err := v.xbpsArch(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to detect XBPS architecture for Hyprland repository selection: %w", err)
|
||||
}
|
||||
if arch != "x86_64" {
|
||||
return fmt.Errorf("hyprland on Void Linux is installed from %s, which currently provides x86_64 packages only (detected %s)", VoidHyprlandRepo, arch)
|
||||
}
|
||||
}
|
||||
|
||||
if err := v.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install prerequisites: %w", err)
|
||||
}
|
||||
|
||||
systemPkgs, xbpsPkgs := v.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
|
||||
|
||||
if len(xbpsPkgs) > 0 {
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.15,
|
||||
Step: "Enabling DMS XBPS repositories...",
|
||||
IsComplete: false,
|
||||
NeedsSudo: true,
|
||||
LogOutput: "Setting up custom XBPS repositories for DMS packages",
|
||||
}
|
||||
if err := v.enableXBPSRepos(ctx, xbpsPkgs, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to enable XBPS repositories: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
allPkgs := v.uniquePackageNames(systemPkgs, v.extractPackageNames(xbpsPkgs))
|
||||
if len(allPkgs) > 0 {
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.35,
|
||||
Step: fmt.Sprintf("Installing %d XBPS packages...", len(allPkgs)),
|
||||
IsComplete: false,
|
||||
NeedsSudo: true,
|
||||
LogOutput: fmt.Sprintf("Installing XBPS packages: %s", strings.Join(allPkgs, ", ")),
|
||||
}
|
||||
if err := v.installXBPSPackages(ctx, allPkgs, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install XBPS packages: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseConfiguration,
|
||||
Progress: 0.90,
|
||||
Step: "Configuring system...",
|
||||
IsComplete: false,
|
||||
LogOutput: "Starting post-installation configuration...",
|
||||
}
|
||||
|
||||
v.log("Void Linux detected; DMS environment and autostart will be configured in the compositor config instead of systemd")
|
||||
if err := v.ensureSessionServices(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to enable Void session services: %w", err)
|
||||
}
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseComplete,
|
||||
Progress: 1.0,
|
||||
Step: "Installation complete!",
|
||||
IsComplete: true,
|
||||
LogOutput: "All packages installed and configured successfully",
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) ensureSessionServices(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
if !v.isRunitSystem() {
|
||||
v.log("Void runit service directory not detected; skipping dbus/elogind service enablement")
|
||||
return nil
|
||||
}
|
||||
|
||||
// D-Bus activation alone starts elogind without its wrapper mounts; the runit service is required.
|
||||
for _, service := range []string{"dbus", "elogind"} {
|
||||
if !v.runitServiceInstalled(service) {
|
||||
v.log(fmt.Sprintf("Warning: %s runit service not found in %s; power/session actions may not work until %s is installed", service, voidRunitSvDir, service))
|
||||
continue
|
||||
}
|
||||
if v.runitServiceEnabled(service) {
|
||||
v.log(fmt.Sprintf("Void runit service %s already enabled", service))
|
||||
continue
|
||||
}
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseConfiguration,
|
||||
Progress: 0.92,
|
||||
Step: fmt.Sprintf("Enabling %s runit service...", service),
|
||||
IsComplete: false,
|
||||
NeedsSudo: true,
|
||||
CommandInfo: fmt.Sprintf("sudo ln -sf %s %s", filepath.Join(voidRunitSvDir, service), filepath.Join(voidRunitServiceDir, service)),
|
||||
LogOutput: fmt.Sprintf("Enabling Void runit service: %s", service),
|
||||
}
|
||||
|
||||
cmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("ln -sf %s %s", filepath.Join(voidRunitSvDir, service), filepath.Join(voidRunitServiceDir, service)))
|
||||
if err := v.runWithProgress(cmd, progressChan, PhaseConfiguration, 0.92, 0.95); err != nil {
|
||||
return fmt.Errorf("failed to enable %s runit service: %w", service, err)
|
||||
}
|
||||
v.log(fmt.Sprintf("✓ Enabled %s runit service", service))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) isRunitSystem() bool {
|
||||
if fi, err := os.Stat("/run/runit"); err == nil && fi.IsDir() {
|
||||
return true
|
||||
}
|
||||
if _, err := os.Stat("/run/systemd/system"); err == nil {
|
||||
return false
|
||||
}
|
||||
if fi, err := os.Stat(voidRunitServiceDir); err == nil && fi.IsDir() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) runitServiceInstalled(name string) bool {
|
||||
fi, err := os.Stat(filepath.Join(voidRunitSvDir, name))
|
||||
return err == nil && fi.IsDir()
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) runitServiceEnabled(name string) bool {
|
||||
_, err := os.Lstat(filepath.Join(voidRunitServiceDir, name))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]string, []PackageMapping) {
|
||||
systemPkgs := []string{}
|
||||
xbpsPkgs := []PackageMapping{}
|
||||
|
||||
variantMap := make(map[string]deps.PackageVariant)
|
||||
for _, dep := range dependencies {
|
||||
variantMap[dep.Name] = dep.Variant
|
||||
}
|
||||
|
||||
packageMap := v.GetPackageMappingWithVariants(wm, variantMap)
|
||||
|
||||
for _, dep := range dependencies {
|
||||
if disabledFlags[dep.Name] {
|
||||
continue
|
||||
}
|
||||
|
||||
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
|
||||
continue
|
||||
}
|
||||
|
||||
pkgInfo, exists := packageMap[dep.Name]
|
||||
if !exists {
|
||||
v.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name))
|
||||
continue
|
||||
}
|
||||
|
||||
switch pkgInfo.Repository {
|
||||
case RepoTypeXBPS:
|
||||
xbpsPkgs = append(xbpsPkgs, pkgInfo)
|
||||
case RepoTypeSystem:
|
||||
systemPkgs = append(systemPkgs, pkgInfo.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return systemPkgs, xbpsPkgs
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) enableXBPSRepos(ctx context.Context, xbpsPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
enabledRepos := make(map[string]bool)
|
||||
enabledRepoURLs := []string{}
|
||||
|
||||
for _, pkg := range xbpsPkgs {
|
||||
if pkg.RepoURL == "" || enabledRepos[pkg.RepoURL] {
|
||||
continue
|
||||
}
|
||||
|
||||
repoName := v.xbpsRepoName(pkg.RepoURL)
|
||||
confPath := filepath.Join("/etc/xbps.d", repoName+".conf")
|
||||
repoLine := fmt.Sprintf("repository=%s", pkg.RepoURL)
|
||||
repoFileContent := repoLine + "\n"
|
||||
|
||||
if content, err := os.ReadFile(confPath); err == nil && string(content) == repoFileContent {
|
||||
v.log(fmt.Sprintf("XBPS repo %s already configured, skipping", pkg.RepoURL))
|
||||
enabledRepos[pkg.RepoURL] = true
|
||||
enabledRepoURLs = append(enabledRepoURLs, pkg.RepoURL)
|
||||
continue
|
||||
}
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.18,
|
||||
Step: fmt.Sprintf("Adding XBPS repo %s...", repoName),
|
||||
IsComplete: false,
|
||||
NeedsSudo: true,
|
||||
CommandInfo: fmt.Sprintf("echo 'repository=%s' | sudo tee %s", pkg.RepoURL, confPath),
|
||||
LogOutput: fmt.Sprintf("Adding XBPS repository: %s", pkg.RepoURL),
|
||||
}
|
||||
|
||||
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, "mkdir -p /etc/xbps.d")
|
||||
if err := v.runWithProgress(mkdirCmd, progressChan, PhaseSystemPackages, 0.18, 0.19); err != nil {
|
||||
return fmt.Errorf("failed to create /etc/xbps.d: %w", err)
|
||||
}
|
||||
|
||||
writeCmd := privesc.ExecCommand(ctx, sudoPassword,
|
||||
fmt.Sprintf("bash -c 'printf \"%%s\\n\" %q > %s'", repoLine, confPath))
|
||||
if err := v.runWithProgress(writeCmd, progressChan, PhaseSystemPackages, 0.19, 0.22); err != nil {
|
||||
return fmt.Errorf("failed to add XBPS repo %s: %w", pkg.RepoURL, err)
|
||||
}
|
||||
|
||||
enabledRepos[pkg.RepoURL] = true
|
||||
enabledRepoURLs = append(enabledRepoURLs, pkg.RepoURL)
|
||||
}
|
||||
|
||||
if len(enabledRepos) > 0 {
|
||||
syncArgs := []string{"xbps-install", "-Sy", "-i"}
|
||||
for _, repoURL := range enabledRepoURLs {
|
||||
syncArgs = append(syncArgs, "--repository", repoURL)
|
||||
}
|
||||
syncCommand := strings.Join(syncArgs, " ")
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.25,
|
||||
Step: "Synchronizing XBPS repositories...",
|
||||
IsComplete: false,
|
||||
NeedsSudo: true,
|
||||
CommandInfo: "sudo sh -c 'yes y | " + syncCommand + "'",
|
||||
LogOutput: "Synchronizing XBPS repository indexes",
|
||||
}
|
||||
|
||||
syncCmd := privesc.ExecCommand(ctx, sudoPassword, "sh -c 'yes y | "+syncCommand+"'")
|
||||
if err := v.runWithProgress(syncCmd, progressChan, PhaseSystemPackages, 0.25, 0.30); err != nil {
|
||||
return fmt.Errorf("failed to synchronize XBPS repositories: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) xbpsRepoName(repoURL string) string {
|
||||
switch repoURL {
|
||||
case VoidDMSRepo:
|
||||
return "dms"
|
||||
case VoidDankLinuxRepo:
|
||||
return "danklinux"
|
||||
case VoidHyprlandRepo:
|
||||
return "hyprland"
|
||||
default:
|
||||
name := strings.TrimPrefix(repoURL, "https://")
|
||||
name = strings.TrimPrefix(name, "http://")
|
||||
name = strings.NewReplacer("/", "-", ".", "-").Replace(name)
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) xbpsArch(ctx context.Context) (string, error) {
|
||||
output, err := exec.CommandContext(ctx, "xbps-uhelper", "arch").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(output)), nil
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) installXBPSPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
if len(packages) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
args := append([]string{"xbps-install", "-Sy"}, packages...)
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.40,
|
||||
Step: "Installing XBPS packages...",
|
||||
IsComplete: false,
|
||||
NeedsSudo: true,
|
||||
CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")),
|
||||
}
|
||||
|
||||
cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " "))
|
||||
return v.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.85)
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) extractPackageNames(packages []PackageMapping) []string {
|
||||
names := make([]string, len(packages))
|
||||
for i, pkg := range packages {
|
||||
names[i] = pkg.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) uniquePackageNames(groups ...[]string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var unique []string
|
||||
for _, group := range groups {
|
||||
for _, pkg := range group {
|
||||
if pkg == "" || seen[pkg] {
|
||||
continue
|
||||
}
|
||||
seen[pkg] = true
|
||||
unique = append(unique, pkg)
|
||||
}
|
||||
}
|
||||
return unique
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func (c *GeoClueClient) startSignalPump() error {
|
||||
if err := c.dbusConn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(c.clientPath),
|
||||
dbus.WithMatchInterface(dbusGeoClueClientInterface),
|
||||
dbus.WithMatchMember("LocationUpdated"),
|
||||
dbus.WithMatchSender(dbusGeoClueClientLocationUpdated),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -30,11 +30,6 @@ const appArmorProfileDest = "/etc/apparmor.d/usr.bin.dms-greeter"
|
||||
|
||||
const GreeterCacheDir = "/var/cache/dms-greeter"
|
||||
|
||||
const (
|
||||
runitSvDir = "/etc/sv"
|
||||
runitServiceDir = "/var/service"
|
||||
)
|
||||
|
||||
func DetectDMSPath() (string, error) {
|
||||
return config.LocateDMSConfig()
|
||||
}
|
||||
@@ -46,142 +41,6 @@ func IsNixOS() bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func IsVoidLinux() bool {
|
||||
osInfo, err := distros.GetOSInfo()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
config, exists := distros.Registry[osInfo.Distribution.ID]
|
||||
return exists && config.Family == distros.FamilyVoid
|
||||
}
|
||||
|
||||
func isRunit() bool {
|
||||
if fi, err := os.Stat("/run/runit"); err == nil && fi.IsDir() {
|
||||
return true
|
||||
}
|
||||
if _, err := os.Stat("/run/systemd/system"); err == nil {
|
||||
return false
|
||||
}
|
||||
if fi, err := os.Stat(runitServiceDir); err == nil && fi.IsDir() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func runitServiceInstalled(name string) bool {
|
||||
fi, err := os.Stat(filepath.Join(runitSvDir, name))
|
||||
return err == nil && fi.IsDir()
|
||||
}
|
||||
|
||||
func runitServiceEnabled(name string) bool {
|
||||
_, err := os.Lstat(filepath.Join(runitServiceDir, name))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func enableRunitService(name, sudoPassword string) error {
|
||||
if !runitServiceInstalled(name) {
|
||||
return fmt.Errorf("runit service %q not found in %s", name, runitSvDir)
|
||||
}
|
||||
if runitServiceEnabled(name) {
|
||||
return nil
|
||||
}
|
||||
return privesc.Run(context.Background(), sudoPassword, "ln", "-sf",
|
||||
filepath.Join(runitSvDir, name), filepath.Join(runitServiceDir, name))
|
||||
}
|
||||
|
||||
func disableRunitService(name, sudoPassword string) error {
|
||||
if !runitServiceEnabled(name) {
|
||||
return nil
|
||||
}
|
||||
return privesc.Run(context.Background(), sudoPassword, "rm", "-f",
|
||||
filepath.Join(runitServiceDir, name))
|
||||
}
|
||||
|
||||
func ensureRunitSeat(greeterUser, sudoPassword string, logFunc func(string)) {
|
||||
if runitServiceInstalled("seatd") {
|
||||
if err := enableRunitService("seatd", sudoPassword); err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ could not enable seatd: %v", err))
|
||||
} else {
|
||||
logFunc("✓ seatd enabled")
|
||||
}
|
||||
} else {
|
||||
logFunc("⚠ seatd not installed — the greeter compositor needs it for GPU/seat access")
|
||||
}
|
||||
|
||||
if err := privesc.Run(context.Background(), sudoPassword, "usermod", "-aG", "_seatd,video,input", greeterUser); err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ could not add %s to seat groups: %v", greeterUser, err))
|
||||
} else {
|
||||
logFunc(fmt.Sprintf("✓ %s added to seat groups (_seatd, video, input)", greeterUser))
|
||||
}
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
const pamPath = "/etc/pam.d/greetd"
|
||||
data, err := os.ReadFile(pamPath)
|
||||
if err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ could not read %s: %v", pamPath, err))
|
||||
return
|
||||
}
|
||||
if strings.Contains(string(data), "pam_rundir") {
|
||||
logFunc("✓ pam_rundir already present in greetd PAM")
|
||||
return
|
||||
}
|
||||
|
||||
line := "session optional pam_rundir.so"
|
||||
if err := privesc.Run(context.Background(), sudoPassword, "sh", "-c",
|
||||
fmt.Sprintf("printf '%%s\\n' %q >> %s", line, pamPath)); err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ could not add pam_rundir to %s: %v", pamPath, err))
|
||||
return
|
||||
}
|
||||
logFunc("✓ pam_rundir added to greetd PAM (provides XDG_RUNTIME_DIR for the session)")
|
||||
}
|
||||
|
||||
func DetectGreeterGroup() string {
|
||||
data, err := os.ReadFile("/etc/group")
|
||||
if err != nil {
|
||||
@@ -529,23 +388,11 @@ func execFromDesktopFile(path string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
inDesktopEntry := false
|
||||
for line := range strings.SplitSeq(string(data), "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
switch {
|
||||
case trimmed == "" || strings.HasPrefix(trimmed, "#"):
|
||||
continue
|
||||
case strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]"):
|
||||
inDesktopEntry = trimmed == "[Desktop Entry]"
|
||||
continue
|
||||
case !inDesktopEntry:
|
||||
continue
|
||||
if strings.HasPrefix(trimmed, "Exec=") {
|
||||
return strings.TrimSpace(trimmed[len("Exec="):]), nil
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -919,8 +766,6 @@ func EnsureGreetdInstalled(logFunc func(string), sudoPassword string) error {
|
||||
installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y greetd")
|
||||
case distros.FamilyGentoo:
|
||||
installCmd = privesc.ExecCommand(ctx, sudoPassword, "emerge --ask n sys-apps/greetd")
|
||||
case distros.FamilyVoid:
|
||||
installCmd = privesc.ExecCommand(ctx, sudoPassword, "xbps-install -Sy greetd")
|
||||
case distros.FamilyNix:
|
||||
return fmt.Errorf("on NixOS, please add greetd to your configuration.nix")
|
||||
default:
|
||||
@@ -1047,14 +892,6 @@ 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)
|
||||
installCmd = exec.CommandContext(ctx, aurHelper, "-S", "--noconfirm", "greetd-dms-greeter-git")
|
||||
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"
|
||||
logFunc("Adding DMS XBPS repository...")
|
||||
if err := ensureVoidXBPSRepo(ctx, sudoPassword, "dms", distros.VoidDMSRepo); err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ Failed to add DMS XBPS repository: %v", err))
|
||||
}
|
||||
privesc.ExecCommand(ctx, sudoPassword, "sh -c 'yes y | xbps-install -Sy -i --repository "+distros.VoidDMSRepo+"'").Run()
|
||||
installCmd = privesc.ExecCommand(ctx, sudoPassword, "xbps-install -Sy dms-greeter")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -1072,20 +909,6 @@ func TryInstallGreeterPackage(logFunc func(string), sudoPassword string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func ensureVoidXBPSRepo(ctx context.Context, sudoPassword, name, repoURL string) error {
|
||||
confPath := filepath.Join("/etc/xbps.d", name+".conf")
|
||||
repoLine := fmt.Sprintf("repository=%s", repoURL)
|
||||
repoFileContent := repoLine + "\n"
|
||||
if content, err := os.ReadFile(confPath); err == nil && string(content) == repoFileContent {
|
||||
return nil
|
||||
}
|
||||
if err := privesc.Run(ctx, sudoPassword, "mkdir", "-p", "/etc/xbps.d"); err != nil {
|
||||
return err
|
||||
}
|
||||
return privesc.Run(ctx, sudoPassword, "sh", "-c",
|
||||
fmt.Sprintf("printf '%%s\\n' %q > %s", repoLine, confPath))
|
||||
}
|
||||
|
||||
// CopyGreeterFiles installs the dms-greeter wrapper and sets up cache directory
|
||||
func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
|
||||
if IsGreeterPackaged() {
|
||||
@@ -1798,10 +1621,6 @@ func syncGreeterColorSource(homeDir, cacheDir string, state greeterThemeSyncStat
|
||||
}
|
||||
|
||||
func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
|
||||
if err := EnsureVoidLogindGreetdCommand(logFunc, sudoPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||
@@ -2333,7 +2152,13 @@ vt = 1
|
||||
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)
|
||||
newConfig := upsertDefaultSession(configContent, greeterUser, commandLine)
|
||||
@@ -2345,84 +2170,6 @@ vt = 1
|
||||
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 {
|
||||
for _, flag := range []string{" -C ", " --config "} {
|
||||
idx := strings.Index(command, flag)
|
||||
@@ -2528,19 +2275,6 @@ func checkSystemdEnabled(service string) (string, error) {
|
||||
func DisableConflictingDisplayManagers(sudoPassword string, logFunc func(string)) error {
|
||||
conflictingDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"}
|
||||
for _, dm := range conflictingDMs {
|
||||
if isRunit() {
|
||||
if !runitServiceEnabled(dm) {
|
||||
continue
|
||||
}
|
||||
logFunc(fmt.Sprintf("Disabling conflicting display manager: %s", dm))
|
||||
if err := disableRunitService(dm, sudoPassword); err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ Warning: Failed to disable %s: %v", dm, err))
|
||||
} else {
|
||||
logFunc(fmt.Sprintf("✓ Disabled %s", dm))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
state, err := checkSystemdEnabled(dm)
|
||||
if err != nil || state == "" || state == "not-found" {
|
||||
continue
|
||||
@@ -2560,23 +2294,6 @@ func DisableConflictingDisplayManagers(sudoPassword string, logFunc func(string)
|
||||
|
||||
// EnableGreetd unmasks and enables greetd, forcing it over any other DM.
|
||||
func EnableGreetd(sudoPassword string, logFunc func(string)) error {
|
||||
if isRunit() {
|
||||
if !runitServiceInstalled("greetd") {
|
||||
return fmt.Errorf("greetd service not found in %s; ensure greetd is installed", runitSvDir)
|
||||
}
|
||||
if IsVoidLinux() {
|
||||
ensureVoidLogindGreeter(DetectGreeterUser(), sudoPassword, logFunc)
|
||||
} else {
|
||||
ensureRunitSeat(DetectGreeterUser(), sudoPassword, logFunc)
|
||||
}
|
||||
ensureGreetdPamRundir(sudoPassword, logFunc)
|
||||
if err := enableRunitService("greetd", sudoPassword); err != nil {
|
||||
return fmt.Errorf("failed to enable greetd: %w", err)
|
||||
}
|
||||
logFunc(fmt.Sprintf("✓ greetd enabled (%s)", runitServiceDir))
|
||||
return nil
|
||||
}
|
||||
|
||||
state, err := checkSystemdEnabled("greetd")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check greetd state: %w", err)
|
||||
@@ -2600,11 +2317,6 @@ func EnableGreetd(sudoPassword string, logFunc func(string)) error {
|
||||
}
|
||||
|
||||
func EnsureGraphicalTarget(sudoPassword string, logFunc func(string)) error {
|
||||
if isRunit() {
|
||||
logFunc("✓ runit detected; no graphical target is needed")
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := exec.Command("systemctl", "get-default")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
|
||||
@@ -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) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package greeter
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -94,95 +93,18 @@ func resolveSessionExecInDirs(sessionID string, dirs []string) (string, error) {
|
||||
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 {
|
||||
execLine, err := ResolveSessionExec(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
argv := parseExecString(strings.TrimSpace(execLine))
|
||||
if len(argv) == 0 {
|
||||
execLine = strings.TrimSpace(stripDesktopExecCodes(execLine))
|
||||
if execLine == "" {
|
||||
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")
|
||||
return syscall.Exec(resolved, argv, env)
|
||||
return syscall.Exec("/bin/sh", []string{"sh", "-c", "exec " + execLine}, env)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -41,8 +41,6 @@ type Config struct {
|
||||
ReplaceConfigs []string // specific configs to deploy (e.g. "niri", "ghostty")
|
||||
ReplaceConfigsAll bool // deploy/replace all configurations
|
||||
Yes bool
|
||||
DankSearch bool // install danksearch and enable its user service
|
||||
DankCalendar bool // install dankcalendar
|
||||
}
|
||||
|
||||
// Runner orchestrates unattended (headless) installation.
|
||||
@@ -216,11 +214,6 @@ func (r *Runner) Run() error {
|
||||
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)
|
||||
if !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
|
||||
compositorName := "niri"
|
||||
@@ -238,32 +231,18 @@ 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
|
||||
fmt.Fprintln(os.Stdout, "Deploying configurations...")
|
||||
r.log("Starting configuration deployment")
|
||||
|
||||
deployer := config.NewConfigDeployer(r.logChan)
|
||||
results, err := deployer.DeployConfigurationsSelectiveWithReinstallsAndSystemd(
|
||||
results, err := deployer.DeployConfigurationsSelectiveWithReinstalls(
|
||||
context.Background(),
|
||||
wm,
|
||||
terminal,
|
||||
dependencies,
|
||||
replaceConfigs,
|
||||
reinstallItems,
|
||||
useSystemd,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configuration deployment failed: %w", err)
|
||||
@@ -288,31 +267,19 @@ func (r *Runner) Run() error {
|
||||
}
|
||||
|
||||
// buildDisabledItems computes the set of dependencies that should be skipped
|
||||
// during installation. Optional components are opt-in (disabled by default),
|
||||
// then re-enabled by the dedicated flags and --include-deps.
|
||||
// during installation, applying the --include-deps and --exclude-deps filters.
|
||||
// dms-greeter is disabled by default (opt-in), matching TUI behavior.
|
||||
func (r *Runner) buildDisabledItems(dependencies []deps.Dependency) (map[string]bool, error) {
|
||||
disabledItems := make(map[string]bool)
|
||||
|
||||
// dms-greeter is opt-in (disabled by default), matching TUI behavior
|
||||
for i := range dependencies {
|
||||
if !dependencies[i].Required {
|
||||
disabledItems[dependencies[i].Name] = true
|
||||
if dependencies[i].Name == "dms-greeter" {
|
||||
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)
|
||||
for _, name := range r.cfg.IncludeDeps {
|
||||
name = strings.TrimSpace(name)
|
||||
|
||||
@@ -342,21 +342,17 @@ func TestConfigReplaceConfigsStoredCorrectly(t *testing.T) {
|
||||
|
||||
func TestBuildDisabledItems(t *testing.T) {
|
||||
dependencies := []deps.Dependency{
|
||||
{Name: "niri", Status: deps.StatusInstalled, Required: true},
|
||||
{Name: "ghostty", Status: deps.StatusMissing, Required: true},
|
||||
{Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled, Required: true},
|
||||
{Name: "niri", Status: deps.StatusInstalled},
|
||||
{Name: "ghostty", Status: deps.StatusMissing},
|
||||
{Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled},
|
||||
{Name: "dms-greeter", Status: deps.StatusMissing},
|
||||
{Name: "danksearch", Status: deps.StatusMissing},
|
||||
{Name: "dankcalendar", Status: deps.StatusMissing},
|
||||
{Name: "waybar", Status: deps.StatusMissing, Required: true},
|
||||
{Name: "waybar", Status: deps.StatusMissing},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
includeDeps []string
|
||||
excludeDeps []string
|
||||
dankSearch bool
|
||||
dankCalendar bool
|
||||
deps []deps.Dependency // nil means use the shared fixture
|
||||
wantErr bool
|
||||
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)
|
||||
}{
|
||||
{
|
||||
name: "no flags set, optional deps disabled by default",
|
||||
wantDisabled: []string{"dms-greeter", "danksearch", "dankcalendar"},
|
||||
name: "no flags set, dms-greeter disabled by default",
|
||||
wantDisabled: []string{"dms-greeter"},
|
||||
wantEnabled: []string{"niri", "ghostty", "waybar"},
|
||||
},
|
||||
{
|
||||
name: "include dms-greeter enables it",
|
||||
includeDeps: []string{"dms-greeter"},
|
||||
wantEnabled: []string{"dms-greeter"},
|
||||
wantDisabled: []string{"danksearch", "dankcalendar"},
|
||||
name: "include dms-greeter enables it",
|
||||
includeDeps: []string{"dms-greeter"},
|
||||
wantEnabled: []string{"dms-greeter"},
|
||||
},
|
||||
{
|
||||
name: "exclude a regular dep",
|
||||
excludeDeps: []string{"waybar"},
|
||||
wantDisabled: []string{"dms-greeter", "danksearch", "dankcalendar", "waybar"},
|
||||
wantDisabled: []string{"dms-greeter", "waybar"},
|
||||
},
|
||||
{
|
||||
name: "include unknown dep returns error",
|
||||
@@ -404,53 +399,24 @@ func TestBuildDisabledItems(t *testing.T) {
|
||||
wantDisabled: []string{"dms-greeter"},
|
||||
},
|
||||
{
|
||||
name: "whitespace entries are skipped",
|
||||
includeDeps: []string{" ", "dms-greeter"},
|
||||
wantEnabled: []string{"dms-greeter"},
|
||||
wantDisabled: []string{"danksearch", "dankcalendar"},
|
||||
name: "whitespace entries are skipped",
|
||||
includeDeps: []string{" ", "dms-greeter"},
|
||||
wantEnabled: []string{"dms-greeter"},
|
||||
},
|
||||
{
|
||||
name: "no optional deps present, nothing disabled by default",
|
||||
name: "no dms-greeter in deps, nothing disabled by default",
|
||||
deps: []deps.Dependency{
|
||||
{Name: "niri", Status: deps.StatusInstalled, Required: true},
|
||||
{Name: "niri", Status: deps.StatusInstalled},
|
||||
},
|
||||
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 {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := NewRunner(Config{
|
||||
IncludeDeps: tt.includeDeps,
|
||||
ExcludeDeps: tt.excludeDeps,
|
||||
DankSearch: tt.dankSearch,
|
||||
DankCalendar: tt.dankCalendar,
|
||||
IncludeDeps: tt.includeDeps,
|
||||
ExcludeDeps: tt.excludeDeps,
|
||||
})
|
||||
d := tt.deps
|
||||
if d == nil {
|
||||
|
||||
@@ -299,9 +299,6 @@ type hyprlandOverrideBind struct {
|
||||
Options map[string]any
|
||||
// Unbind: negative override (hl.unbind only, no rebind).
|
||||
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 {
|
||||
@@ -1049,26 +1046,18 @@ func luaActionStringFromHyprlangAction(action string) string {
|
||||
if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok {
|
||||
return expr
|
||||
}
|
||||
// Unrecognized dispatchers are freeform text, not Lua; forward them to
|
||||
// hyprctl quoted so a stray `"` can't produce broken Lua output.
|
||||
return luaHyprctlDispatchFunction(action)
|
||||
return action
|
||||
}
|
||||
|
||||
// luaExprToInternalAction converts a parsed Lua bind expression back into
|
||||
// "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) {
|
||||
func luaExprToInternalAction(expr string) string {
|
||||
d, p := luaExprToDispatcherParams(expr)
|
||||
if d == expr && p == "" {
|
||||
return expr, true
|
||||
}
|
||||
if d == "exec" && p != "" && !strings.HasPrefix(p, "hyprctl dispatch lua:") {
|
||||
return "exec " + p, false
|
||||
return "exec " + p
|
||||
}
|
||||
if p != "" {
|
||||
return d + " " + p, false
|
||||
return d + " " + p
|
||||
}
|
||||
return d, false
|
||||
return d
|
||||
}
|
||||
|
||||
func luaBindOptions(bind *hyprlandOverrideBind) []string {
|
||||
@@ -1086,25 +1075,20 @@ func luaBindOptions(bind *hyprlandOverrideBind) []string {
|
||||
}
|
||||
|
||||
func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
|
||||
key := strconv.Quote(formatLuaBindKey(bind.Key))
|
||||
key := formatLuaBindKey(bind.Key)
|
||||
if bind.Unbind {
|
||||
fmt.Fprintf(sb, `hl.unbind(%s)`, key)
|
||||
fmt.Fprintf(sb, `hl.unbind("%s")`, key)
|
||||
sb.WriteByte('\n')
|
||||
return
|
||||
}
|
||||
var expr string
|
||||
if bind.RawLuaAction {
|
||||
expr = bind.Action
|
||||
} else {
|
||||
expr = luaActionStringFromHyprlangAction(bind.Action)
|
||||
}
|
||||
expr := luaActionStringFromHyprlangAction(bind.Action)
|
||||
opts := luaBindOptions(bind)
|
||||
fmt.Fprintf(sb, `hl.unbind(%s)`, key)
|
||||
fmt.Fprintf(sb, `hl.unbind("%s")`, key)
|
||||
sb.WriteByte('\n')
|
||||
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 {
|
||||
fmt.Fprintf(sb, `hl.bind(%s, %s)`, key, expr)
|
||||
fmt.Fprintf(sb, `hl.bind("%s", %s)`, key, expr)
|
||||
}
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
@@ -1120,18 +1104,17 @@ func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) {
|
||||
}
|
||||
internalKey := luaKeyComboToInternalKey(kbc)
|
||||
|
||||
action, isRawLua := luaExprToInternalAction(actionExpr)
|
||||
action := luaExprToInternalAction(actionExpr)
|
||||
flags := luaBindOptFlags(optSuffix)
|
||||
description := luaBindOptDescription(optSuffix)
|
||||
if description == "" {
|
||||
description = luaLineTrailingComment(line)
|
||||
}
|
||||
return &hyprlandOverrideBind{
|
||||
Key: internalKey,
|
||||
Action: action,
|
||||
Description: description,
|
||||
Flags: flags,
|
||||
RawLuaAction: isRawLua,
|
||||
Key: internalKey,
|
||||
Action: action,
|
||||
Description: description,
|
||||
Flags: flags,
|
||||
}, true
|
||||
}
|
||||
|
||||
@@ -1171,7 +1154,7 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := expandLuaConfigLines(strings.Split(string(data), "\n"))
|
||||
lines := strings.Split(string(data), "\n")
|
||||
parser := NewHyprlandParser("")
|
||||
pendingUnbinds := make(map[string]string)
|
||||
for _, line := range lines {
|
||||
|
||||
@@ -1,414 +0,0 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Lua configs can express binds dynamically: variables (mainMod .. " + C"),
|
||||
// tostring() calls, and numeric for loops (workspace binds). This resolves such
|
||||
// expressions to literal key combos so the static bind parser can read them.
|
||||
|
||||
const luaMaxLoopIterations = 1000
|
||||
|
||||
var (
|
||||
luaAssignRE = regexp.MustCompile(`^(?:local\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$`)
|
||||
luaForRE = regexp.MustCompile(`^for\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(-?\d+)\s*,\s*(-?\d+)\s*(?:,\s*(-?\d+)\s*)?do\b(.*)$`)
|
||||
luaTostringRE = regexp.MustCompile(`to(?:string|number)\s*\(\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|-?\d+(?:\.\d+)?)\s*\)`)
|
||||
luaBlockOpenRE = regexp.MustCompile(`\b(?:function|for|while|if)\b`)
|
||||
luaBlockCloseRE = regexp.MustCompile(`\bend\b`)
|
||||
luaNumberRE = regexp.MustCompile(`^-?\d+(?:\.\d+)?$`)
|
||||
)
|
||||
|
||||
type luaVarEnv map[string]string
|
||||
|
||||
type luaForHeader struct {
|
||||
varName string
|
||||
start int
|
||||
stop int
|
||||
step int
|
||||
inline string
|
||||
}
|
||||
|
||||
func expandLuaConfigLines(lines []string) []string {
|
||||
return expandLuaBlock(lines, luaVarEnv{})
|
||||
}
|
||||
|
||||
func expandLuaBlock(lines []string, env luaVarEnv) []string {
|
||||
out := make([]string, 0, len(lines))
|
||||
for i := 0; i < len(lines); i++ {
|
||||
code := strings.TrimSpace(luaStripLineComment(lines[i]))
|
||||
|
||||
if name, value, ok := parseLuaStringAssignment(code, env); ok {
|
||||
env[name] = value
|
||||
out = append(out, lines[i])
|
||||
continue
|
||||
}
|
||||
|
||||
header, ok := parseLuaForHeader(code)
|
||||
if !ok {
|
||||
out = append(out, resolveLuaDynamicLine(lines[i], env))
|
||||
continue
|
||||
}
|
||||
|
||||
body, consumed, complete := collectLuaForBody(header, lines, i)
|
||||
if !complete {
|
||||
out = append(out, resolveLuaDynamicLine(lines[i], env))
|
||||
continue
|
||||
}
|
||||
out = append(out, expandLuaForLoop(header, body, env)...)
|
||||
i = consumed
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseLuaStringAssignment(code string, env luaVarEnv) (name, value string, ok bool) {
|
||||
m := luaAssignRE.FindStringSubmatch(code)
|
||||
if m == nil {
|
||||
return "", "", false
|
||||
}
|
||||
value, ok = evalLuaConcat(m[2], env)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return m[1], value, true
|
||||
}
|
||||
|
||||
func parseLuaForHeader(code string) (luaForHeader, bool) {
|
||||
m := luaForRE.FindStringSubmatch(code)
|
||||
if m == nil {
|
||||
return luaForHeader{}, false
|
||||
}
|
||||
start, _ := strconv.Atoi(m[2])
|
||||
stop, _ := strconv.Atoi(m[3])
|
||||
step := 1
|
||||
if m[4] != "" {
|
||||
step, _ = strconv.Atoi(m[4])
|
||||
}
|
||||
if step == 0 {
|
||||
return luaForHeader{}, false
|
||||
}
|
||||
return luaForHeader{varName: m[1], start: start, stop: stop, step: step, inline: strings.TrimSpace(m[5])}, true
|
||||
}
|
||||
|
||||
func collectLuaForBody(header luaForHeader, lines []string, headerIdx int) (body []string, consumed int, complete bool) {
|
||||
depth := 1
|
||||
if header.inline != "" {
|
||||
delta := luaBlockDelta(header.inline)
|
||||
if depth+delta <= 0 {
|
||||
if stmt := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(header.inline), "end")); stmt != "" {
|
||||
body = append(body, stmt)
|
||||
}
|
||||
return body, headerIdx, true
|
||||
}
|
||||
depth += delta
|
||||
body = append(body, header.inline)
|
||||
}
|
||||
for j := headerIdx + 1; j < len(lines); j++ {
|
||||
delta := luaBlockDelta(lines[j])
|
||||
if depth+delta <= 0 {
|
||||
return body, j, true
|
||||
}
|
||||
depth += delta
|
||||
body = append(body, lines[j])
|
||||
}
|
||||
return nil, headerIdx, false
|
||||
}
|
||||
|
||||
func expandLuaForLoop(header luaForHeader, body []string, env luaVarEnv) []string {
|
||||
var out []string
|
||||
inRange := func(v int) bool {
|
||||
if header.step > 0 {
|
||||
return v <= header.stop
|
||||
}
|
||||
return v >= header.stop
|
||||
}
|
||||
count := 0
|
||||
for v := header.start; inRange(v); v += header.step {
|
||||
if count++; count > luaMaxLoopIterations {
|
||||
break
|
||||
}
|
||||
value := strconv.Itoa(v)
|
||||
iterLines := make([]string, len(body))
|
||||
for k, bl := range body {
|
||||
iterLines[k] = substituteLuaIdent(bl, header.varName, value)
|
||||
}
|
||||
out = append(out, expandLuaBlock(iterLines, cloneLuaEnv(env))...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func luaBlockDelta(line string) int {
|
||||
masked := luaMaskStrings(line)
|
||||
return len(luaBlockOpenRE.FindAllString(masked, -1)) - len(luaBlockCloseRE.FindAllString(masked, -1))
|
||||
}
|
||||
|
||||
func resolveLuaDynamicLine(line string, env luaVarEnv) string {
|
||||
if !strings.Contains(line, "hl.bind") && !strings.Contains(line, "hl.unbind") {
|
||||
return line
|
||||
}
|
||||
line = normalizeLuaToString(line)
|
||||
return rewriteLuaBindKeyArg(line, env)
|
||||
}
|
||||
|
||||
func normalizeLuaToString(line string) string {
|
||||
return luaTostringRE.ReplaceAllStringFunc(line, func(m string) string {
|
||||
inner := luaTostringRE.FindStringSubmatch(m)[1]
|
||||
if inner[0] == '"' || inner[0] == '\'' {
|
||||
return inner
|
||||
}
|
||||
return strconv.Quote(inner)
|
||||
})
|
||||
}
|
||||
|
||||
func rewriteLuaBindKeyArg(line string, env luaVarEnv) string {
|
||||
for _, fn := range []string{"hl.bind", "hl.unbind"} {
|
||||
idx := strings.Index(line, fn)
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
open := skipLuaWS(line, idx+len(fn))
|
||||
if open >= len(line) || line[open] != '(' {
|
||||
continue
|
||||
}
|
||||
argStart := skipLuaWS(line, open+1)
|
||||
expr, end, ok := parseLuaFirstArgExpr(line, argStart)
|
||||
if !ok || isLuaPlainStringArg(expr) {
|
||||
continue
|
||||
}
|
||||
value, ok := evalLuaConcat(expr, env)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
return line[:argStart] + strconv.Quote(value) + line[end:]
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func evalLuaConcat(expr string, env luaVarEnv) (string, bool) {
|
||||
parts := splitLuaConcat(expr)
|
||||
var sb strings.Builder
|
||||
for _, part := range parts {
|
||||
value, ok := evalLuaOperand(part, env)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
sb.WriteString(value)
|
||||
}
|
||||
return sb.String(), true
|
||||
}
|
||||
|
||||
func evalLuaOperand(op string, env luaVarEnv) (string, bool) {
|
||||
op = strings.TrimSpace(op)
|
||||
if op == "" {
|
||||
return "", false
|
||||
}
|
||||
switch op[0] {
|
||||
case '"', '\'':
|
||||
s, next, ok := parseLuaStringLiteral(op, 0)
|
||||
return s, next == len(op) && ok
|
||||
}
|
||||
if luaNumberRE.MatchString(op) {
|
||||
return op, true
|
||||
}
|
||||
if inner, ok := luaUnwrapCall(op, "tostring"); ok {
|
||||
return evalLuaConcat(inner, env)
|
||||
}
|
||||
if inner, ok := luaUnwrapCall(op, "tonumber"); ok {
|
||||
return evalLuaConcat(inner, env)
|
||||
}
|
||||
if value, ok := env[op]; ok {
|
||||
return value, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func splitLuaConcat(expr string) []string {
|
||||
var parts []string
|
||||
parenDepth, braceDepth, bracketDepth := 0, 0, 0
|
||||
inStr := byte(0)
|
||||
esc := false
|
||||
start := 0
|
||||
for i := 0; i < len(expr); i++ {
|
||||
c := expr[i]
|
||||
if inStr != 0 {
|
||||
switch {
|
||||
case esc:
|
||||
esc = false
|
||||
case c == '\\' && inStr == '"':
|
||||
esc = true
|
||||
case c == inStr:
|
||||
inStr = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '"', '\'':
|
||||
inStr = c
|
||||
case '(':
|
||||
parenDepth++
|
||||
case ')':
|
||||
if parenDepth > 0 {
|
||||
parenDepth--
|
||||
}
|
||||
case '{':
|
||||
braceDepth++
|
||||
case '}':
|
||||
if braceDepth > 0 {
|
||||
braceDepth--
|
||||
}
|
||||
case '[':
|
||||
bracketDepth++
|
||||
case ']':
|
||||
if bracketDepth > 0 {
|
||||
bracketDepth--
|
||||
}
|
||||
case '.':
|
||||
if parenDepth == 0 && braceDepth == 0 && bracketDepth == 0 && i+1 < len(expr) && expr[i+1] == '.' {
|
||||
parts = append(parts, expr[start:i])
|
||||
i++
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return append(parts, expr[start:])
|
||||
}
|
||||
|
||||
func substituteLuaIdent(line, name, value string) string {
|
||||
if !strings.Contains(line, name) {
|
||||
return line
|
||||
}
|
||||
var sb strings.Builder
|
||||
inStr := byte(0)
|
||||
esc := false
|
||||
for i := 0; i < len(line); {
|
||||
c := line[i]
|
||||
if inStr != 0 {
|
||||
sb.WriteByte(c)
|
||||
switch {
|
||||
case esc:
|
||||
esc = false
|
||||
case c == '\\' && inStr == '"':
|
||||
esc = true
|
||||
case c == inStr:
|
||||
inStr = 0
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if c == '"' || c == '\'' {
|
||||
inStr = c
|
||||
sb.WriteByte(c)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if isLuaIdentStart(c) {
|
||||
j := i + 1
|
||||
for j < len(line) && isLuaIdentByte(line[j]) {
|
||||
j++
|
||||
}
|
||||
word := line[i:j]
|
||||
if word == name && (i == 0 || line[i-1] != '.') {
|
||||
sb.WriteString(value)
|
||||
} else {
|
||||
sb.WriteString(word)
|
||||
}
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
sb.WriteByte(c)
|
||||
i++
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func luaMaskStrings(line string) string {
|
||||
b := []byte(line)
|
||||
inStr := byte(0)
|
||||
esc := false
|
||||
for i := 0; i < len(b); i++ {
|
||||
c := b[i]
|
||||
if inStr != 0 {
|
||||
wasEnd := !esc && c == inStr
|
||||
esc = !esc && c == '\\' && inStr == '"'
|
||||
b[i] = ' '
|
||||
if wasEnd {
|
||||
inStr = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '"', '\'':
|
||||
inStr = c
|
||||
b[i] = ' '
|
||||
case '-':
|
||||
if i+1 < len(b) && b[i+1] == '-' {
|
||||
for ; i < len(b); i++ {
|
||||
b[i] = ' '
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func luaStripLineComment(line string) string {
|
||||
inStr := byte(0)
|
||||
esc := false
|
||||
for i := 0; i+1 < len(line); i++ {
|
||||
c := line[i]
|
||||
if inStr != 0 {
|
||||
switch {
|
||||
case esc:
|
||||
esc = false
|
||||
case c == '\\' && inStr == '"':
|
||||
esc = true
|
||||
case c == inStr:
|
||||
inStr = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '"', '\'':
|
||||
inStr = c
|
||||
case '-':
|
||||
if line[i+1] == '-' {
|
||||
return line[:i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func luaUnwrapCall(op, fn string) (string, bool) {
|
||||
op = strings.TrimSpace(op)
|
||||
if !strings.HasPrefix(op, fn) {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.TrimSpace(op[len(fn):])
|
||||
if !strings.HasPrefix(rest, "(") || !strings.HasSuffix(rest, ")") {
|
||||
return "", false
|
||||
}
|
||||
return rest[1 : len(rest)-1], true
|
||||
}
|
||||
|
||||
func isLuaPlainStringArg(expr string) bool {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" || (expr[0] != '"' && expr[0] != '\'') {
|
||||
return false
|
||||
}
|
||||
_, next, ok := parseLuaStringLiteral(expr, 0)
|
||||
return ok && next == len(expr)
|
||||
}
|
||||
|
||||
func isLuaIdentStart(c byte) bool {
|
||||
return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||
}
|
||||
|
||||
func cloneLuaEnv(env luaVarEnv) luaVarEnv {
|
||||
clone := make(luaVarEnv, len(env))
|
||||
maps.Copy(clone, env)
|
||||
return clone
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExpandLuaConfigLinesVariableConcat(t *testing.T) {
|
||||
lines := []string{
|
||||
`local mainMod = "SUPER"`,
|
||||
`hl.bind(mainMod .. " + C", hl.dsp.window.close())`,
|
||||
`hl.bind(mainMod .. " + H", hl.dsp.focus({direction = "l"}))`,
|
||||
`hl.bind("ALT + TAB", hl.dsp.window.cycle_next({}))`,
|
||||
}
|
||||
got := expandLuaConfigLines(lines)
|
||||
|
||||
want := []string{
|
||||
`hl.bind("SUPER + C",`,
|
||||
`hl.bind("SUPER + H",`,
|
||||
`hl.bind("ALT + TAB",`,
|
||||
}
|
||||
joined := strings.Join(got, "\n")
|
||||
for _, w := range want {
|
||||
if !strings.Contains(joined, w) {
|
||||
t.Errorf("expanded output missing %q\n---\n%s", w, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandLuaConfigLinesForLoop(t *testing.T) {
|
||||
lines := []string{
|
||||
`local mainMod = "SUPER"`,
|
||||
`for i = 1, 3 do`,
|
||||
` hl.bind(mainMod .. " + " .. i, hl.dsp.focus({workspace = tostring(i)}))`,
|
||||
` hl.bind(mainMod .. " SHIFT + " .. i, hl.dsp.window.move({workspace = tostring(i)}))`,
|
||||
`end`,
|
||||
}
|
||||
got := strings.Join(expandLuaConfigLines(lines), "\n")
|
||||
|
||||
for _, w := range []string{
|
||||
`hl.bind("SUPER + 1",`,
|
||||
`hl.bind("SUPER + 2",`,
|
||||
`hl.bind("SUPER + 3",`,
|
||||
`hl.bind("SUPER SHIFT + 3",`,
|
||||
`{workspace = "1"}`,
|
||||
} {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Errorf("expanded loop missing %q\n---\n%s", w, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLuaLinesDynamicBinds(t *testing.T) {
|
||||
content := strings.Join([]string{
|
||||
`local mainMod = "SUPER"`,
|
||||
`hl.bind(mainMod .. " + C", hl.dsp.window.close())`,
|
||||
`hl.bind("ALT + TAB", hl.dsp.window.cycle_next({}))`,
|
||||
`for i = 1, 2 do`,
|
||||
` hl.bind(mainMod .. " + " .. i, hl.dsp.focus({workspace = tostring(i)}))`,
|
||||
`end`,
|
||||
}, "\n")
|
||||
|
||||
parser := NewHyprlandParser("")
|
||||
section, err := parser.parseLuaLines(content, "", "test.lua", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseLuaLines: %v", err)
|
||||
}
|
||||
|
||||
keys := map[string]*HyprlandKeyBinding{}
|
||||
for i := range section.Keybinds {
|
||||
kb := §ion.Keybinds[i]
|
||||
keys[parser.formatBindKey(kb)] = kb
|
||||
}
|
||||
|
||||
for _, want := range []string{"SUPER+C", "ALT+TAB", "SUPER+1", "SUPER+2"} {
|
||||
if _, ok := keys[want]; !ok {
|
||||
t.Errorf("missing bind %q; got %v", want, keysList(keys))
|
||||
}
|
||||
}
|
||||
if kb := keys["SUPER+C"]; kb != nil && kb.Dispatcher != "killactive" {
|
||||
t.Errorf("SUPER+C dispatcher = %q, want killactive", kb.Dispatcher)
|
||||
}
|
||||
if kb := keys["SUPER+1"]; kb != nil {
|
||||
if kb.Dispatcher != "workspace" || kb.Params != "1" {
|
||||
t.Errorf("SUPER+1 = %q %q, want workspace 1", kb.Dispatcher, kb.Params)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func keysList(m map[string]*HyprlandKeyBinding) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestEvalLuaConcat(t *testing.T) {
|
||||
env := luaVarEnv{"mainMod": "SUPER", "i": "5"}
|
||||
tests := []struct {
|
||||
expr string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{`mainMod .. " + C"`, "SUPER + C", true},
|
||||
{`mainMod .. " + " .. i`, "SUPER + 5", true},
|
||||
{`mainMod .. " + " .. tostring(i)`, "SUPER + 5", true},
|
||||
{`"ALT + TAB"`, "ALT + TAB", true},
|
||||
{`mainMod .. someFunc()`, "", false},
|
||||
{`unknownVar .. "x"`, "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, ok := evalLuaConcat(tt.expr, env)
|
||||
if ok != tt.ok || (ok && got != tt.want) {
|
||||
t.Errorf("evalLuaConcat(%q) = %q,%v want %q,%v", tt.expr, got, ok, tt.want, tt.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteLuaIdent(t *testing.T) {
|
||||
tests := []struct {
|
||||
line, name, value, want string
|
||||
}{
|
||||
{`hl.bind(m .. " + " .. i, x)`, "i", "3", `hl.bind(m .. " + " .. 3, x)`},
|
||||
{`hl.dsp.exec("light -i")`, "i", "3", `hl.dsp.exec("light -i")`},
|
||||
{`foo.i`, "i", "3", `foo.i`},
|
||||
{`tostring(i)`, "i", "3", `tostring(3)`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := substituteLuaIdent(tt.line, tt.name, tt.value); got != tt.want {
|
||||
t.Errorf("substituteLuaIdent(%q,%q,%q) = %q, want %q", tt.line, tt.name, tt.value, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -623,7 +623,7 @@ func (p *HyprlandParser) parseLuaLines(content string, baseDir, absPath, section
|
||||
prevSource := p.currentSource
|
||||
p.currentSource = absPath
|
||||
|
||||
lines := expandLuaConfigLines(strings.Split(content, "\n"))
|
||||
lines := strings.Split(content, "\n")
|
||||
boundInFile := make(map[string]bool)
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
@@ -147,10 +147,9 @@ hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"), { descripti
|
||||
func TestWriteLuaBindLineLeavesCustomLuaDispatcherRaw(t *testing.T) {
|
||||
var sb strings.Builder
|
||||
writeLuaBindLine(&sb, &hyprlandOverrideBind{
|
||||
Key: "Super+u",
|
||||
Action: "hl.dsp.no_op()",
|
||||
Description: "Custom Lua",
|
||||
RawLuaAction: true,
|
||||
Key: "Super+u",
|
||||
Action: "hl.dsp.no_op()",
|
||||
Description: "Custom Lua",
|
||||
})
|
||||
|
||||
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) {
|
||||
tests := []struct {
|
||||
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()")
|
||||
want := `function() hl.exec_cmd("hyprctl dispatch hl.dsp.no_op()") end`
|
||||
want := `hl.dsp.no_op()`
|
||||
if 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) {
|
||||
|
||||
@@ -19,20 +19,6 @@ type NiriProvider struct {
|
||||
parsed bool
|
||||
}
|
||||
|
||||
type niriActionPart struct {
|
||||
value string
|
||||
quoted bool
|
||||
}
|
||||
|
||||
var niriActionPropertyOrder = []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"}
|
||||
var niriActionProperties = map[string]struct{}{
|
||||
"focus": {},
|
||||
"show-pointer": {},
|
||||
"write-to-disk": {},
|
||||
"skip-confirmation": {},
|
||||
"delay-ms": {},
|
||||
}
|
||||
|
||||
func NewNiriProvider(configDir string) *NiriProvider {
|
||||
if configDir == "" {
|
||||
configDir = defaultNiriConfigDir()
|
||||
@@ -69,7 +55,6 @@ func (n *NiriProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
|
||||
sheet := &keybinds.CheatSheet{
|
||||
Title: "Niri Keybinds",
|
||||
Provider: n.Name(),
|
||||
ModKey: result.ModKey,
|
||||
Binds: categorizedBinds,
|
||||
DMSBindsIncluded: result.DMSBindsIncluded,
|
||||
}
|
||||
@@ -367,7 +352,7 @@ func (n *NiriProvider) buildActionFromNode(bindNode *document.Node) string {
|
||||
}
|
||||
|
||||
if actionNode.Properties != nil {
|
||||
for _, propName := range niriActionPropertyOrder {
|
||||
for _, propName := range []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"} {
|
||||
if val, ok := actionNode.Properties.Get(propName); ok {
|
||||
parts = append(parts, propName+"="+val.String())
|
||||
}
|
||||
@@ -455,10 +440,10 @@ func (n *NiriProvider) buildActionNode(action string) *document.Node {
|
||||
return node
|
||||
}
|
||||
|
||||
node.SetName(parts[0].value)
|
||||
node.SetName(parts[0])
|
||||
for _, arg := range parts[1:] {
|
||||
if n.isNiriActionPropertyToken(arg) {
|
||||
kv := strings.SplitN(arg.value, "=", 2)
|
||||
if strings.Contains(arg, "=") {
|
||||
kv := strings.SplitN(arg, "=", 2)
|
||||
switch kv[1] {
|
||||
case "true":
|
||||
node.AddProperty(kv[0], true, "")
|
||||
@@ -469,25 +454,13 @@ func (n *NiriProvider) buildActionNode(action string) *document.Node {
|
||||
}
|
||||
continue
|
||||
}
|
||||
node.AddArgument(arg.value, "")
|
||||
node.AddArgument(arg, "")
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func (n *NiriProvider) isNiriActionPropertyToken(part niriActionPart) bool {
|
||||
if part.quoted || !strings.Contains(part.value, "=") {
|
||||
return false
|
||||
}
|
||||
key, _, ok := strings.Cut(part.value, "=")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, ok = niriActionProperties[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (n *NiriProvider) parseActionParts(action string) []niriActionPart {
|
||||
var parts []niriActionPart
|
||||
func (n *NiriProvider) parseActionParts(action string) []string {
|
||||
var parts []string
|
||||
var current strings.Builder
|
||||
var inQuote, escaped, wasQuoted bool
|
||||
|
||||
@@ -503,7 +476,7 @@ func (n *NiriProvider) parseActionParts(action string) []niriActionPart {
|
||||
inQuote = !inQuote
|
||||
case r == ' ' && !inQuote:
|
||||
if current.Len() > 0 || wasQuoted {
|
||||
parts = append(parts, niriActionPart{value: current.String(), quoted: wasQuoted})
|
||||
parts = append(parts, current.String())
|
||||
current.Reset()
|
||||
wasQuoted = false
|
||||
}
|
||||
@@ -512,7 +485,7 @@ func (n *NiriProvider) parseActionParts(action string) []niriActionPart {
|
||||
}
|
||||
}
|
||||
if current.Len() > 0 || wasQuoted {
|
||||
parts = append(parts, niriActionPart{value: current.String(), quoted: wasQuoted})
|
||||
parts = append(parts, current.String())
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ type NiriSection struct {
|
||||
|
||||
type NiriParser struct {
|
||||
configDir string
|
||||
modKey string
|
||||
processedFiles map[string]bool
|
||||
bindMap map[string]*NiriKeyBinding
|
||||
bindOrder []string
|
||||
@@ -238,7 +237,6 @@ func isBraceAdjacentSpace(b byte) bool {
|
||||
func NewNiriParser(configDir string) *NiriParser {
|
||||
return &NiriParser{
|
||||
configDir: configDir,
|
||||
modKey: "Super",
|
||||
processedFiles: make(map[string]bool),
|
||||
bindMap: make(map[string]*NiriKeyBinding),
|
||||
bindOrder: []string{},
|
||||
@@ -379,8 +377,6 @@ func (p *NiriParser) processNodes(nodes []*document.Node, section *NiriSection,
|
||||
switch name {
|
||||
case "include":
|
||||
p.handleInclude(node, section, baseDir)
|
||||
case "input":
|
||||
p.handleInput(node)
|
||||
case "binds":
|
||||
p.extractBinds(node, section, "")
|
||||
case "recent-windows":
|
||||
@@ -389,19 +385,6 @@ func (p *NiriParser) processNodes(nodes []*document.Node, section *NiriSection,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *NiriParser) handleInput(node *document.Node) {
|
||||
for _, child := range node.Children {
|
||||
if child.Name.String() != "mod-key" || len(child.Arguments) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
modKey := strings.Trim(strings.TrimSpace(child.Arguments[0].String()), "\"")
|
||||
if modKey != "" {
|
||||
p.modKey = modKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *NiriParser) handleInclude(node *document.Node, section *NiriSection, baseDir string) {
|
||||
if len(node.Arguments) == 0 {
|
||||
return
|
||||
@@ -551,7 +534,6 @@ func (p *NiriParser) parseKeyCombo(combo string) ([]string, string) {
|
||||
|
||||
type NiriParseResult struct {
|
||||
Section *NiriSection
|
||||
ModKey string
|
||||
DMSBindsIncluded bool
|
||||
DMSStatus *DMSBindsStatusInfo
|
||||
ConflictingConfigs map[string]*NiriKeyBinding
|
||||
@@ -604,7 +586,6 @@ func ParseNiriKeys(configDir string) (*NiriParseResult, error) {
|
||||
}
|
||||
return &NiriParseResult{
|
||||
Section: section,
|
||||
ModKey: parser.modKey,
|
||||
DMSBindsIncluded: parser.HasDMSBindsIncluded(),
|
||||
DMSStatus: parser.buildDMSStatus(),
|
||||
ConflictingConfigs: parser.conflictingConfigs,
|
||||
|
||||
@@ -7,28 +7,6 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNiriParseModKey(t *testing.T) {
|
||||
config := `input {
|
||||
mod-key "Alt"
|
||||
}
|
||||
binds {
|
||||
Mod+T { spawn "kitty"; }
|
||||
}
|
||||
`
|
||||
tmpDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0o644); err != nil {
|
||||
t.Fatalf("Failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
result, err := ParseNiriKeys(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseNiriKeys failed: %v", err)
|
||||
}
|
||||
if result.ModKey != "Alt" {
|
||||
t.Errorf("ModKey = %q, want %q", result.ModKey, "Alt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNiriParse_NoSpaceBeforeBrace(t *testing.T) {
|
||||
config := `recent-windows {
|
||||
binds {
|
||||
|
||||
@@ -17,10 +17,7 @@ func TestNiriProviderGetCheatSheet(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configFile := filepath.Join(tmpDir, "config.kdl")
|
||||
|
||||
content := `input {
|
||||
mod-key "Alt"
|
||||
}
|
||||
binds {
|
||||
content := `binds {
|
||||
Mod+Q { close-window; }
|
||||
Mod+F { fullscreen-window; }
|
||||
Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; }
|
||||
@@ -48,10 +45,6 @@ binds {
|
||||
t.Errorf("Provider = %q, want %q", cheatSheet.Provider, "niri")
|
||||
}
|
||||
|
||||
if cheatSheet.ModKey != "Alt" {
|
||||
t.Errorf("ModKey = %q, want %q", cheatSheet.ModKey, "Alt")
|
||||
}
|
||||
|
||||
windowBinds := cheatSheet.Binds["Window"]
|
||||
if len(windowBinds) < 2 {
|
||||
t.Errorf("Expected at least 2 Window binds, got %d", len(windowBinds))
|
||||
@@ -234,58 +227,6 @@ func TestNiriGenerateBindsContent(t *testing.T) {
|
||||
expected: `binds {
|
||||
Mod+Space hotkey-overlay-title="Application Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; }
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "spawn with equals arg",
|
||||
binds: map[string]*overrideBind{
|
||||
"Mod+B": {
|
||||
Key: "Mod+B",
|
||||
Action: `spawn /opt/browser --profile-directory=Default`,
|
||||
},
|
||||
},
|
||||
expected: `binds {
|
||||
Mod+B { spawn "/opt/browser" "--profile-directory=Default"; }
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "spawn shell command with quoted equals args",
|
||||
binds: map[string]*overrideBind{
|
||||
"Mod+C": {
|
||||
Key: "Mod+C",
|
||||
Action: `spawn sh -c "chrome --profile-directory=Default --app=x"`,
|
||||
},
|
||||
},
|
||||
expected: `binds {
|
||||
Mod+C { spawn "sh" "-c" "chrome --profile-directory=Default --app=x"; }
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "spawn env assignment stays arg",
|
||||
binds: map[string]*overrideBind{
|
||||
"Mod+E": {
|
||||
Key: "Mod+E",
|
||||
Action: `spawn env FOO=bar mycmd`,
|
||||
},
|
||||
},
|
||||
expected: `binds {
|
||||
Mod+E { spawn "env" "FOO=bar" "mycmd"; }
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "niri action property remains property",
|
||||
binds: map[string]*overrideBind{
|
||||
"Print": {
|
||||
Key: "Print",
|
||||
Action: `screenshot show-pointer=false`,
|
||||
},
|
||||
},
|
||||
expected: `binds {
|
||||
Print { screenshot show-pointer=false; }
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -32,7 +32,6 @@ type DMSBindsStatus struct {
|
||||
type CheatSheet struct {
|
||||
Title string `json:"title"`
|
||||
Provider string `json:"provider"`
|
||||
ModKey string `json:"modKey,omitempty"`
|
||||
Binds map[string][]Keybind `json:"binds"`
|
||||
DMSBindsIncluded bool `json:"dmsBindsIncluded"`
|
||||
DMSStatus *DMSBindsStatus `json:"dmsStatus,omitempty"`
|
||||
|
||||
@@ -68,8 +68,6 @@ func GetQtLoggingRules() string {
|
||||
level = "info"
|
||||
}
|
||||
|
||||
// scene carries QML engine warnings (e.g. QQuickImage "Cannot open" cache
|
||||
// probes); suppressed except at debug level
|
||||
var rules []string
|
||||
switch strings.ToLower(level) {
|
||||
case "fatal":
|
||||
@@ -77,13 +75,13 @@ func GetQtLoggingRules() string {
|
||||
case "error":
|
||||
rules = []string{"*.debug=false", "*.info=false", "*.warning=false"}
|
||||
case "warn", "warning":
|
||||
rules = []string{"*.debug=false", "*.info=false", "scene.warning=false"}
|
||||
rules = []string{"*.debug=false", "*.info=false"}
|
||||
case "info":
|
||||
rules = []string{"*.debug=false", "scene.warning=false"}
|
||||
rules = []string{"*.debug=false"}
|
||||
case "debug":
|
||||
return ""
|
||||
default:
|
||||
rules = []string{"*.debug=false", "scene.warning=false"}
|
||||
rules = []string{"*.debug=false"}
|
||||
}
|
||||
|
||||
return strings.Join(rules, ";")
|
||||
@@ -124,9 +122,7 @@ func GetLogger() *Logger {
|
||||
logger = &Logger{base}
|
||||
|
||||
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
|
||||
logMu.Lock()
|
||||
_ = setLogFile(logger, path)
|
||||
logMu.Unlock()
|
||||
_ = SetLogFile(path)
|
||||
}
|
||||
})
|
||||
return logger
|
||||
@@ -147,18 +143,15 @@ func SetLevel(level string) {
|
||||
// profile when stderr is a TTY and route the file through ansiStripWriter so
|
||||
// the file stays plain while stderr keeps its colors.
|
||||
func SetLogFile(path string) error {
|
||||
l := GetLogger()
|
||||
logMu.Lock()
|
||||
defer logMu.Unlock()
|
||||
return setLogFile(l, path)
|
||||
}
|
||||
|
||||
func setLogFile(l *Logger, path string) error {
|
||||
if logFile != nil {
|
||||
logFile.Close()
|
||||
logFile = nil
|
||||
}
|
||||
|
||||
l := GetLogger()
|
||||
if path == "" {
|
||||
l.SetOutput(logStderr)
|
||||
applyColorProfile(l, logStderr)
|
||||
|
||||
@@ -118,50 +118,6 @@ type ColorsOutput struct {
|
||||
} `json:"colors"`
|
||||
}
|
||||
|
||||
type SchemePreview struct {
|
||||
Dark string `json:"dark"`
|
||||
Light string `json:"light"`
|
||||
}
|
||||
|
||||
var previewSchemeTypes = []string{
|
||||
"scheme-tonal-spot",
|
||||
"scheme-vibrant",
|
||||
"scheme-content",
|
||||
"scheme-expressive",
|
||||
"scheme-fidelity",
|
||||
"scheme-fruit-salad",
|
||||
"scheme-monochrome",
|
||||
"scheme-neutral",
|
||||
"scheme-rainbow",
|
||||
}
|
||||
|
||||
func PreviewSchemes(sourceColor string, contrast float64) (map[string]SchemePreview, error) {
|
||||
if sourceColor == "" {
|
||||
return nil, fmt.Errorf("source color is required")
|
||||
}
|
||||
|
||||
previews := make(map[string]SchemePreview, len(previewSchemeTypes))
|
||||
for _, schemeType := range previewSchemeTypes {
|
||||
output, err := runMatugenDryRun(&Options{
|
||||
Kind: "hex",
|
||||
Value: sourceColor,
|
||||
MatugenType: schemeType,
|
||||
Contrast: contrast,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("preview %s: %w", schemeType, err)
|
||||
}
|
||||
|
||||
dark := extractMatugenColor(output, "primary", "dark")
|
||||
light := extractMatugenColor(output, "primary", "light")
|
||||
if dark == "" || light == "" {
|
||||
return nil, fmt.Errorf("preview %s: primary colors missing from matugen output", schemeType)
|
||||
}
|
||||
previews[schemeType] = SchemePreview{Dark: dark, Light: light}
|
||||
}
|
||||
return previews, nil
|
||||
}
|
||||
|
||||
func (o *Options) ColorsOutput() string {
|
||||
return filepath.Join(o.StateDir, "dms-colors.json")
|
||||
}
|
||||
@@ -260,15 +216,15 @@ func Run(opts Options) error {
|
||||
return buildErr
|
||||
}
|
||||
|
||||
if opts.SyncModeWithPortal {
|
||||
syncColorScheme(opts.Mode)
|
||||
}
|
||||
|
||||
if !changed {
|
||||
log.Info("No color changes detected, skipping refresh")
|
||||
return ErrNoChanges
|
||||
}
|
||||
|
||||
if opts.SyncModeWithPortal {
|
||||
syncColorScheme(opts.Mode)
|
||||
}
|
||||
|
||||
log.Info("Done")
|
||||
return nil
|
||||
}
|
||||
@@ -699,7 +655,6 @@ func redetectMatugenVersion(old matugenFlags) (matugenFlags, bool) {
|
||||
|
||||
func detectMatugenVersionLocked() (matugenFlags, error) {
|
||||
cmd := exec.Command("matugen", "--version")
|
||||
cmd.Env = utils.EnvWithUserBinPath(nil)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return matugenFlags{}, fmt.Errorf("failed to get matugen version: %w", err)
|
||||
@@ -756,7 +711,6 @@ func runMatugen(baseArgs []string) error {
|
||||
|
||||
args := buildMatugenArgs(baseArgs, flags)
|
||||
cmd := exec.Command("matugen", args...)
|
||||
cmd.Env = utils.EnvWithUserBinPath(nil)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
runErr := cmd.Run()
|
||||
@@ -774,7 +728,6 @@ func runMatugen(baseArgs []string) error {
|
||||
log.Warnf("Matugen version changed (v4: %v -> %v), retrying", flags.isV4, newFlags.isV4)
|
||||
args = buildMatugenArgs(baseArgs, newFlags)
|
||||
retryCmd := exec.Command("matugen", args...)
|
||||
retryCmd.Env = utils.EnvWithUserBinPath(nil)
|
||||
retryCmd.Stdout = os.Stdout
|
||||
retryCmd.Stderr = os.Stderr
|
||||
return retryCmd.Run()
|
||||
@@ -817,7 +770,6 @@ func execDryRun(opts *Options, flags matugenFlags) (string, error) {
|
||||
}
|
||||
|
||||
cmd := exec.Command("matugen", baseArgs...)
|
||||
cmd.Env = utils.EnvWithUserBinPath(nil)
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
output, err := cmd.Output()
|
||||
@@ -916,26 +868,7 @@ func refreshGTK(mode ColorMode) {
|
||||
}
|
||||
}
|
||||
|
||||
var colorSchemeEchoHook func(scheme string)
|
||||
|
||||
func SetColorSchemeEchoHook(hook func(scheme string)) {
|
||||
colorSchemeEchoHook = hook
|
||||
}
|
||||
|
||||
func expectColorSchemeEcho(scheme string) {
|
||||
if colorSchemeEchoHook != nil {
|
||||
colorSchemeEchoHook(scheme)
|
||||
}
|
||||
}
|
||||
|
||||
// The color-scheme round trip is the only mechanism that makes running GTK4
|
||||
// apps reload ~/.config/gtk-4.0 CSS (a gtk-theme flip does not). But apps
|
||||
// following the portal color-scheme (Chromium) can drop the restore signal
|
||||
// mid-repaint and latch the wrong mode, so this is opt-in.
|
||||
func refreshGTK4() {
|
||||
if os.Getenv("DMS_ENABLE_GTK4_REFRESH") != "1" {
|
||||
return
|
||||
}
|
||||
output, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme")
|
||||
if err != nil {
|
||||
return
|
||||
@@ -949,13 +882,11 @@ func refreshGTK4() {
|
||||
toggle = "prefer-dark"
|
||||
}
|
||||
|
||||
expectColorSchemeEcho(toggle)
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", toggle); err != nil {
|
||||
log.Warnf("Failed to toggle color-scheme for GTK4 refresh: %v", err)
|
||||
return
|
||||
}
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
expectColorSchemeEcho(current)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", current); err != nil {
|
||||
log.Warnf("Failed to restore color-scheme for GTK4 refresh: %v", err)
|
||||
}
|
||||
@@ -1006,13 +937,6 @@ func syncColorScheme(mode ColorMode) {
|
||||
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 {
|
||||
log.Warnf("Failed to sync color-scheme: %v", err)
|
||||
}
|
||||
@@ -1063,9 +987,6 @@ func closestAdwaitaAccent(primaryHex string) string {
|
||||
|
||||
func syncAccentColor(primaryHex string) {
|
||||
accent := closestAdwaitaAccent(primaryHex)
|
||||
if cur, err := utils.GsettingsGet("org.gnome.desktop.interface", "accent-color"); err == nil && strings.Trim(cur, "'") == accent {
|
||||
return
|
||||
}
|
||||
log.Infof("Setting GNOME accent color: %s", accent)
|
||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "accent-color", accent); err != nil {
|
||||
log.Warnf("Failed to set accent-color: %v", err)
|
||||
|
||||
+51
-508
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -33,32 +32,6 @@ const (
|
||||
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{
|
||||
"system-auth",
|
||||
"common-auth",
|
||||
@@ -70,11 +43,10 @@ var includedPamAuthFiles = []string{
|
||||
}
|
||||
|
||||
type AuthSettings struct {
|
||||
EnableFprint bool `json:"enableFprint"`
|
||||
EnableU2f bool `json:"enableU2f"`
|
||||
GreeterEnableFprint bool `json:"greeterEnableFprint"`
|
||||
GreeterEnableU2f bool `json:"greeterEnableU2f"`
|
||||
GreeterPamExternallyManaged bool `json:"greeterPamExternallyManaged"`
|
||||
EnableFprint bool `json:"enableFprint"`
|
||||
EnableU2f bool `json:"enableU2f"`
|
||||
GreeterEnableFprint bool `json:"greeterEnableFprint"`
|
||||
GreeterEnableU2f bool `json:"greeterEnableU2f"`
|
||||
}
|
||||
|
||||
type SyncAuthOptions struct {
|
||||
@@ -103,50 +75,10 @@ type lockscreenPamIncludeDirective struct {
|
||||
}
|
||||
|
||||
type lockscreenPamResolver struct {
|
||||
baseDirs []string
|
||||
pamDir string
|
||||
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 {
|
||||
return syncDeps{
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -451,10 +375,32 @@ func parseLockscreenPamIncludeDirective(trimmed string, inheritedFilter string)
|
||||
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) {
|
||||
path, err := r.locate(serviceName)
|
||||
path, err := resolveLockscreenPamIncludePath(r.pamDir, serviceName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read PAM file %s: %w", serviceName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, seen := range stack {
|
||||
@@ -512,74 +458,30 @@ func (r lockscreenPamResolver) resolveService(serviceName string, filterType str
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func resolvedLinesHaveAuth(lines []string) bool {
|
||||
for _, line := range lines {
|
||||
if pamDirectiveType(strings.TrimSpace(line)) == "auth" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
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
|
||||
func buildManagedLockscreenPamContent(pamDir string, readFile func(string) ([]byte, error)) (string, error) {
|
||||
resolver := lockscreenPamResolver{
|
||||
pamDir: pamDir,
|
||||
readFile: readFile,
|
||||
}
|
||||
|
||||
// Shared building blocks for distros without a login service (openSUSE,
|
||||
// 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()
|
||||
resolvedLines, err := resolver.resolveService("login", "", nil)
|
||||
if err != nil {
|
||||
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
|
||||
b.WriteString("#%PAM-1.0\n")
|
||||
@@ -592,365 +494,6 @@ func buildManagedLockscreenPamContent(baseDirs []string, readFile func(string) (
|
||||
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
|
||||
authModules []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)
|
||||
if lineType == "auth" {
|
||||
acc.authModules = append(acc.authModules, 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 ValidateLockscreenU2fPamService(name string) LockscreenPamValidation {
|
||||
return validateLockscreenU2fPam(name, "", defaultValidateDeps())
|
||||
}
|
||||
|
||||
func ValidateLockscreenU2fPamPath(path string) LockscreenPamValidation {
|
||||
return validateLockscreenU2fPam("", 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 validateLockscreenU2fPam(serviceName string, path string, deps lockscreenPamValidateDeps) LockscreenPamValidation {
|
||||
result := validateLockscreenPam(serviceName, path, deps)
|
||||
if result.Path == "" {
|
||||
return result
|
||||
}
|
||||
|
||||
resolver := lockscreenPamResolver{baseDirs: deps.baseDirs, readFile: deps.readFile}
|
||||
analysis := resolver.analyzePath(result.Path)
|
||||
if analysis.err != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
filteredWarnings := result.Warnings[:0]
|
||||
for _, warning := range result.Warnings {
|
||||
if strings.Contains(warning, "pam_u2f is present") && strings.Contains(warning, "double-prompt") {
|
||||
continue
|
||||
}
|
||||
filteredWarnings = append(filteredWarnings, warning)
|
||||
}
|
||||
result.Warnings = filteredWarnings
|
||||
|
||||
hasU2fAuth := false
|
||||
unsafeModules := []string{}
|
||||
unsafeSeen := map[string]bool{}
|
||||
for _, ref := range analysis.authModules {
|
||||
name := filepath.Base(ref)
|
||||
if name == "pam_u2f.so" {
|
||||
hasU2fAuth = true
|
||||
continue
|
||||
}
|
||||
switch name {
|
||||
case "pam_env.so", "pam_faildelay.so", "pam_nologin.so":
|
||||
continue
|
||||
default:
|
||||
if !unsafeSeen[name] {
|
||||
unsafeSeen[name] = true
|
||||
unsafeModules = append(unsafeModules, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasU2fAuth {
|
||||
result.Errors = append(result.Errors, "no pam_u2f auth directive found; select a dedicated security-key PAM service")
|
||||
}
|
||||
for _, name := range unsafeModules {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("additional auth module %s is not allowed in a dedicated security-key PAM service", name))
|
||||
}
|
||||
for _, name := range result.MissingModules {
|
||||
if strings.Contains(name, "pam_u2f") {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("%s is not installed or its configured path is unavailable", name))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
var b strings.Builder
|
||||
b.WriteString("#%PAM-1.0\n")
|
||||
@@ -965,7 +508,7 @@ func buildManagedLockscreenU2FPamContent() string {
|
||||
|
||||
func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error {
|
||||
if deps.isNixOS() {
|
||||
logFunc("ℹ NixOS detected. DMS does not write /etc/pam.d/dankshell; the lock screen uses a sanitized password-only service in the user state directory unless you select a custom PAM source.")
|
||||
logFunc("ℹ NixOS detected. DMS continues to use /etc/pam.d/login for lock screen password auth on NixOS unless you declare security.pam.services.dankshell yourself. U2F and fingerprint are handled separately and should not be included in dankshell.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -979,7 +522,7 @@ func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string,
|
||||
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 {
|
||||
return fmt.Errorf("failed to build %s from %s: %w", deps.dankshellPath, filepath.Join(deps.pamDir, "login"), err)
|
||||
}
|
||||
@@ -1182,7 +725,7 @@ func syncGreeterPamConfigWithDeps(logFunc func(string), sudoPassword string, set
|
||||
if wantFprint || wantU2f {
|
||||
blockLines := []string{GreeterPamManagedBlockStart}
|
||||
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 {
|
||||
blockLines = append(blockLines, "auth sufficient pam_u2f.so cue nouserok timeout=10")
|
||||
|
||||
@@ -246,31 +246,6 @@ func TestBuildManagedLockscreenPamContent(t *testing.T) {
|
||||
"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",
|
||||
files: map[string]string{
|
||||
@@ -306,7 +281,7 @@ func TestBuildManagedLockscreenPamContent(t *testing.T) {
|
||||
env.writePamFile(t, name, content)
|
||||
}
|
||||
|
||||
content, err := buildManagedLockscreenPamContent([]string{env.pamDir}, os.ReadFile)
|
||||
content, err := buildManagedLockscreenPamContent(env.pamDir, os.ReadFile)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -611,8 +400,8 @@ func TestSyncLockscreenPamConfigWithDeps(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("syncLockscreenPamConfigWithDeps returned error on NixOS path: %v", err)
|
||||
}
|
||||
if len(logs) == 0 || !strings.Contains(logs[0], "NixOS detected") || !strings.Contains(logs[0], "sanitized password-only service") {
|
||||
t.Fatalf("expected NixOS informational log describing the user-state fallback, got %v", logs)
|
||||
if len(logs) == 0 || !strings.Contains(logs[0], "NixOS detected") || !strings.Contains(logs[0], "/etc/pam.d/login") {
|
||||
t.Fatalf("expected NixOS informational log mentioning /etc/pam.d/login, got %v", logs)
|
||||
}
|
||||
if _, err := os.Stat(env.dankshellPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected no dankshell file to be written on NixOS path, stat err = %v", err)
|
||||
@@ -723,7 +512,7 @@ func TestSyncGreeterPamConfigWithDeps(t *testing.T) {
|
||||
got := readFileString(t, env.greetdPath)
|
||||
for _, want := range []string{
|
||||
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",
|
||||
GreeterPamManagedBlockEnd,
|
||||
} {
|
||||
@@ -786,301 +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 TestValidateLockscreenU2fPam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("accepts a dedicated U2F stack with custom options", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.availableModules["pam_u2f.so"] = true
|
||||
env.writePamFile(t, "dankshell-u2f", "#%PAM-1.0\nauth required pam_u2f.so cue authfile=/etc/u2f-mappings\naccount required pam_permit.so\n")
|
||||
|
||||
result := validateLockscreenU2fPam("dankshell-u2f", "", env.validateDeps())
|
||||
if !result.Valid {
|
||||
t.Fatalf("expected valid dedicated U2F stack, got %+v", result)
|
||||
}
|
||||
if !result.InlineU2f {
|
||||
t.Fatalf("expected inline U2F detection, got %+v", result)
|
||||
}
|
||||
if containsSubstr(result.Warnings, "double-prompt") {
|
||||
t.Fatalf("dedicated U2F validation should not warn about its expected U2F module: %v", result.Warnings)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects a primary login stack that also prompts for a password", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.availableModules["pam_unix.so"] = true
|
||||
env.availableModules["pam_u2f.so"] = true
|
||||
env.writePamFile(t, "login", "#%PAM-1.0\nauth required pam_unix.so\nauth required pam_u2f.so cue\naccount required pam_unix.so\n")
|
||||
|
||||
result := validateLockscreenU2fPam("login", "", env.validateDeps())
|
||||
if result.Valid {
|
||||
t.Fatalf("expected mixed password/U2F stack to be rejected, got %+v", result)
|
||||
}
|
||||
if !containsSubstr(result.Errors, "pam_unix.so") || !containsSubstr(result.Errors, "dedicated security-key") {
|
||||
t.Fatalf("expected actionable mixed-stack error, got %v", result.Errors)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects a stack without pam_u2f", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.availableModules["pam_unix.so"] = true
|
||||
env.writePamFile(t, "password-only", "#%PAM-1.0\nauth required pam_unix.so\n")
|
||||
|
||||
result := validateLockscreenU2fPam("password-only", "", env.validateDeps())
|
||||
if result.Valid || !containsSubstr(result.Errors, "pam_u2f") {
|
||||
t.Fatalf("expected missing-U2F error, got %+v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does not accept a similarly named module as pam_u2f", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.availableModules["pam_u2f_helper.so"] = true
|
||||
env.writePamFile(t, "not-u2f", "#%PAM-1.0\nauth required pam_u2f_helper.so\n")
|
||||
|
||||
result := validateLockscreenU2fPam("not-u2f", "", env.validateDeps())
|
||||
if result.Valid || !containsSubstr(result.Errors, "no pam_u2f auth directive") {
|
||||
t.Fatalf("expected exact pam_u2f module validation, got %+v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects a missing pam_u2f module", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.writePamFile(t, "dankshell-u2f", "#%PAM-1.0\nauth required pam_u2f.so cue\n")
|
||||
|
||||
result := validateLockscreenU2fPam("dankshell-u2f", "", env.validateDeps())
|
||||
if result.Valid || !containsSubstr(result.Errors, "pam_u2f.so is not installed") {
|
||||
t.Fatalf("expected missing-module error, got %+v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1135,7 +629,7 @@ func TestSyncAuthConfigWithDeps(t *testing.T) {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if strings.Contains(greetd, "auth sufficient pam_u2f.so cue nouserok timeout=10") {
|
||||
@@ -1143,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.Parallel()
|
||||
|
||||
|
||||
@@ -64,20 +64,7 @@ func (m *Manager) findInstalledPath(pluginID string) (string, error) {
|
||||
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) {
|
||||
if !isSafePluginPathComponent(pluginID) {
|
||||
return "", fmt.Errorf("invalid plugin id: %q", pluginID)
|
||||
}
|
||||
|
||||
// First, check if folder with exact ID name exists
|
||||
exactPath := filepath.Join(dir, pluginID)
|
||||
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) {
|
||||
if !isSafePluginPathComponent(idOrName) {
|
||||
return "", fmt.Errorf("invalid plugin id/name: %q", idOrName)
|
||||
}
|
||||
|
||||
// Check exact folder name match first
|
||||
exactPath := filepath.Join(dir, idOrName)
|
||||
if exists, _ := afero.DirExists(m.fs, exactPath); exists {
|
||||
@@ -571,50 +554,35 @@ func (m *Manager) findInDirByIDOrName(dir, idOrName string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (hasUpdates bool, diffURL string, err error) {
|
||||
func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (bool, error) {
|
||||
pluginPath, err := m.findInstalledPath(pluginID)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("failed to find plugin: %w", err)
|
||||
return false, fmt.Errorf("failed to find plugin: %w", err)
|
||||
}
|
||||
|
||||
if pluginPath == "" {
|
||||
return false, "", fmt.Errorf("plugin not installed: %s", pluginID)
|
||||
return false, fmt.Errorf("plugin not installed: %s", pluginID)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
|
||||
return false, "", nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
metaPath := pluginPath + ".meta"
|
||||
metaExists, err := afero.Exists(m.fs, metaPath)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("failed to check metadata: %w", err)
|
||||
return false, fmt.Errorf("failed to check metadata: %w", err)
|
||||
}
|
||||
|
||||
var hasUp bool
|
||||
var localHash, remoteHash string
|
||||
if metaExists {
|
||||
// Plugin is from a monorepo, check the repo directory
|
||||
reposDir := filepath.Join(m.pluginsDir, ".repos")
|
||||
repoName := m.getRepoName(plugin.Repo)
|
||||
repoPath := filepath.Join(reposDir, repoName)
|
||||
|
||||
hasUp, localHash, remoteHash, err = m.gitClient.HasUpdates(repoPath)
|
||||
} else {
|
||||
// Plugin is a standalone repo
|
||||
hasUp, localHash, remoteHash, err = m.gitClient.HasUpdates(pluginPath)
|
||||
return m.gitClient.HasUpdates(repoPath)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
diffURL = plugin.Repo
|
||||
if diffURL != "" {
|
||||
diffURL = strings.TrimSuffix(diffURL, ".git")
|
||||
if hasUp && len(localHash) >= 7 && len(remoteHash) >= 7 {
|
||||
diffURL = fmt.Sprintf("%s/compare/%s...%s", diffURL, localHash[:7], remoteHash[:7])
|
||||
}
|
||||
}
|
||||
return hasUp, diffURL, nil
|
||||
// Plugin is a standalone repo
|
||||
return m.gitClient.HasUpdates(pluginPath)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ type Plugin struct {
|
||||
type GitClient interface {
|
||||
PlainClone(path string, url string) error
|
||||
Pull(path string) error
|
||||
HasUpdates(path string) (hasUpdates bool, localHash string, remoteHash string, err error)
|
||||
HasUpdates(path string) (bool, error)
|
||||
}
|
||||
|
||||
type realGitClient struct{}
|
||||
@@ -65,10 +65,10 @@ func (g *realGitClient) Pull(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||
func (g *realGitClient) HasUpdates(path string) (bool, error) {
|
||||
repo, err := git.PlainOpen(path)
|
||||
if err != nil {
|
||||
return false, "", "", err
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Fetch remote changes
|
||||
@@ -76,24 +76,24 @@ func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||
if err != nil && err.Error() != "already up-to-date" {
|
||||
// If fetch fails, we can't determine if there are updates
|
||||
// Return false and the error
|
||||
return false, "", "", err
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Get the HEAD reference
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
return false, "", "", err
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Get the remote HEAD reference (typically origin/HEAD or origin/main or origin/master)
|
||||
remote, err := repo.Remote("origin")
|
||||
if err != nil {
|
||||
return false, "", "", err
|
||||
return false, err
|
||||
}
|
||||
|
||||
refs, err := remote.List(&git.ListOptions{})
|
||||
if err != nil {
|
||||
return false, "", "", err
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Find the default branch remote ref
|
||||
@@ -108,14 +108,13 @@ func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
localHash := head.Hash().String()
|
||||
// If we couldn't find a remote HEAD, assume no updates
|
||||
if remoteHead == "" {
|
||||
return false, localHash, "", nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Compare local HEAD with remote HEAD
|
||||
return localHash != remoteHead, localHash, remoteHead, nil
|
||||
return head.Hash().String() != remoteHead, nil
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
type mockGitClient struct {
|
||||
cloneFunc func(path string, url string) error
|
||||
pullFunc func(path string) error
|
||||
hasUpdatesFunc func(path string) (bool, string, string, error)
|
||||
hasUpdatesFunc func(path string) (bool, error)
|
||||
}
|
||||
|
||||
func (m *mockGitClient) PlainClone(path string, url string) error {
|
||||
@@ -30,11 +30,11 @@ func (m *mockGitClient) Pull(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||
func (m *mockGitClient) HasUpdates(path string) (bool, error) {
|
||||
if m.hasUpdatesFunc != nil {
|
||||
return m.hasUpdatesFunc(path)
|
||||
}
|
||||
return false, "", "", nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func TestNewRegistry(t *testing.T) {
|
||||
|
||||
@@ -135,14 +135,15 @@ func EscapeSingleQuotes(s string) string {
|
||||
}
|
||||
|
||||
// MakeCommand returns a bash command string that runs `command` with the
|
||||
// detected tool, prompting interactively on a TTY where applicable. The
|
||||
// sudo-with-password case lives in ExecCommand, which pipes the password via
|
||||
// stdin so it never lands in argv.
|
||||
// detected tool. When the tool supports stdin passwords and password is
|
||||
// non-empty, the password is piped in. Otherwise the tool is invoked with
|
||||
// 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
|
||||
// message so callers that treat the *exec.Cmd as infallible still fail
|
||||
// deterministically.
|
||||
func MakeCommand(command string) string {
|
||||
func MakeCommand(password, command string) string {
|
||||
t, err := Detect()
|
||||
if err != nil {
|
||||
return failingShell(err)
|
||||
@@ -150,6 +151,9 @@ func MakeCommand(command string) string {
|
||||
|
||||
switch t {
|
||||
case ToolSudo:
|
||||
if password != "" {
|
||||
return fmt.Sprintf("echo '%s' | sudo -S %s", EscapeSingleQuotes(password), command)
|
||||
}
|
||||
return fmt.Sprintf("sudo %s", command)
|
||||
case ToolDoas:
|
||||
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
|
||||
// detected tool. Detection errors surface at Run() time as a failing
|
||||
// command writing a clear error to stderr. A sudo password is piped via
|
||||
// stdin (sudo -S) so it never appears in argv.
|
||||
// command writing a clear error to stderr.
|
||||
func ExecCommand(ctx context.Context, password, command string) *exec.Cmd {
|
||||
t, err := Detect()
|
||||
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))
|
||||
return exec.CommandContext(ctx, "bash", "-c", MakeCommand(password, command))
|
||||
}
|
||||
|
||||
// ExecArgv builds an exec.Cmd that runs argv as root via the detected tool.
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
// Generated by go-wayland-scanner
|
||||
// https://github.com/yaslama/go-wayland/cmd/go-wayland-scanner
|
||||
// XML file : internal/proto/xml/virtual-keyboard-unstable-v1.xml
|
||||
//
|
||||
// virtual_keyboard_unstable_v1 Protocol Copyright:
|
||||
//
|
||||
// Copyright © 2008-2011 Kristian Høgsberg
|
||||
// Copyright © 2010-2013 Intel Corporation
|
||||
// Copyright © 2012-2013 Collabora, Ltd.
|
||||
// Copyright © 2018 Purism SPC
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
// copy of this software and associated documentation files (the "Software"),
|
||||
// to deal in the Software without restriction, including without limitation
|
||||
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
// and/or sell copies of the Software, and to permit persons to whom the
|
||||
// Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice (including the next
|
||||
// paragraph) shall be included in all copies or substantial portions of the
|
||||
// Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
|
||||
package virtual_keyboard
|
||||
|
||||
import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// ZwpVirtualKeyboardV1InterfaceName is the name of the interface as it appears in the [client.Registry].
|
||||
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
|
||||
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
|
||||
const ZwpVirtualKeyboardV1InterfaceName = "zwp_virtual_keyboard_v1"
|
||||
|
||||
// ZwpVirtualKeyboardV1 : virtual keyboard
|
||||
//
|
||||
// The virtual keyboard provides an application with requests which emulate
|
||||
// the behaviour of a physical keyboard.
|
||||
//
|
||||
// This interface can be used by clients on its own to provide raw input
|
||||
// events, or it can accompany the input method protocol.
|
||||
type ZwpVirtualKeyboardV1 struct {
|
||||
client.BaseProxy
|
||||
}
|
||||
|
||||
// NewZwpVirtualKeyboardV1 : virtual keyboard
|
||||
//
|
||||
// The virtual keyboard provides an application with requests which emulate
|
||||
// the behaviour of a physical keyboard.
|
||||
//
|
||||
// This interface can be used by clients on its own to provide raw input
|
||||
// events, or it can accompany the input method protocol.
|
||||
func NewZwpVirtualKeyboardV1(ctx *client.Context) *ZwpVirtualKeyboardV1 {
|
||||
zwpVirtualKeyboardV1 := &ZwpVirtualKeyboardV1{}
|
||||
ctx.Register(zwpVirtualKeyboardV1)
|
||||
return zwpVirtualKeyboardV1
|
||||
}
|
||||
|
||||
// Keymap : keyboard mapping
|
||||
//
|
||||
// Provide a file descriptor to the compositor which can be
|
||||
// memory-mapped to provide a keyboard mapping description.
|
||||
//
|
||||
// Format carries a value from the keymap_format enumeration.
|
||||
//
|
||||
// format: keymap format
|
||||
// fd: keymap file descriptor
|
||||
// size: keymap size, in bytes
|
||||
func (i *ZwpVirtualKeyboardV1) Keymap(format uint32, fd int, size uint32) error {
|
||||
const opcode = 0
|
||||
const _reqBufLen = 8 + 4 + 4
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(format))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(size))
|
||||
l += 4
|
||||
oob := unix.UnixRights(int(fd))
|
||||
err := i.Context().WriteMsg(_reqBuf[:], oob)
|
||||
return err
|
||||
}
|
||||
|
||||
// Key : key event
|
||||
//
|
||||
// A key was pressed or released.
|
||||
// The time argument is a timestamp with millisecond granularity, with an
|
||||
// undefined base. All requests regarding a single object must share the
|
||||
// same clock.
|
||||
//
|
||||
// Keymap must be set before issuing this request.
|
||||
//
|
||||
// State carries a value from the key_state enumeration.
|
||||
//
|
||||
// time: timestamp with millisecond granularity
|
||||
// key: key that produced the event
|
||||
// state: physical state of the key
|
||||
func (i *ZwpVirtualKeyboardV1) Key(time, key, state uint32) error {
|
||||
const opcode = 1
|
||||
const _reqBufLen = 8 + 4 + 4 + 4
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(time))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(key))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(state))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Modifiers : modifier and group state
|
||||
//
|
||||
// Notifies the compositor that the modifier and/or group state has
|
||||
// changed, and it should update state.
|
||||
//
|
||||
// The client should use wl_keyboard.modifiers event to synchronize its
|
||||
// internal state with seat state.
|
||||
//
|
||||
// Keymap must be set before issuing this request.
|
||||
//
|
||||
// modsDepressed: depressed modifiers
|
||||
// modsLatched: latched modifiers
|
||||
// modsLocked: locked modifiers
|
||||
// group: keyboard layout
|
||||
func (i *ZwpVirtualKeyboardV1) Modifiers(modsDepressed, modsLatched, modsLocked, group uint32) error {
|
||||
const opcode = 2
|
||||
const _reqBufLen = 8 + 4 + 4 + 4 + 4
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(modsDepressed))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(modsLatched))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(modsLocked))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(group))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Destroy : destroy the virtual keyboard keyboard object
|
||||
func (i *ZwpVirtualKeyboardV1) Destroy() error {
|
||||
defer i.Context().Unregister(i)
|
||||
const opcode = 3
|
||||
const _reqBufLen = 8
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
type ZwpVirtualKeyboardV1Error uint32
|
||||
|
||||
// ZwpVirtualKeyboardV1Error :
|
||||
const (
|
||||
// ZwpVirtualKeyboardV1ErrorNoKeymap : No keymap was set
|
||||
ZwpVirtualKeyboardV1ErrorNoKeymap ZwpVirtualKeyboardV1Error = 0
|
||||
)
|
||||
|
||||
func (e ZwpVirtualKeyboardV1Error) Name() string {
|
||||
switch e {
|
||||
case ZwpVirtualKeyboardV1ErrorNoKeymap:
|
||||
return "no_keymap"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e ZwpVirtualKeyboardV1Error) Value() string {
|
||||
switch e {
|
||||
case ZwpVirtualKeyboardV1ErrorNoKeymap:
|
||||
return "0"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e ZwpVirtualKeyboardV1Error) String() string {
|
||||
return e.Name() + "=" + e.Value()
|
||||
}
|
||||
|
||||
// ZwpVirtualKeyboardManagerV1InterfaceName is the name of the interface as it appears in the [client.Registry].
|
||||
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
|
||||
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
|
||||
const ZwpVirtualKeyboardManagerV1InterfaceName = "zwp_virtual_keyboard_manager_v1"
|
||||
|
||||
// ZwpVirtualKeyboardManagerV1 : virtual keyboard manager
|
||||
//
|
||||
// A virtual keyboard manager allows an application to provide keyboard
|
||||
// input events as if they came from a physical keyboard.
|
||||
type ZwpVirtualKeyboardManagerV1 struct {
|
||||
client.BaseProxy
|
||||
}
|
||||
|
||||
// NewZwpVirtualKeyboardManagerV1 : virtual keyboard manager
|
||||
//
|
||||
// A virtual keyboard manager allows an application to provide keyboard
|
||||
// input events as if they came from a physical keyboard.
|
||||
func NewZwpVirtualKeyboardManagerV1(ctx *client.Context) *ZwpVirtualKeyboardManagerV1 {
|
||||
zwpVirtualKeyboardManagerV1 := &ZwpVirtualKeyboardManagerV1{}
|
||||
ctx.Register(zwpVirtualKeyboardManagerV1)
|
||||
return zwpVirtualKeyboardManagerV1
|
||||
}
|
||||
|
||||
// CreateVirtualKeyboard : Create a new virtual keyboard
|
||||
//
|
||||
// Creates a new virtual keyboard associated to a seat.
|
||||
//
|
||||
// If the compositor enables a keyboard to perform arbitrary actions, it
|
||||
// should present an error when an untrusted client requests a new
|
||||
// keyboard.
|
||||
func (i *ZwpVirtualKeyboardManagerV1) CreateVirtualKeyboard(seat *client.Seat) (*ZwpVirtualKeyboardV1, error) {
|
||||
id := NewZwpVirtualKeyboardV1(i.Context())
|
||||
const opcode = 0
|
||||
const _reqBufLen = 8 + 4 + 4
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], seat.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], id.ID())
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (i *ZwpVirtualKeyboardManagerV1) Destroy() error {
|
||||
i.Context().Unregister(i)
|
||||
return nil
|
||||
}
|
||||
|
||||
type ZwpVirtualKeyboardManagerV1Error uint32
|
||||
|
||||
// ZwpVirtualKeyboardManagerV1Error :
|
||||
const (
|
||||
// ZwpVirtualKeyboardManagerV1ErrorUnauthorized : client not authorized to use the interface
|
||||
ZwpVirtualKeyboardManagerV1ErrorUnauthorized ZwpVirtualKeyboardManagerV1Error = 0
|
||||
)
|
||||
|
||||
func (e ZwpVirtualKeyboardManagerV1Error) Name() string {
|
||||
switch e {
|
||||
case ZwpVirtualKeyboardManagerV1ErrorUnauthorized:
|
||||
return "unauthorized"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e ZwpVirtualKeyboardManagerV1Error) Value() string {
|
||||
switch e {
|
||||
case ZwpVirtualKeyboardManagerV1ErrorUnauthorized:
|
||||
return "0"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e ZwpVirtualKeyboardManagerV1Error) String() string {
|
||||
return e.Name() + "=" + e.Value()
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="virtual_keyboard_unstable_v1">
|
||||
<copyright>
|
||||
Copyright © 2008-2011 Kristian Høgsberg
|
||||
Copyright © 2010-2013 Intel Corporation
|
||||
Copyright © 2012-2013 Collabora, Ltd.
|
||||
Copyright © 2018 Purism SPC
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice (including the next
|
||||
paragraph) shall be included in all copies or substantial portions of the
|
||||
Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
</copyright>
|
||||
|
||||
<interface name="zwp_virtual_keyboard_v1" version="1">
|
||||
<description summary="virtual keyboard">
|
||||
The virtual keyboard provides an application with requests which emulate
|
||||
the behaviour of a physical keyboard.
|
||||
|
||||
This interface can be used by clients on its own to provide raw input
|
||||
events, or it can accompany the input method protocol.
|
||||
</description>
|
||||
|
||||
<request name="keymap">
|
||||
<description summary="keyboard mapping">
|
||||
Provide a file descriptor to the compositor which can be
|
||||
memory-mapped to provide a keyboard mapping description.
|
||||
|
||||
Format carries a value from the keymap_format enumeration.
|
||||
</description>
|
||||
<arg name="format" type="uint" summary="keymap format"/>
|
||||
<arg name="fd" type="fd" summary="keymap file descriptor"/>
|
||||
<arg name="size" type="uint" summary="keymap size, in bytes"/>
|
||||
</request>
|
||||
|
||||
<enum name="error">
|
||||
<entry name="no_keymap" value="0" summary="No keymap was set"/>
|
||||
</enum>
|
||||
|
||||
<request name="key">
|
||||
<description summary="key event">
|
||||
A key was pressed or released.
|
||||
The time argument is a timestamp with millisecond granularity, with an
|
||||
undefined base. All requests regarding a single object must share the
|
||||
same clock.
|
||||
|
||||
Keymap must be set before issuing this request.
|
||||
|
||||
State carries a value from the key_state enumeration.
|
||||
</description>
|
||||
<arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
|
||||
<arg name="key" type="uint" summary="key that produced the event"/>
|
||||
<arg name="state" type="uint" summary="physical state of the key"/>
|
||||
</request>
|
||||
|
||||
<request name="modifiers">
|
||||
<description summary="modifier and group state">
|
||||
Notifies the compositor that the modifier and/or group state has
|
||||
changed, and it should update state.
|
||||
|
||||
The client should use wl_keyboard.modifiers event to synchronize its
|
||||
internal state with seat state.
|
||||
|
||||
Keymap must be set before issuing this request.
|
||||
</description>
|
||||
<arg name="mods_depressed" type="uint" summary="depressed modifiers"/>
|
||||
<arg name="mods_latched" type="uint" summary="latched modifiers"/>
|
||||
<arg name="mods_locked" type="uint" summary="locked modifiers"/>
|
||||
<arg name="group" type="uint" summary="keyboard layout"/>
|
||||
</request>
|
||||
|
||||
<request name="destroy" type="destructor" since="1">
|
||||
<description summary="destroy the virtual keyboard keyboard object"/>
|
||||
</request>
|
||||
</interface>
|
||||
|
||||
<interface name="zwp_virtual_keyboard_manager_v1" version="1">
|
||||
<description summary="virtual keyboard manager">
|
||||
A virtual keyboard manager allows an application to provide keyboard
|
||||
input events as if they came from a physical keyboard.
|
||||
</description>
|
||||
|
||||
<enum name="error">
|
||||
<entry name="unauthorized" value="0" summary="client not authorized to use the interface"/>
|
||||
</enum>
|
||||
|
||||
<request name="create_virtual_keyboard">
|
||||
<description summary="Create a new virtual keyboard">
|
||||
Creates a new virtual keyboard associated to a seat.
|
||||
|
||||
If the compositor enables a keyboard to perform arbitrary actions, it
|
||||
should present an error when an untrusted client requests a new
|
||||
keyboard.
|
||||
</description>
|
||||
<arg name="seat" type="object" interface="wl_seat"/>
|
||||
<arg name="id" type="new_id" interface="zwp_virtual_keyboard_v1"/>
|
||||
</request>
|
||||
</interface>
|
||||
</protocol>
|
||||
@@ -1,25 +0,0 @@
|
||||
package qmlchecks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGreeterExternalAuthStatusUsesEffectiveFingerprintAvailability(t *testing.T) {
|
||||
data, err := os.ReadFile("../../../quickshell/Modules/Greetd/GreeterContent.qml")
|
||||
if err != nil {
|
||||
t.Fatalf("read greeter QML: %v", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
for _, required := range []string{
|
||||
"readonly property bool greeterPamHasExternalAuth: greeterPamHasFprint || greeterPamHasU2f",
|
||||
"if (greeterPamHasFprint && greeterPamHasU2f)",
|
||||
"if (greeterPamHasFprint)",
|
||||
} {
|
||||
if !strings.Contains(content, required) {
|
||||
t.Fatalf("greeter external-auth status must contain %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,51 +23,3 @@ func TestLockScreenPasswordFieldBypassesTextInputIME(t *testing.T) {
|
||||
t.Fatalf("passwordField should handle physical key text manually instead of relying on a text input control")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockScreenAuthenticationCardOwnsFactorControls(t *testing.T) {
|
||||
data, err := os.ReadFile("../../../quickshell/Modules/Settings/LockScreenTab.qml")
|
||||
if err != nil {
|
||||
t.Fatalf("read lock screen settings QML: %v", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
authCard := strings.Index(content, `title: I18n.tr("Authentication")`)
|
||||
behaviorCard := strings.Index(content, `title: I18n.tr("Behavior")`)
|
||||
fingerprintToggle := strings.Index(content, `settingKey: "enableFprint"`)
|
||||
u2fToggle := strings.Index(content, `settingKey: "enableU2f"`)
|
||||
u2fSource := strings.Index(content, `settingKey: "lockU2fPamPath"`)
|
||||
if authCard < 0 || behaviorCard < 0 || fingerprintToggle < 0 || u2fToggle < 0 || u2fSource < 0 {
|
||||
t.Fatalf("expected authentication card, factor toggles, and U2F source setting")
|
||||
}
|
||||
for name, position := range map[string]int{
|
||||
"fingerprint toggle": fingerprintToggle,
|
||||
"U2F toggle": u2fToggle,
|
||||
"U2F source": u2fSource,
|
||||
} {
|
||||
if position < authCard || position > behaviorCard {
|
||||
t.Fatalf("%s must remain in the authentication card", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockScreenPamSupportsManagedAndSystemPolicies(t *testing.T) {
|
||||
data, err := os.ReadFile("../../../quickshell/Modules/Lock/Pam.qml")
|
||||
if err != nil {
|
||||
t.Fatalf("read lock screen PAM QML: %v", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
for _, required := range []string{
|
||||
"SettingsData.lockPamExternallyManaged",
|
||||
"SettingsData.lockU2fPamPath",
|
||||
"customU2fPamActive",
|
||||
"u2fSuppressedByPrimaryPam",
|
||||
} {
|
||||
if !strings.Contains(content, required) {
|
||||
t.Fatalf("lock screen PAM must contain %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(content, "runningFromNixStore || resolveUserPam.running") {
|
||||
t.Fatalf("DMS-managed policy must generate the sanitized user PAM stack on Nix-store installs")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -30,30 +30,41 @@ func DetectCompositor() Compositor {
|
||||
return detectedCompositor
|
||||
}
|
||||
|
||||
candidates := []struct {
|
||||
socket string
|
||||
needsStat bool
|
||||
compositor Compositor
|
||||
}{
|
||||
{os.Getenv("MANGO_INSTANCE_SIGNATURE"), true, CompositorMango},
|
||||
{os.Getenv("NIRI_SOCKET"), true, CompositorNiri},
|
||||
{os.Getenv("SCROLLSOCK"), true, CompositorScroll},
|
||||
{os.Getenv("MIRACLESOCK"), true, CompositorMiracle},
|
||||
{os.Getenv("SWAYSOCK"), true, CompositorSway},
|
||||
{os.Getenv("HYPRLAND_INSTANCE_SIGNATURE"), false, CompositorHyprland},
|
||||
}
|
||||
hyprlandSig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE")
|
||||
niriSocket := os.Getenv("NIRI_SOCKET")
|
||||
swaySocket := os.Getenv("SWAYSOCK")
|
||||
scrollSocket := os.Getenv("SCROLLSOCK")
|
||||
miracleSocket := os.Getenv("MIRACLESOCK")
|
||||
mangoSocket := os.Getenv("MANGO_INSTANCE_SIGNATURE")
|
||||
|
||||
// A stale env var from a previous session must not mask the live compositor
|
||||
for _, c := range candidates {
|
||||
if c.socket == "" {
|
||||
continue
|
||||
switch {
|
||||
case mangoSocket != "":
|
||||
if _, err := os.Stat(mangoSocket); err == nil {
|
||||
detectedCompositor = CompositorMango
|
||||
return detectedCompositor
|
||||
}
|
||||
if c.needsStat {
|
||||
if _, err := os.Stat(c.socket); err != nil {
|
||||
continue
|
||||
}
|
||||
case niriSocket != "":
|
||||
if _, err := os.Stat(niriSocket); err == nil {
|
||||
detectedCompositor = CompositorNiri
|
||||
return detectedCompositor
|
||||
}
|
||||
detectedCompositor = c.compositor
|
||||
case scrollSocket != "":
|
||||
if _, err := os.Stat(scrollSocket); err == nil {
|
||||
detectedCompositor = CompositorScroll
|
||||
return detectedCompositor
|
||||
}
|
||||
case miracleSocket != "":
|
||||
if _, err := os.Stat(miracleSocket); err == nil {
|
||||
detectedCompositor = CompositorMiracle
|
||||
return detectedCompositor
|
||||
}
|
||||
case swaySocket != "":
|
||||
if _, err := os.Stat(swaySocket); err == nil {
|
||||
detectedCompositor = CompositorSway
|
||||
return detectedCompositor
|
||||
}
|
||||
case hyprlandSig != "":
|
||||
detectedCompositor = CompositorHyprland
|
||||
return detectedCompositor
|
||||
}
|
||||
|
||||
@@ -79,7 +90,7 @@ func GetActiveWindow() (*WindowGeometry, error) {
|
||||
case CompositorMango:
|
||||
return getMangoActiveWindow()
|
||||
default:
|
||||
return nil, fmt.Errorf("window capture requires Hyprland, Mango, or niri")
|
||||
return nil, fmt.Errorf("window capture requires Hyprland or Mango")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,58 +54,6 @@ func BufferToImageWithFormat(buf *ShmBuffer, format uint32) *image.RGBA {
|
||||
return img
|
||||
}
|
||||
|
||||
func ImageToBuffer(img image.Image) (*ShmBuffer, error) {
|
||||
bounds := img.Bounds()
|
||||
w, h := bounds.Dx(), bounds.Dy()
|
||||
buf, err := CreateShmBuffer(w, h, w*4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := buf.Data()
|
||||
switch src := img.(type) {
|
||||
case *image.NRGBA:
|
||||
for y := range h {
|
||||
srcOff := y * src.Stride
|
||||
dstOff := y * buf.Stride
|
||||
for x := range w {
|
||||
si, di := srcOff+x*4, dstOff+x*4
|
||||
a := uint32(src.Pix[si+3])
|
||||
data[di+0] = uint8(uint32(src.Pix[si+2]) * a / 255)
|
||||
data[di+1] = uint8(uint32(src.Pix[si+1]) * a / 255)
|
||||
data[di+2] = uint8(uint32(src.Pix[si+0]) * a / 255)
|
||||
data[di+3] = uint8(a)
|
||||
}
|
||||
}
|
||||
case *image.RGBA:
|
||||
for y := range h {
|
||||
srcOff := y * src.Stride
|
||||
dstOff := y * buf.Stride
|
||||
for x := range w {
|
||||
si, di := srcOff+x*4, dstOff+x*4
|
||||
data[di+0] = src.Pix[si+2]
|
||||
data[di+1] = src.Pix[si+1]
|
||||
data[di+2] = src.Pix[si+0]
|
||||
data[di+3] = src.Pix[si+3]
|
||||
}
|
||||
}
|
||||
default:
|
||||
for y := range h {
|
||||
dstOff := y * buf.Stride
|
||||
for x := range w {
|
||||
cr, cg, cb, ca := img.At(bounds.Min.X+x, bounds.Min.Y+y).RGBA()
|
||||
di := dstOff + x*4
|
||||
data[di+0] = uint8(cb >> 8)
|
||||
data[di+1] = uint8(cg >> 8)
|
||||
data[di+2] = uint8(cr >> 8)
|
||||
data[di+3] = uint8(ca >> 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
buf.Format = FormatARGB8888
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func EncodePNG(w io.Writer, img image.Image) error {
|
||||
enc := png.Encoder{CompressionLevel: png.BestSpeed}
|
||||
return enc.Encode(w, img)
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
package screenshot
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const niriScreenshotTimeout = 5 * time.Second
|
||||
|
||||
// CaptureNiriWindowImage captures the focused window through niri's
|
||||
// ScreenshotWindow action; niri replies before writing the file, so a second
|
||||
// event-stream connection waits for ScreenshotCaptured. niri also copies the
|
||||
// capture to its own clipboard, which cannot be disabled.
|
||||
func CaptureNiriWindowImage(showPointer bool) (image.Image, error) {
|
||||
socket := os.Getenv("NIRI_SOCKET")
|
||||
if socket == "" {
|
||||
return nil, fmt.Errorf("NIRI_SOCKET not set")
|
||||
}
|
||||
|
||||
dir := os.Getenv("XDG_RUNTIME_DIR")
|
||||
if dir == "" {
|
||||
dir = os.TempDir()
|
||||
}
|
||||
path := filepath.Join(dir, fmt.Sprintf("dms-window-%d.png", os.Getpid()))
|
||||
|
||||
events, err := subscribeNiriEvents(socket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer events.Close()
|
||||
|
||||
if err := requestNiriWindowScreenshot(socket, path, showPointer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.Remove(path)
|
||||
|
||||
if err := awaitNiriScreenshot(events, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open niri screenshot: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
img, err := png.Decode(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode niri screenshot: %w", err)
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func subscribeNiriEvents(socket string) (net.Conn, error) {
|
||||
conn, err := net.DialTimeout("unix", socket, 2*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect niri socket: %w", err)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(niriScreenshotTimeout))
|
||||
|
||||
if _, err := conn.Write([]byte("\"EventStream\"\n")); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("subscribe niri events: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func awaitNiriScreenshot(events net.Conn, path string) error {
|
||||
scanner := bufio.NewScanner(events)
|
||||
scanner.Buffer(make([]byte, 0, 64<<10), 1<<20)
|
||||
|
||||
for scanner.Scan() {
|
||||
var event struct {
|
||||
ScreenshotCaptured *struct {
|
||||
Path string `json:"path"`
|
||||
} `json:"ScreenshotCaptured"`
|
||||
}
|
||||
if json.Unmarshal(scanner.Bytes(), &event) != nil {
|
||||
continue
|
||||
}
|
||||
if event.ScreenshotCaptured != nil && event.ScreenshotCaptured.Path == path {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("await niri screenshot: %w", err)
|
||||
}
|
||||
return fmt.Errorf("niri event stream closed before screenshot completed")
|
||||
}
|
||||
|
||||
func requestNiriWindowScreenshot(socket, path string, showPointer bool) error {
|
||||
conn, err := net.DialTimeout("unix", socket, 2*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect niri socket: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
|
||||
request := map[string]any{
|
||||
"Action": map[string]any{
|
||||
"ScreenshotWindow": map[string]any{
|
||||
"id": nil,
|
||||
"write_to_disk": true,
|
||||
"show_pointer": showPointer,
|
||||
"path": path,
|
||||
},
|
||||
},
|
||||
}
|
||||
payload, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := conn.Write(append(payload, '\n')); err != nil {
|
||||
return fmt.Errorf("niri request: %w", err)
|
||||
}
|
||||
|
||||
line, err := bufio.NewReader(conn).ReadBytes('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("niri reply: %w", err)
|
||||
}
|
||||
|
||||
var reply map[string]json.RawMessage
|
||||
if err := json.Unmarshal(line, &reply); err != nil {
|
||||
return fmt.Errorf("parse niri reply: %w", err)
|
||||
}
|
||||
if raw, ok := reply["Err"]; ok {
|
||||
var msg string
|
||||
_ = json.Unmarshal(raw, &msg)
|
||||
return fmt.Errorf("niri screenshot: %s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Screenshoter) captureNiriWindow() (*CaptureResult, error) {
|
||||
img, err := CaptureNiriWindowImage(s.config.Cursor == CursorOn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
buf, err := ImageToBuffer(img)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scale := 1.0
|
||||
if output := s.findOutputByName(GetFocusedMonitor()); output != nil {
|
||||
scale = output.effectiveScale()
|
||||
}
|
||||
|
||||
return &CaptureResult{
|
||||
Buffer: buf,
|
||||
YInverted: false,
|
||||
Format: uint32(FormatARGB8888),
|
||||
Scale: scale,
|
||||
}, nil
|
||||
}
|
||||
@@ -95,9 +95,6 @@ type RegionSelector struct {
|
||||
showCapturedCursor bool
|
||||
shiftHeld bool
|
||||
|
||||
phase selectorPhase
|
||||
scroll *scrollSession
|
||||
|
||||
running bool
|
||||
cancelled bool
|
||||
result Region
|
||||
@@ -170,32 +167,20 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
|
||||
|
||||
r.running = true
|
||||
for r.running {
|
||||
if err := r.dispatchOrTick(); err != nil {
|
||||
if err := r.ctx.Dispatch(); err != nil {
|
||||
return nil, false, fmt.Errorf("dispatch: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if r.scroll != nil && r.scroll.abortErr != nil {
|
||||
return nil, false, r.scroll.abortErr
|
||||
}
|
||||
|
||||
if r.cancelled || r.capturedBuffer == nil {
|
||||
return nil, r.cancelled, nil
|
||||
}
|
||||
|
||||
yInverted := false
|
||||
var format uint32
|
||||
scale := 1.0
|
||||
if r.selection.surface != nil {
|
||||
yInverted = r.selection.surface.yInverted
|
||||
format = r.selection.surface.screenFormat
|
||||
if s := r.selection.surface.output.fractionalScale; s > 0 {
|
||||
scale = s
|
||||
}
|
||||
}
|
||||
if r.scroll != nil {
|
||||
yInverted = false
|
||||
format = uint32(r.scroll.format)
|
||||
}
|
||||
|
||||
return &CaptureResult{
|
||||
@@ -203,7 +188,6 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
|
||||
Region: r.result,
|
||||
YInverted: yInverted,
|
||||
Format: format,
|
||||
Scale: scale,
|
||||
}, false, nil
|
||||
}
|
||||
|
||||
@@ -707,9 +691,7 @@ func (r *RegionSelector) initRenderBuffer(os *OutputSurface) {
|
||||
}
|
||||
slot.pool = pool
|
||||
|
||||
// niri latches surface opacity from the first buffer's format
|
||||
// (observed), so slots are ARGB from the start with A=255 when opaque
|
||||
wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), alphaFormat(os.screenFormat))
|
||||
wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), os.screenFormat)
|
||||
if err != nil {
|
||||
log.Error("create render slot wl_buffer failed", "err", err)
|
||||
pool.Destroy()
|
||||
@@ -751,9 +733,8 @@ func (r *RegionSelector) applyPreSelection(os *OutputSurface) {
|
||||
|
||||
x1 := float64(r.preSelect.X-os.output.x) * scaleX
|
||||
y1 := float64(r.preSelect.Y-os.output.y) * scaleY
|
||||
// selection edges are inclusive; the exclusive width edge is one device px past it
|
||||
x2 := float64(r.preSelect.X-os.output.x+r.preSelect.Width)*scaleX - scaleX
|
||||
y2 := float64(r.preSelect.Y-os.output.y+r.preSelect.Height)*scaleY - scaleY
|
||||
x2 := float64(r.preSelect.X-os.output.x+r.preSelect.Width) * scaleX
|
||||
y2 := float64(r.preSelect.Y-os.output.y+r.preSelect.Height) * scaleY
|
||||
|
||||
r.selection.hasSelection = true
|
||||
r.selection.dragging = false
|
||||
@@ -783,13 +764,10 @@ func (r *RegionSelector) redrawSurface(os *OutputSurface) {
|
||||
return
|
||||
}
|
||||
|
||||
switch r.phase {
|
||||
case phaseScroll:
|
||||
r.drawScrollOverlay(os, slot.shm)
|
||||
default:
|
||||
slot.shm.CopyFrom(srcBuf)
|
||||
r.drawOverlay(os, slot.shm)
|
||||
}
|
||||
slot.shm.CopyFrom(srcBuf)
|
||||
|
||||
// Draw overlay (dimming + selection) into this slot
|
||||
r.drawOverlay(os, slot.shm)
|
||||
|
||||
if os.viewport != nil {
|
||||
_ = os.wlSurface.SetBufferScale(1)
|
||||
@@ -825,8 +803,6 @@ func (r *RegionSelector) cleanup() {
|
||||
r.cursorBuffer.Close()
|
||||
}
|
||||
|
||||
r.cleanupScroll()
|
||||
|
||||
for _, os := range r.surfaces {
|
||||
for _, slot := range os.slots {
|
||||
if slot == nil {
|
||||
|
||||
@@ -94,20 +94,6 @@ func (r *RegionSelector) setupPointerHandlers() {
|
||||
return
|
||||
}
|
||||
|
||||
if r.phase == phaseScroll {
|
||||
if e.Button != 0x110 || e.State != 1 || r.activeSurface != r.selection.surface {
|
||||
return
|
||||
}
|
||||
switch r.scrollBarHit(r.pointerX, r.pointerY) {
|
||||
case "done":
|
||||
r.finishScroll()
|
||||
case "cancel":
|
||||
r.cancelled = true
|
||||
r.running = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch e.Button {
|
||||
case 0x110: // BTN_LEFT
|
||||
switch e.State {
|
||||
@@ -149,17 +135,6 @@ func (r *RegionSelector) setupKeyboardHandlers() {
|
||||
return
|
||||
}
|
||||
|
||||
if r.phase == phaseScroll {
|
||||
switch e.Key {
|
||||
case 1:
|
||||
r.cancelled = true
|
||||
r.running = false
|
||||
case 28, 96:
|
||||
r.finishScroll()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch e.Key {
|
||||
case 1:
|
||||
r.cancelled = true
|
||||
@@ -177,15 +152,17 @@ func (r *RegionSelector) setupKeyboardHandlers() {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *RegionSelector) selectionDeviceRect() (*OutputSurface, int, int, int, int) {
|
||||
func (r *RegionSelector) finishSelection() {
|
||||
if r.selection.surface == nil {
|
||||
return nil, 0, 0, 0, 0
|
||||
r.running = false
|
||||
return
|
||||
}
|
||||
|
||||
os := r.selection.surface
|
||||
srcBuf := r.getSourceBuffer(os)
|
||||
if srcBuf == nil {
|
||||
return nil, 0, 0, 0, 0
|
||||
r.running = false
|
||||
return
|
||||
}
|
||||
|
||||
x1, y1 := r.selection.anchorX, r.selection.anchorY
|
||||
@@ -204,10 +181,24 @@ func (r *RegionSelector) selectionDeviceRect() (*OutputSurface, int, int, int, i
|
||||
scaleY = float64(srcBuf.Height) / float64(os.logicalH)
|
||||
}
|
||||
|
||||
bx1 := clamp(int(x1*scaleX), 0, srcBuf.Width)
|
||||
by1 := clamp(int(y1*scaleY), 0, srcBuf.Height)
|
||||
bx2 := clamp(int(x2*scaleX), 0, srcBuf.Width)
|
||||
by2 := clamp(int(y2*scaleY), 0, srcBuf.Height)
|
||||
bx1 := int(x1 * scaleX)
|
||||
by1 := int(y1 * scaleY)
|
||||
bx2 := int(x2 * scaleX)
|
||||
by2 := int(y2 * scaleY)
|
||||
|
||||
// Clamp to buffer bounds
|
||||
if bx1 < 0 {
|
||||
bx1 = 0
|
||||
}
|
||||
if by1 < 0 {
|
||||
by1 = 0
|
||||
}
|
||||
if bx2 > srcBuf.Width {
|
||||
bx2 = srcBuf.Width
|
||||
}
|
||||
if by2 > srcBuf.Height {
|
||||
by2 = srcBuf.Height
|
||||
}
|
||||
|
||||
w, h := bx2-bx1+1, by2-by1+1
|
||||
if r.shiftHeld && w != h {
|
||||
@@ -224,23 +215,7 @@ func (r *RegionSelector) selectionDeviceRect() (*OutputSurface, int, int, int, i
|
||||
h = 1
|
||||
}
|
||||
|
||||
return os, bx1, by1, w, h
|
||||
}
|
||||
|
||||
func (r *RegionSelector) finishSelection() {
|
||||
os, bx1, by1, w, h := r.selectionDeviceRect()
|
||||
if os == nil {
|
||||
r.running = false
|
||||
return
|
||||
}
|
||||
|
||||
if r.screenshoter != nil && r.screenshoter.config.Mode == ModeScroll {
|
||||
r.enterScrollPhase(os, bx1, by1, w, h)
|
||||
return
|
||||
}
|
||||
|
||||
srcBuf := r.getSourceBuffer(os)
|
||||
|
||||
// Create cropped buffer and copy pixels directly
|
||||
cropped, err := CreateShmBuffer(w, h, w*4)
|
||||
if err != nil {
|
||||
r.running = false
|
||||
|
||||
@@ -57,7 +57,7 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
|
||||
w, h := renderBuf.Width, renderBuf.Height
|
||||
format := os.screenFormat
|
||||
|
||||
// dim, forcing alpha: the X-format source's padding byte is undefined
|
||||
// Dim the entire buffer
|
||||
for y := 0; y < h; y++ {
|
||||
off := y * stride
|
||||
for x := 0; x < w; x++ {
|
||||
@@ -68,7 +68,6 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
|
||||
data[i+0] = uint8(int(data[i+0]) * 3 / 5)
|
||||
data[i+1] = uint8(int(data[i+1]) * 3 / 5)
|
||||
data[i+2] = uint8(int(data[i+2]) * 3 / 5)
|
||||
data[i+3] = 255
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +110,7 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
|
||||
data[di+0] = srcData[si+0]
|
||||
data[di+1] = srcData[si+1]
|
||||
data[di+2] = srcData[si+2]
|
||||
data[di+3] = 255
|
||||
data[di+3] = srcData[si+3]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,81 +126,6 @@ func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
|
||||
r.drawDimensions(data, stride, w, h, bx1, by1, selW, selH, format)
|
||||
}
|
||||
|
||||
func (r *RegionSelector) drawScrollOverlay(os *OutputSurface, renderBuf *ShmBuffer) {
|
||||
data := renderBuf.Data()
|
||||
stride := renderBuf.Stride
|
||||
w, h := renderBuf.Width, renderBuf.Height
|
||||
|
||||
// 40% premultiplied scrim
|
||||
for y := 0; y < h; y++ {
|
||||
off := y * stride
|
||||
for x := 0; x < w; x++ {
|
||||
i := off + x*4
|
||||
if i+3 >= len(data) {
|
||||
continue
|
||||
}
|
||||
data[i+0], data[i+1], data[i+2], data[i+3] = 0, 0, 0, 102
|
||||
}
|
||||
}
|
||||
|
||||
s := r.scroll
|
||||
if s == nil || r.selection.surface != os {
|
||||
return
|
||||
}
|
||||
|
||||
// hole oversized 2px so overlay pixels never land in captured frames
|
||||
holeX := s.holeX - 2
|
||||
holeY := s.holeY - 2
|
||||
holeW := s.holeW + 4
|
||||
holeH := s.holeH + 4
|
||||
|
||||
x1 := clamp(holeX, 0, w)
|
||||
y1 := clamp(holeY, 0, h)
|
||||
x2 := clamp(holeX+holeW, 0, w)
|
||||
y2 := clamp(holeY+holeH, 0, h)
|
||||
|
||||
for y := y1; y < y2; y++ {
|
||||
off := y * stride
|
||||
for x := x1; x < x2; x++ {
|
||||
i := off + x*4
|
||||
if i+3 >= len(data) {
|
||||
continue
|
||||
}
|
||||
data[i+0], data[i+1], data[i+2], data[i+3] = 0, 0, 0, 0
|
||||
}
|
||||
}
|
||||
|
||||
r.drawBorder(data, stride, w, h, holeX-1, holeY-1, holeW+2, holeH+2, os.screenFormat)
|
||||
r.drawScrollBar(data, stride, w, h, os.screenFormat)
|
||||
}
|
||||
|
||||
func (r *RegionSelector) drawScrollBar(data []byte, stride, bufW, bufH int, format uint32) {
|
||||
s := r.scroll
|
||||
style := LoadOverlayStyle()
|
||||
const charH = 12
|
||||
|
||||
r.fillRect(data, stride, bufW, bufH, s.barX, s.barY, s.barW, s.barH,
|
||||
style.BackgroundR, style.BackgroundG, style.BackgroundB, 245, format)
|
||||
|
||||
labelY := s.doneY + (s.btnH-charH)/2
|
||||
r.fillRect(data, stride, bufW, bufH, s.doneX, s.doneY, s.doneW, s.btnH,
|
||||
style.AccentR, style.AccentG, style.AccentB, 255, format)
|
||||
r.drawText(data, stride, bufW, bufH, s.doneX+12, labelY, "done", 10, 10, 10, format)
|
||||
|
||||
r.fillRect(data, stride, bufW, bufH, s.cancelX, s.cancelY, s.cancelW, s.btnH,
|
||||
70, 70, 70, 255, format)
|
||||
r.drawText(data, stride, bufW, bufH, s.cancelX+12, labelY, "cancel",
|
||||
style.TextR, style.TextG, style.TextB, format)
|
||||
|
||||
rows := 0
|
||||
if s.st != nil {
|
||||
rows = s.st.rows()
|
||||
}
|
||||
counter := fmt.Sprintf("%d shots %dpx", s.kept, rows)
|
||||
r.drawText(data, stride, bufW, bufH, s.cancelX+s.cancelW+16, labelY, counter,
|
||||
style.TextR, style.TextG, style.TextB, format)
|
||||
}
|
||||
|
||||
func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32) {
|
||||
if r.selection.dragging {
|
||||
return
|
||||
|
||||
@@ -28,21 +28,6 @@ type CaptureResult struct {
|
||||
Region Region
|
||||
YInverted bool
|
||||
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 {
|
||||
@@ -92,7 +77,7 @@ func (s *Screenshoter) Run() (*CaptureResult, error) {
|
||||
switch s.config.Mode {
|
||||
case ModeLastRegion:
|
||||
return s.captureLastRegion()
|
||||
case ModeRegion, ModeScroll:
|
||||
case ModeRegion:
|
||||
return s.captureRegion()
|
||||
case ModeWindow:
|
||||
return s.captureWindow()
|
||||
@@ -145,10 +130,6 @@ func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
|
||||
}
|
||||
|
||||
func (s *Screenshoter) captureWindow() (*CaptureResult, error) {
|
||||
if DetectCompositor() == CompositorNiri {
|
||||
return s.captureNiriWindow()
|
||||
}
|
||||
|
||||
geom, err := GetActiveWindow()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -274,7 +255,6 @@ func (s *Screenshoter) captureMangoWindow(output *WaylandOutput, region Region,
|
||||
Region: region,
|
||||
YInverted: false,
|
||||
Format: result.Format,
|
||||
Scale: scale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -450,7 +430,6 @@ func (s *Screenshoter) captureAllScreens() (*CaptureResult, error) {
|
||||
Buffer: composite,
|
||||
Region: Region{X: int32(minX), Y: int32(minY), Width: int32(totalW), Height: int32(totalH)},
|
||||
Format: format,
|
||||
Scale: maxScale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -523,7 +502,6 @@ func (s *Screenshoter) captureWholeOutput(output *WaylandOutput) (*CaptureResult
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Scale = output.effectiveScale()
|
||||
|
||||
if result.YInverted {
|
||||
result.Buffer.FlipVertical()
|
||||
@@ -626,7 +604,6 @@ func (s *Screenshoter) captureAndCrop(output *WaylandOutput, region Region) (*Ca
|
||||
Region: region,
|
||||
YInverted: false,
|
||||
Format: result.Format,
|
||||
Scale: scale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -635,7 +612,16 @@ func (s *Screenshoter) captureRegionOnOutput(output *WaylandOutput, region Regio
|
||||
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)
|
||||
localY := int32(float64(region.Y-output.y) * scale)
|
||||
@@ -674,12 +660,7 @@ func (s *Screenshoter) captureRegionOnOutput(output *WaylandOutput, region Regio
|
||||
return nil, fmt.Errorf("capture region: %w", err)
|
||||
}
|
||||
|
||||
result, err := s.processFrame(frame, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Scale = scale
|
||||
return result, nil
|
||||
return s.processFrame(frame, region)
|
||||
}
|
||||
|
||||
func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, region Region) (*CaptureResult, error) {
|
||||
@@ -688,7 +669,16 @@ func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, r
|
||||
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)
|
||||
localY := int(float64(region.Y-output.y) * scale)
|
||||
@@ -740,7 +730,6 @@ func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, r
|
||||
Region: region,
|
||||
YInverted: false,
|
||||
Format: result.Format,
|
||||
Scale: scale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -758,16 +747,12 @@ func (s *Screenshoter) processFrame(frame *wlr_screencopy.ZwlrScreencopyFrameV1,
|
||||
bpp := format.BytesPerPixel()
|
||||
if int(e.Stride) < int(e.Width)*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
|
||||
}
|
||||
var err error
|
||||
buf, err = CreateShmBuffer(int(e.Width), int(e.Height), int(e.Stride))
|
||||
if err != nil {
|
||||
log.Error("failed to create buffer", "err", err)
|
||||
failed = true
|
||||
return
|
||||
}
|
||||
buf.Format = format
|
||||
@@ -786,7 +771,6 @@ func (s *Screenshoter) processFrame(frame *wlr_screencopy.ZwlrScreencopyFrameV1,
|
||||
pool, err = s.shm.CreatePool(buf.Fd(), int32(buf.Size()))
|
||||
if err != nil {
|
||||
log.Error("failed to create pool", "err", err)
|
||||
failed = true
|
||||
return
|
||||
}
|
||||
|
||||
@@ -795,7 +779,6 @@ func (s *Screenshoter) processFrame(frame *wlr_screencopy.ZwlrScreencopyFrameV1,
|
||||
pool.Destroy()
|
||||
pool = nil
|
||||
log.Error("failed to create wl_buffer", "err", err)
|
||||
failed = true
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,574 +0,0 @@
|
||||
package screenshot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_layer_shell"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type selectorPhase int
|
||||
|
||||
const (
|
||||
phaseSelect selectorPhase = iota
|
||||
phaseScroll
|
||||
)
|
||||
|
||||
const (
|
||||
scrollMaxFailures = 5
|
||||
scrollSeamTicks = 4
|
||||
)
|
||||
|
||||
type scrollSession struct {
|
||||
output *WaylandOutput
|
||||
// wire coords for CaptureOutputRegion (logical or device px per compositor)
|
||||
capX, capY, capW, capH int32
|
||||
// device-pixel rect in the overlay buffer, for hole/border drawing
|
||||
holeX, holeY, holeW, holeH int
|
||||
|
||||
interval time.Duration
|
||||
nextTick time.Time
|
||||
inFlight bool
|
||||
failures int
|
||||
kept int
|
||||
abortErr error
|
||||
|
||||
buf *ShmBuffer
|
||||
pool *client.ShmPool
|
||||
wlBuf *client.Buffer
|
||||
frame *wlr_screencopy.ZwlrScreencopyFrameV1
|
||||
format PixelFormat
|
||||
frameW, frameH int
|
||||
yInverted bool
|
||||
|
||||
prevSig []float32
|
||||
prevPlaced bool
|
||||
unmatched bool
|
||||
unmatchedTicks int
|
||||
|
||||
// control bar geometry in overlay buffer pixels
|
||||
barX, barY, barW, barH int
|
||||
doneX, doneY, doneW int
|
||||
cancelX, cancelY int
|
||||
cancelW int
|
||||
btnH int
|
||||
|
||||
sigCh chan os.Signal
|
||||
keysBound bool
|
||||
|
||||
st *stitcher
|
||||
}
|
||||
|
||||
func (r *RegionSelector) dispatchOrTick() error {
|
||||
timeout := -1
|
||||
if s := r.scroll; r.phase == phaseScroll && s != nil && s.sigCh != nil {
|
||||
select {
|
||||
case sig := <-s.sigCh:
|
||||
switch sig {
|
||||
case unix.SIGUSR2:
|
||||
r.cancelled = true
|
||||
r.running = false
|
||||
default:
|
||||
r.finishScroll()
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
}
|
||||
if s := r.scroll; r.phase == phaseScroll && s.abortErr == nil && (s.st == nil || !s.st.full) {
|
||||
timeout = max(int(time.Until(s.nextTick).Milliseconds()), 0)
|
||||
}
|
||||
|
||||
fds := []unix.PollFd{{Fd: int32(r.ctx.Fd()), Events: unix.POLLIN}}
|
||||
n, err := unix.Poll(fds, timeout)
|
||||
switch {
|
||||
case err == unix.EINTR:
|
||||
return nil
|
||||
case err != nil:
|
||||
return err
|
||||
case n > 0:
|
||||
return r.ctx.Dispatch()
|
||||
}
|
||||
|
||||
r.scrollTick()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RegionSelector) scrollTick() {
|
||||
s := r.scroll
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.inFlight || (s.st != nil && s.st.full) {
|
||||
s.nextTick = time.Now().Add(s.interval)
|
||||
return
|
||||
}
|
||||
r.startScrollCapture()
|
||||
}
|
||||
|
||||
func (r *RegionSelector) enterScrollPhase(os *OutputSurface, x, y, w, h int) {
|
||||
switch {
|
||||
case os.output.transform != TransformNormal:
|
||||
r.abortScroll(fmt.Errorf("scroll capture does not support rotated outputs"))
|
||||
return
|
||||
case w < 1 || h < 1:
|
||||
r.abortScroll(fmt.Errorf("empty scroll capture region"))
|
||||
return
|
||||
}
|
||||
|
||||
interval := 45
|
||||
if r.screenshoter != nil && r.screenshoter.config.IntervalMs > 0 {
|
||||
interval = r.screenshoter.config.IntervalMs
|
||||
}
|
||||
|
||||
capX, capY, capW, capH := x, y, w, h
|
||||
switch DetectCompositor() {
|
||||
case CompositorHyprland, CompositorMango:
|
||||
// both take device pixels, deviating from spec (observed)
|
||||
default:
|
||||
// spec: logical coordinates, scaled by the compositor
|
||||
// https://wayland.app/protocols/wlr-screencopy-unstable-v1#zwlr_screencopy_manager_v1:request:capture_output_region
|
||||
if scale := os.output.fractionalScale; scale > 1 {
|
||||
capX = int(float64(x)/scale + 0.5)
|
||||
capY = int(float64(y)/scale + 0.5)
|
||||
capW = int(float64(w)/scale + 0.5)
|
||||
capH = int(float64(h)/scale + 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
r.scroll = &scrollSession{
|
||||
output: os.output,
|
||||
capX: int32(capX),
|
||||
capY: int32(capY),
|
||||
capW: int32(capW),
|
||||
capH: int32(capH),
|
||||
holeX: x,
|
||||
holeY: y,
|
||||
holeW: w,
|
||||
holeH: h,
|
||||
interval: time.Duration(interval) * time.Millisecond,
|
||||
nextTick: time.Now(),
|
||||
}
|
||||
|
||||
r.layoutScrollBar(os)
|
||||
|
||||
for _, surf := range r.surfaces {
|
||||
r.setInputPassthrough(surf, surf == os)
|
||||
}
|
||||
|
||||
// Hyprland routes all pointer input to exclusive-keyboard layers
|
||||
// (https://github.com/hyprwm/Hyprland/discussions/14136), so the keyboard
|
||||
// is released there and Enter/Esc come back via temporary global binds
|
||||
if DetectCompositor() == CompositorHyprland {
|
||||
r.enterHyprlandScrollInput(os)
|
||||
}
|
||||
|
||||
r.phase = phaseScroll
|
||||
for _, surf := range r.surfaces {
|
||||
r.redrawSurface(surf)
|
||||
}
|
||||
}
|
||||
|
||||
// sized for the worst-case counter so the input region is set once
|
||||
func (r *RegionSelector) layoutScrollBar(os *OutputSurface) {
|
||||
s := r.scroll
|
||||
const charAdv, pad, gap = 9, 12, 16
|
||||
|
||||
s.btnH = 24
|
||||
s.doneW = len("done")*charAdv + 24
|
||||
s.cancelW = len("cancel")*charAdv + 24
|
||||
counterW := len("99999 shots 999999px") * charAdv
|
||||
s.barW = pad + s.doneW + gap + s.cancelW + gap + counterW + pad
|
||||
s.barH = s.btnH + 24
|
||||
|
||||
bufW, bufH := os.screenBuf.Width, os.screenBuf.Height
|
||||
s.barX = (bufW - s.barW) / 2
|
||||
s.barY = bufH - s.barH - 24
|
||||
|
||||
borderX1, borderY1 := s.holeX-3, s.holeY-3
|
||||
borderX2, borderY2 := s.holeX+s.holeW+3, s.holeY+s.holeH+3
|
||||
overlaps := s.barX < borderX2 && s.barX+s.barW > borderX1 &&
|
||||
s.barY < borderY2 && s.barY+s.barH > borderY1
|
||||
if overlaps {
|
||||
s.barY = 24
|
||||
}
|
||||
|
||||
s.doneX = s.barX + pad
|
||||
s.doneY = s.barY + (s.barH-s.btnH)/2
|
||||
s.cancelX = s.doneX + s.doneW + gap
|
||||
s.cancelY = s.doneY
|
||||
}
|
||||
|
||||
func (r *RegionSelector) setInputPassthrough(os *OutputSurface, withBar bool) {
|
||||
reg, err := r.compositor.CreateRegion()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if withBar && os.screenBuf != nil && os.logicalW > 0 {
|
||||
s := r.scroll
|
||||
scaleX := float64(os.logicalW) / float64(os.screenBuf.Width)
|
||||
scaleY := float64(os.logicalH) / float64(os.screenBuf.Height)
|
||||
_ = reg.Add(int32(float64(s.barX)*scaleX), int32(float64(s.barY)*scaleY),
|
||||
int32(float64(s.barW)*scaleX)+1, int32(float64(s.barH)*scaleY)+1)
|
||||
}
|
||||
_ = os.wlSurface.SetInputRegion(reg)
|
||||
_ = reg.Destroy()
|
||||
}
|
||||
|
||||
func (r *RegionSelector) enterHyprlandScrollInput(osurf *OutputSurface) {
|
||||
for _, surf := range r.surfaces {
|
||||
_ = surf.layerSurf.SetKeyboardInteractivity(uint32(wlr_layer_shell.ZwlrLayerSurfaceV1KeyboardInteractivityNone))
|
||||
}
|
||||
if r.shortcutsInhibitor != nil {
|
||||
_ = r.shortcutsInhibitor.Destroy()
|
||||
r.shortcutsInhibitor = nil
|
||||
}
|
||||
|
||||
s := r.scroll
|
||||
scale := osurf.output.fractionalScale
|
||||
if scale <= 0 {
|
||||
scale = 1
|
||||
}
|
||||
cx := int(float64(osurf.output.x) + float64(s.holeX+s.holeW/2)/scale)
|
||||
cy := int(float64(osurf.output.y) + float64(s.holeY+s.holeH/2)/scale)
|
||||
hyprlandFocusWindowAt(cx, cy)
|
||||
|
||||
s.sigCh = make(chan os.Signal, 2)
|
||||
signal.Notify(s.sigCh, unix.SIGUSR1, unix.SIGUSR2)
|
||||
s.keysBound = hyprlandBindScrollKeys(os.Getpid())
|
||||
}
|
||||
|
||||
func hyprlandFocusWindowAt(x, y int) {
|
||||
out, err := exec.Command("hyprctl", "-j", "clients").Output()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var clients []struct {
|
||||
Address string `json:"address"`
|
||||
At [2]int `json:"at"`
|
||||
Size [2]int `json:"size"`
|
||||
Mapped bool `json:"mapped"`
|
||||
Hidden bool `json:"hidden"`
|
||||
FocusHistoryID int `json:"focusHistoryID"`
|
||||
}
|
||||
if json.Unmarshal(out, &clients) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
best := -1
|
||||
for i, c := range clients {
|
||||
if !c.Mapped || c.Hidden {
|
||||
continue
|
||||
}
|
||||
if x < c.At[0] || x >= c.At[0]+c.Size[0] || y < c.At[1] || y >= c.At[1]+c.Size[1] {
|
||||
continue
|
||||
}
|
||||
if best < 0 || c.FocusHistoryID < clients[best].FocusHistoryID {
|
||||
best = i
|
||||
}
|
||||
}
|
||||
if best < 0 {
|
||||
return
|
||||
}
|
||||
_ = exec.Command("hyprctl", "dispatch", "focuswindow", "address:"+clients[best].Address).Run()
|
||||
}
|
||||
|
||||
func hyprlandBindScrollKeys(pid int) bool {
|
||||
batch := fmt.Sprintf("keyword bind ,Return,exec,kill -USR1 %d ; keyword bind ,Escape,exec,kill -USR2 %d", pid, pid)
|
||||
return exec.Command("hyprctl", "--batch", batch).Run() == nil
|
||||
}
|
||||
|
||||
func hyprlandUnbindScrollKeys() {
|
||||
_ = exec.Command("hyprctl", "--batch", "keyword unbind ,Return ; keyword unbind ,Escape").Run()
|
||||
}
|
||||
|
||||
func (r *RegionSelector) scrollBarHit(x, y float64) string {
|
||||
s := r.scroll
|
||||
os := r.selection.surface
|
||||
if s == nil || os == nil || os.screenBuf == nil || os.logicalW == 0 {
|
||||
return ""
|
||||
}
|
||||
bx := int(x * float64(os.screenBuf.Width) / float64(os.logicalW))
|
||||
by := int(y * float64(os.screenBuf.Height) / float64(os.logicalH))
|
||||
|
||||
switch {
|
||||
case bx >= s.doneX && bx < s.doneX+s.doneW && by >= s.doneY && by < s.doneY+s.btnH:
|
||||
return "done"
|
||||
case bx >= s.cancelX && bx < s.cancelX+s.cancelW && by >= s.cancelY && by < s.cancelY+s.btnH:
|
||||
return "cancel"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func alphaFormat(format uint32) uint32 {
|
||||
switch format {
|
||||
case uint32(FormatXRGB8888):
|
||||
return uint32(FormatARGB8888)
|
||||
case uint32(FormatXBGR8888):
|
||||
return uint32(FormatABGR8888)
|
||||
default:
|
||||
return format
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RegionSelector) startScrollCapture() {
|
||||
s := r.scroll
|
||||
frame, err := r.screencopy.CaptureOutputRegion(0, s.output.wlOutput, s.capX, s.capY, s.capW, s.capH)
|
||||
if err != nil {
|
||||
r.abortScroll(fmt.Errorf("scroll capture: %w", err))
|
||||
return
|
||||
}
|
||||
s.inFlight = true
|
||||
s.frame = frame
|
||||
s.nextTick = time.Now().Add(s.interval)
|
||||
|
||||
frame.SetBufferHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1BufferEvent) {
|
||||
if err := s.ensureCaptureBuffer(r, e); err != nil {
|
||||
r.abortScroll(err)
|
||||
return
|
||||
}
|
||||
if err := frame.Copy(s.wlBuf); err != nil {
|
||||
log.Error("scroll frame copy failed", "err", err)
|
||||
}
|
||||
})
|
||||
|
||||
frame.SetFlagsHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FlagsEvent) {
|
||||
s.yInverted = (e.Flags & 1) != 0
|
||||
})
|
||||
|
||||
frame.SetReadyHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1ReadyEvent) {
|
||||
frame.Destroy()
|
||||
s.frame = nil
|
||||
s.inFlight = false
|
||||
s.failures = 0
|
||||
s.nextTick = time.Now().Add(s.interval)
|
||||
r.handleScrollFrame()
|
||||
})
|
||||
|
||||
frame.SetFailedHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FailedEvent) {
|
||||
frame.Destroy()
|
||||
s.frame = nil
|
||||
s.inFlight = false
|
||||
s.failures++
|
||||
s.nextTick = time.Now().Add(s.interval)
|
||||
if s.failures >= scrollMaxFailures {
|
||||
r.abortScroll(fmt.Errorf("screencopy failed %d consecutive times", s.failures))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *scrollSession) ensureCaptureBuffer(r *RegionSelector, e wlr_screencopy.ZwlrScreencopyFrameV1BufferEvent) error {
|
||||
if s.buf != nil {
|
||||
if int(e.Width) != s.frameW || int(e.Height) != s.frameH || PixelFormat(e.Format) != s.format {
|
||||
return fmt.Errorf("output changed during scroll capture")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
format := PixelFormat(e.Format)
|
||||
if int(e.Stride) < int(e.Width)*format.BytesPerPixel() {
|
||||
return fmt.Errorf("invalid stride from compositor: %d for width %d", e.Stride, e.Width)
|
||||
}
|
||||
|
||||
buf, err := CreateShmBuffer(int(e.Width), int(e.Height), int(e.Stride))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create scroll buffer: %w", err)
|
||||
}
|
||||
buf.Format = format
|
||||
|
||||
pool, err := r.shm.CreatePool(buf.Fd(), int32(buf.Size()))
|
||||
if err != nil {
|
||||
buf.Close()
|
||||
return fmt.Errorf("create scroll pool: %w", err)
|
||||
}
|
||||
|
||||
wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), e.Format)
|
||||
if err != nil {
|
||||
pool.Destroy()
|
||||
buf.Close()
|
||||
return fmt.Errorf("create scroll wl_buffer: %w", err)
|
||||
}
|
||||
|
||||
s.buf = buf
|
||||
s.pool = pool
|
||||
s.wlBuf = wlBuf
|
||||
s.format = format
|
||||
s.frameW = int(e.Width)
|
||||
s.frameH = int(e.Height)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RegionSelector) handleScrollFrame() {
|
||||
s := r.scroll
|
||||
if s == nil || s.buf == nil {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := s.extractRows()
|
||||
if err != nil {
|
||||
r.abortScroll(err)
|
||||
return
|
||||
}
|
||||
|
||||
if s.st == nil {
|
||||
s.st = newStitcher(s.frameW * 4)
|
||||
}
|
||||
|
||||
cols := s.st.rowSamples(rows)
|
||||
sig := s.st.frameSig(rows)
|
||||
dup := duplicateFrame(sig, s.prevSig)
|
||||
s.prevSig = sig
|
||||
|
||||
// moving content: recapture at compositor speed, the timer paces idle only
|
||||
if !dup {
|
||||
s.nextTick = time.Now()
|
||||
}
|
||||
|
||||
var added int
|
||||
switch {
|
||||
case dup && s.unmatched:
|
||||
// settled somewhere unreachable: seam a new segment after a few ticks
|
||||
s.unmatchedTicks++
|
||||
if s.unmatchedTicks < scrollSeamTicks {
|
||||
return
|
||||
}
|
||||
var placed bool
|
||||
added, placed = s.st.pushFrame(rows, cols)
|
||||
if !placed {
|
||||
added = s.st.seamAppend(rows, cols)
|
||||
}
|
||||
s.prevPlaced = true
|
||||
s.unmatched = false
|
||||
s.unmatchedTicks = 0
|
||||
case dup && s.prevPlaced:
|
||||
return
|
||||
default:
|
||||
var placed bool
|
||||
added, placed = s.st.pushFrame(rows, cols)
|
||||
s.prevPlaced = placed
|
||||
s.unmatched = !placed
|
||||
s.unmatchedTicks = 0
|
||||
}
|
||||
|
||||
if scrollDebug {
|
||||
log.Error("scroll frame", "dup", dup, "unmatched", s.unmatched,
|
||||
"placed", s.prevPlaced, "added", added, "canvas", s.st.rows(), "kept", s.kept)
|
||||
}
|
||||
if added == 0 {
|
||||
return
|
||||
}
|
||||
s.kept++
|
||||
if r.selection.surface != nil {
|
||||
r.redrawSurface(r.selection.surface)
|
||||
}
|
||||
}
|
||||
|
||||
var scrollDebug = os.Getenv("DMS_SCROLL_DEBUG") != ""
|
||||
|
||||
func (s *scrollSession) extractRows() ([]byte, error) {
|
||||
src := s.buf
|
||||
format := s.format
|
||||
|
||||
if format.Is24Bit() {
|
||||
converted, newFormat, err := src.ConvertTo32Bit(format)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("convert scroll frame: %w", err)
|
||||
}
|
||||
defer converted.Close()
|
||||
src = converted
|
||||
s.format = newFormat
|
||||
}
|
||||
|
||||
rows := make([]byte, s.frameW*4*s.frameH)
|
||||
data := src.Data()
|
||||
for y := 0; y < s.frameH; y++ {
|
||||
srcY := y
|
||||
if s.yInverted {
|
||||
srcY = s.frameH - 1 - y
|
||||
}
|
||||
srcOff := srcY * src.Stride
|
||||
dstOff := y * s.frameW * 4
|
||||
if srcOff+s.frameW*4 > len(data) {
|
||||
continue
|
||||
}
|
||||
copy(rows[dstOff:dstOff+s.frameW*4], data[srcOff:srcOff+s.frameW*4])
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *RegionSelector) finishScroll() {
|
||||
s := r.scroll
|
||||
if s == nil || s.st == nil || s.st.rows() == 0 {
|
||||
r.cancelled = true
|
||||
r.running = false
|
||||
return
|
||||
}
|
||||
|
||||
buf, err := CreateShmBuffer(s.frameW, s.st.rows(), s.frameW*4)
|
||||
if err != nil {
|
||||
r.abortScroll(fmt.Errorf("create stitched buffer: %w", err))
|
||||
return
|
||||
}
|
||||
copy(buf.Data(), s.st.canvas)
|
||||
buf.Format = s.format
|
||||
|
||||
r.capturedBuffer = buf
|
||||
r.capturedRegion = Region{
|
||||
X: int32(s.holeX),
|
||||
Y: int32(s.holeY),
|
||||
Width: int32(s.holeW),
|
||||
Height: int32(s.holeH),
|
||||
Output: s.output.name,
|
||||
}
|
||||
// same convention as finishSelection or preselect breaks on scaled outputs
|
||||
r.result = Region{
|
||||
X: int32(s.holeX) + s.output.x,
|
||||
Y: int32(s.holeY) + s.output.y,
|
||||
Width: int32(s.holeW),
|
||||
Height: int32(s.holeH),
|
||||
Output: s.output.name,
|
||||
}
|
||||
r.running = false
|
||||
}
|
||||
|
||||
func (r *RegionSelector) abortScroll(err error) {
|
||||
if r.scroll == nil {
|
||||
r.scroll = &scrollSession{}
|
||||
}
|
||||
r.scroll.abortErr = err
|
||||
r.running = false
|
||||
}
|
||||
|
||||
func (r *RegionSelector) cleanupScroll() {
|
||||
s := r.scroll
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.keysBound {
|
||||
hyprlandUnbindScrollKeys()
|
||||
}
|
||||
if s.sigCh != nil {
|
||||
signal.Stop(s.sigCh)
|
||||
}
|
||||
if s.frame != nil {
|
||||
s.frame.Destroy()
|
||||
}
|
||||
if s.wlBuf != nil {
|
||||
s.wlBuf.Destroy()
|
||||
}
|
||||
if s.pool != nil {
|
||||
s.pool.Destroy()
|
||||
}
|
||||
if s.buf != nil {
|
||||
s.buf.Close()
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
package screenshot
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mirrors handleScrollFrame's stitch logic so glides run without a compositor
|
||||
type simSession struct {
|
||||
prevSig []float32
|
||||
prevPlaced bool
|
||||
unmatched bool
|
||||
unmatchedTicks int
|
||||
st *stitcher
|
||||
}
|
||||
|
||||
func (s *simSession) observe(rows []byte) {
|
||||
cols := s.st.rowSamples(rows)
|
||||
sig := s.st.frameSig(rows)
|
||||
dup := duplicateFrame(sig, s.prevSig)
|
||||
s.prevSig = sig
|
||||
|
||||
switch {
|
||||
case dup && s.unmatched:
|
||||
s.unmatchedTicks++
|
||||
if s.unmatchedTicks < scrollSeamTicks {
|
||||
return
|
||||
}
|
||||
if _, placed := s.st.pushFrame(rows, cols); !placed {
|
||||
s.st.seamAppend(rows, cols)
|
||||
}
|
||||
s.prevPlaced = true
|
||||
s.unmatched = false
|
||||
s.unmatchedTicks = 0
|
||||
case dup && s.prevPlaced:
|
||||
return
|
||||
default:
|
||||
_, placed := s.st.pushFrame(rows, cols)
|
||||
s.prevPlaced = placed
|
||||
s.unmatched = !placed
|
||||
s.unmatchedTicks = 0
|
||||
}
|
||||
}
|
||||
|
||||
// the page at a fractional scroll offset, as a compositor renders mid-glide
|
||||
func fractionalFrame(page []byte, stride, frameH int, offset float64) []byte {
|
||||
top := int(offset)
|
||||
frac := offset - float64(top)
|
||||
out := make([]byte, frameH*stride)
|
||||
for y := 0; y < frameH; y++ {
|
||||
a := page[(top+y)*stride : (top+y+1)*stride]
|
||||
b := page[(top+y+1)*stride : (top+y+2)*stride]
|
||||
row := out[y*stride : (y+1)*stride]
|
||||
for x := range row {
|
||||
row[x] = byte(float64(a[x])*(1-frac) + float64(b[x])*frac)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// blank gaps between paragraphs plus identical card blocks repeated around
|
||||
func webbyPage(rng *rand.Rand, stride, rows int) []byte {
|
||||
page := make([]byte, rows*stride)
|
||||
card := make([]byte, 40*stride)
|
||||
rng.Read(card)
|
||||
|
||||
row := 0
|
||||
for row < rows {
|
||||
switch rng.Intn(4) {
|
||||
case 0: // blank gap
|
||||
row += 10 + rng.Intn(20)
|
||||
case 1: // repeated card block
|
||||
n := copy(page[row*stride:], card)
|
||||
row += n / stride
|
||||
default: // paragraph of distinct rows
|
||||
n := (8 + rng.Intn(22)) * stride
|
||||
if row*stride+n > len(page) {
|
||||
n = len(page) - row*stride
|
||||
}
|
||||
rng.Read(page[row*stride : row*stride+n])
|
||||
row += n / stride
|
||||
}
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
// screen-fixed sidebar in the unsampled outer 8% plus per-frame hover noise
|
||||
func addFixedChrome(rng *rand.Rand, frame []byte, stride, frameH int, sidebar []byte) {
|
||||
sbw := len(sidebar) / frameH
|
||||
for y := 0; y < frameH; y++ {
|
||||
copy(frame[y*stride:y*stride+sbw], sidebar[y*sbw:(y+1)*sbw])
|
||||
}
|
||||
hoverTop := 40 + rng.Intn(frameH-80)
|
||||
for y := hoverTop; y < hoverTop+24; y++ {
|
||||
off := y*stride + stride/3
|
||||
for x := 0; x < 60; x++ {
|
||||
frame[off+x] ^= 0x08
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// starting at the page bottom and scrolling up must prepend, never stall
|
||||
func TestScrollSimulationBottomUp(t *testing.T) {
|
||||
const stride = 2048
|
||||
const frameH = 240
|
||||
rng := rand.New(rand.NewSource(99))
|
||||
page := webbyPage(rng, stride, 4000)
|
||||
|
||||
st := newStitcher(stride)
|
||||
sidebar := make([]byte, frameH*140)
|
||||
rng.Read(sidebar)
|
||||
|
||||
sim := &simSession{st: st}
|
||||
|
||||
pos := 3700.0
|
||||
capture := func() []byte {
|
||||
f := fractionalFrame(page, stride, frameH, pos)
|
||||
addFixedChrome(rng, f, stride, frameH, sidebar)
|
||||
return f
|
||||
}
|
||||
glide := func(target float64) {
|
||||
for i := 0; ; i++ {
|
||||
step := (target - pos) * 0.45
|
||||
if step > -1 && step < 1 {
|
||||
break
|
||||
}
|
||||
pos += step
|
||||
if i%4 != 3 {
|
||||
pos = float64(int(pos))
|
||||
}
|
||||
sim.observe(capture())
|
||||
}
|
||||
pos = target
|
||||
sim.observe(capture())
|
||||
sim.observe(capture())
|
||||
}
|
||||
|
||||
sim.observe(capture())
|
||||
for _, target := range []float64{3640, 3560, 3460, 3340, 3240} {
|
||||
glide(target)
|
||||
}
|
||||
|
||||
wantRows := (3700 + frameH) - 3240
|
||||
got := sim.st.rows()
|
||||
if got < wantRows-stitchMinAppend || got > wantRows+2 {
|
||||
t.Fatalf("canvas has %d rows, want ~%d (upward scrolling must prepend)", got, wantRows)
|
||||
}
|
||||
topPage := 3240 + (wantRows - got)
|
||||
for _, cr := range []int{0, 100, 300} {
|
||||
if !rowMatchesPage(sim.st.canvas, page, stride, cr, topPage+cr) {
|
||||
t.Fatalf("canvas row %d does not map onto page row %d", cr, topPage+cr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// exact page row or a blend of neighbors, allowing a one-row offset
|
||||
func rowMatchesPage(canvas, page []byte, stride, canvasRow, pageRow int) bool {
|
||||
for x := 200; x < stride-1400; x++ {
|
||||
c := int(canvas[canvasRow*stride+x])
|
||||
lo, hi := 255, 0
|
||||
for k := pageRow - 1; k <= pageRow+1; k++ {
|
||||
v := int(page[k*stride+x])
|
||||
lo, hi = min(lo, v), max(hi, v)
|
||||
}
|
||||
if c < lo-1 || c > hi+1 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// a fling past a full frame height must seam a new segment, not go dead
|
||||
func TestScrollSimulationFastFlingRecovers(t *testing.T) {
|
||||
const stride = 2048
|
||||
const frameH = 240
|
||||
rng := rand.New(rand.NewSource(7))
|
||||
page := webbyPage(rng, stride, 4000)
|
||||
|
||||
sim := &simSession{st: newStitcher(stride)}
|
||||
frame := func(top int) []byte {
|
||||
return slices.Clone(page[top*stride : (top+frameH)*stride])
|
||||
}
|
||||
rest := func(top int) {
|
||||
for range scrollSeamTicks + 2 {
|
||||
sim.observe(frame(top))
|
||||
}
|
||||
}
|
||||
|
||||
rest(0)
|
||||
sim.observe(frame(60))
|
||||
sim.observe(frame(130))
|
||||
rest(130)
|
||||
firstRange := 130 + frameH
|
||||
|
||||
sim.observe(frame(900))
|
||||
sim.observe(frame(1400))
|
||||
rest(1800)
|
||||
|
||||
sim.observe(frame(1860))
|
||||
sim.observe(frame(1930))
|
||||
rest(1930)
|
||||
|
||||
wantRows := firstRange + (1930 - 1800) + frameH
|
||||
if got := sim.st.rows(); got != wantRows {
|
||||
t.Fatalf("canvas has %d rows, want %d (first range %d + new segment)", got, wantRows, firstRange)
|
||||
}
|
||||
seamStart := firstRange
|
||||
if !slices.Equal(sim.st.canvas[seamStart*stride:], page[1800*stride:(1930+frameH)*stride]) {
|
||||
t.Fatal("new segment content wrong after fling recovery")
|
||||
}
|
||||
}
|
||||
|
||||
// eased glides with up/down scrubbing must cover the range exactly once
|
||||
func TestScrollSimulationSmoothGlide(t *testing.T) {
|
||||
const stride = 2048
|
||||
const frameH = 240
|
||||
rng := rand.New(rand.NewSource(99))
|
||||
page := webbyPage(rng, stride, 4000)
|
||||
|
||||
st := newStitcher(stride)
|
||||
sidebar := make([]byte, frameH*140)
|
||||
rng.Read(sidebar)
|
||||
|
||||
sim := &simSession{st: st}
|
||||
|
||||
pos := 0.0
|
||||
capture := func() []byte {
|
||||
f := fractionalFrame(page, stride, frameH, pos)
|
||||
addFixedChrome(rng, f, stride, frameH, sidebar)
|
||||
return f
|
||||
}
|
||||
glide := func(target float64) {
|
||||
for i := 0; ; i++ {
|
||||
step := (target - pos) * 0.45
|
||||
if step > -1 && step < 1 {
|
||||
break
|
||||
}
|
||||
pos += step
|
||||
// mostly snapped to device pixels, with the odd fractional frame
|
||||
if i%4 != 3 {
|
||||
pos = float64(int(pos))
|
||||
}
|
||||
sim.observe(capture())
|
||||
}
|
||||
pos = target
|
||||
sim.observe(capture())
|
||||
sim.observe(capture())
|
||||
}
|
||||
|
||||
sim.observe(capture())
|
||||
for _, target := range []float64{160, 330, 480, 650, 800, 960, 1100} {
|
||||
glide(target)
|
||||
}
|
||||
for _, target := range []float64{700, 300, 900, 1100} {
|
||||
glide(target)
|
||||
}
|
||||
|
||||
wantRows := 1100 + frameH
|
||||
got := sim.st.rows()
|
||||
if got < wantRows-stitchMinAppend || got > wantRows+2 {
|
||||
t.Fatalf("canvas has %d rows, want ~%d (more = duplicated bands, fewer = gaps)", got, wantRows)
|
||||
}
|
||||
|
||||
hoverLo, hoverHi := stride/3, stride/3+60
|
||||
mismatched := 0
|
||||
for row := 0; row < min(got, wantRows); row += 7 {
|
||||
off := row * stride
|
||||
a1, b1 := sim.st.canvas[off+200:off+hoverLo], page[off+200:off+hoverLo]
|
||||
a2, b2 := sim.st.canvas[off+hoverHi:off+stride], page[off+hoverHi:off+stride]
|
||||
if !slices.Equal(a1, b1) || !slices.Equal(a2, b2) {
|
||||
mismatched++
|
||||
}
|
||||
}
|
||||
if mismatched > (wantRows/7)/20 {
|
||||
t.Fatalf("%d of %d sampled rows mismatch page content (mid-animation pixels baked in)", mismatched, wantRows/7)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user