mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-01 19:18:28 -04:00
Compare commits
159 Commits
e54be7d12d
...
stable
| Author | SHA1 | Date | |
|---|---|---|---|
| 069ddab041 | |||
| 45cf6ecefd | |||
| 7058b00091 | |||
| 6ba4b79039 | |||
| b8e2ce1da8 | |||
| c58d55db7d | |||
| e15db00714 | |||
| 906eba5c17 | |||
| d2b61b91ef | |||
| 6c27b92445 | |||
| a64bd80cc9 | |||
| 661dc27d26 | |||
| 3d5a6fb9d7 | |||
| c2a5397d8a | |||
| 9b5538323d | |||
| 8321d28d18 | |||
| 39278860c8 | |||
| f455456ff1 | |||
| 5a80a32951 | |||
| 3cedd56f23 | |||
| dc1ad5b329 | |||
| 25c12a14a9 | |||
| b72b276fb4 | |||
| f89fb67316 | |||
| f457e04cb1 | |||
| 23daca0b57 | |||
| accdd0972c | |||
| 00c28f69fd | |||
| 2e0337b68c | |||
| 4975960ea4 | |||
| 0381a112b3 | |||
| ed5e2a22c0 | |||
| 847718459c | |||
| 95b120b475 | |||
| 4dd2f1d5be | |||
| bf5faf725b | |||
| e5eff919b8 | |||
| 606115748a | |||
| ad936bfdc9 | |||
| 110396cb2a | |||
| 7b08e61863 | |||
| 876ead665b | |||
| 74896fb87c | |||
| f1f528b2cb | |||
| 9853e1431b | |||
| e7b8196a4a | |||
| 85ebb402cb | |||
| f37f4a1f35 | |||
| fc11dfbc57 | |||
| 7806cf4a58 | |||
| 89895da4fa | |||
| 4ff93bf46e | |||
| 63ffd7caec | |||
| dac08b34c6 | |||
| 840a448b4a | |||
| 26d429fa31 | |||
| fd20cce9b7 | |||
| ad5a96d95b | |||
| 8ba5e1a082 | |||
| 7d6a7bae06 | |||
| 986a6e0230 | |||
| 64ba1fbb1b | |||
| ab9df791b3 | |||
| 65f4a9a044 | |||
| 59164cbd30 | |||
| 7ab1f6a956 | |||
| 841167ac44 | |||
| c2a6a7aebd | |||
| b17ef80c91 | |||
| a55177f1d2 | |||
| 3d0ce17a8c | |||
| 6306bc7aa2 | |||
| eca92898f9 | |||
| db9fb0bd90 | |||
| 815820aa27 | |||
| 01c0ed2778 | |||
| 9967db0c5a | |||
| 883a787db7 | |||
| 3b1a3c1c22 | |||
| 970262f276 | |||
| b708e97ec5 | |||
| e94fc7fe99 | |||
| c4bca6f6cd | |||
| 9e7fc833e9 | |||
| 9e2d493749 | |||
| 93120fd73b | |||
| 536ebf7b0a | |||
| 1105047a80 | |||
| 8b46397200 | |||
| 6b70491ba9 | |||
| deb0138198 | |||
| 2e0587ca16 | |||
| 45cafc6bdb | |||
| 82d5081b7d | |||
| b5b083305f | |||
| 039d14c790 | |||
| ee7ac9d2bd | |||
| bd159be414 | |||
| 4dda0e3a5e | |||
| 724ee9422d | |||
| c91cb5d89a | |||
| 660953ebc5 | |||
| bd70a6286e | |||
| d87e2fcb01 | |||
| 928b272628 | |||
| 781ed16538 | |||
| 417da7408d | |||
| 0d550662de | |||
| 35e35a16fa | |||
| d96559f7df | |||
| 7f37799e53 | |||
| fcda17f517 | |||
| 4b05e7a0fa | |||
| 7c6ad65b5a | |||
| acda199e28 | |||
| b3a129af2f | |||
| e9d958b32b | |||
| dd9edd8a00 | |||
| 28c25d46d5 | |||
| 58530ae56c | |||
| c0134c249a | |||
| f7157f0e92 | |||
| e29b9749df | |||
| 6791c20f40 | |||
| 6c2572fbfe | |||
| 3ee07f7f20 | |||
| ef244cb645 | |||
| 860c347362 | |||
| c7b58e52d7 | |||
| e0fb817a75 | |||
| b5908f4d1b | |||
| ee6c91e094 | |||
| 59d367f4a4 | |||
| ee84489b95 | |||
| b1beecb083 | |||
| ffd3840058 | |||
| 447b4d89b6 | |||
| 36772735f3 | |||
| 375afaaf3a | |||
| fe0214d522 | |||
| fce1a09790 | |||
| 554e55c63a | |||
| f7d8e2b56c | |||
| 1d4572ac1c | |||
| c02a24fecf | |||
| bf644516f8 | |||
| 61f9ea4954 | |||
| 5a169bea72 | |||
| 3d60f35683 | |||
| b95f4cdbf3 | |||
| b834e82668 | |||
| 302cfda2f4 | |||
| c86676df9b | |||
| fa26dbb544 | |||
| 067d01f678 | |||
| 95c8cc80f6 | |||
| b8f216dc0f | |||
| e84e63fa3a | |||
| 6d08b91004 |
@@ -0,0 +1,23 @@
|
||||
# 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: ["*"]
|
||||
Executable
+170
@@ -0,0 +1,170 @@
|
||||
#!/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,14 +7,17 @@ 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@v1
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
@@ -28,4 +31,54 @@ jobs:
|
||||
- name: Push to stable branch
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
run: git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:refs/heads/stable --force
|
||||
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"
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
- "core/**"
|
||||
- ".github/workflows/go-ci.yml"
|
||||
pull_request:
|
||||
branches: [master, main]
|
||||
branches: [master, main, "stable-*"]
|
||||
paths:
|
||||
- "core/**"
|
||||
- ".github/workflows/go-ci.yml"
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Nix flake and NixOS tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master, main]
|
||||
branches: [master, main, "stable-*"]
|
||||
paths:
|
||||
- "flake.*"
|
||||
- "distro/nix/**"
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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"
|
||||
@@ -0,0 +1,30 @@
|
||||
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
|
||||
@@ -0,0 +1,101 @@
|
||||
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]
|
||||
branches: [master, main, "stable-*"]
|
||||
jobs:
|
||||
pre-commit-check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -7,10 +7,16 @@ 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 }}
|
||||
@@ -205,14 +211,24 @@ 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
|
||||
fi
|
||||
|
||||
cat > RELEASE_BODY.md << 'EOF'
|
||||
## Installation
|
||||
@@ -417,3 +433,19 @@ 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"
|
||||
|
||||
+111
-39
@@ -3,10 +3,15 @@ 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:
|
||||
types: [published]
|
||||
# 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
|
||||
@@ -29,30 +34,63 @@ on:
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
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
|
||||
if: github.repository == 'AvengeMedia/DankMaterialShell'
|
||||
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: Checkout gh-pages branch
|
||||
- name: Record checked-out source commit
|
||||
run: echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download current R2 repository
|
||||
run: |
|
||||
git clone --branch gh-pages https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git gh-pages-repo || {
|
||||
echo "⚠️ gh-pages branch not found or empty, initializing a new one..."
|
||||
mkdir gh-pages-repo
|
||||
cd gh-pages-repo
|
||||
git init
|
||||
git checkout -b gh-pages
|
||||
git remote add origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git
|
||||
cd ..
|
||||
}
|
||||
aws --version
|
||||
bash .github/scripts/publish-void-r2.sh download
|
||||
|
||||
- name: Install XBPS Static Utilities
|
||||
run: |
|
||||
@@ -126,19 +164,19 @@ jobs:
|
||||
- name: Configure repositories
|
||||
run: |
|
||||
# Append the repository to repos-remote templates so xbps-src translates it automatically
|
||||
echo "repository=https://avengemedia.github.io/DankLinux/current" >> void-packages/etc/xbps.d/repos-remote.conf
|
||||
echo "repository=https://avengemedia.github.io/DankLinux/current" >> void-packages/etc/xbps.d/repos-remote-x86_64-multilib.conf
|
||||
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 "gh-pages-repo/current" ]; then
|
||||
if [ -d "r2-repo/current" ]; then
|
||||
mkdir -p void-packages/hostdir/binpkgs
|
||||
cp -L gh-pages-repo/current/*.xbps void-packages/hostdir/binpkgs/ 2>/dev/null || true
|
||||
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 gh-pages-repo/current
|
||||
mkdir -p r2-repo/current
|
||||
|
||||
BUILD_DMS="${{ github.event.inputs.build_dms || 'true' }}"
|
||||
BUILD_GREETER="${{ github.event.inputs.build_greeter || 'true' }}"
|
||||
@@ -157,6 +195,25 @@ jobs:
|
||||
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"
|
||||
@@ -165,6 +222,19 @@ jobs:
|
||||
|
||||
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)
|
||||
@@ -178,7 +248,7 @@ jobs:
|
||||
mkdir -p "$SRC_CACHE"
|
||||
tar -czf "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" \
|
||||
--exclude=void-packages \
|
||||
--exclude=gh-pages-repo \
|
||||
--exclude=r2-repo \
|
||||
--exclude=.git \
|
||||
--exclude=danklinux \
|
||||
-C .. .
|
||||
@@ -192,13 +262,13 @@ jobs:
|
||||
|
||||
EXPECTED_GIT_FILE="dms-git-${GIT_VER}_1.x86_64.xbps"
|
||||
|
||||
if [ -f "../gh-pages-repo/current/$EXPECTED_GIT_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
|
||||
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 "../gh-pages-repo/current/${EXPECTED_GIT_FILE}"
|
||||
cp -L hostdir/binpkgs/dms-git-*.xbps ../gh-pages-repo/current/
|
||||
rm -f "../r2-repo/current/${EXPECTED_GIT_FILE}"
|
||||
cp -L "hostdir/binpkgs/${EXPECTED_GIT_FILE}" ../r2-repo/current/
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -208,13 +278,13 @@ jobs:
|
||||
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 "../gh-pages-repo/current/$EXPECTED_DMS_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
|
||||
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 "../gh-pages-repo/current/${EXPECTED_DMS_FILE}"
|
||||
cp -L hostdir/binpkgs/dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps ../gh-pages-repo/current/
|
||||
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
|
||||
|
||||
@@ -224,22 +294,21 @@ jobs:
|
||||
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 "../gh-pages-repo/current/$EXPECTED_GREETER_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then
|
||||
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 "../gh-pages-repo/current/${EXPECTED_GREETER_FILE}"
|
||||
cp -L hostdir/binpkgs/dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps ../gh-pages-repo/current/
|
||||
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 gh-pages-repo/current
|
||||
cd r2-repo/current
|
||||
|
||||
# Clean up any stale or dangling signature files to prevent O_CREAT ENOENT errors
|
||||
rm -f *.sig2 *.sig
|
||||
rm -f *.sig
|
||||
|
||||
# Guard: nothing to index if no .xbps files exist
|
||||
if ! ls *.xbps 1>/dev/null 2>&1; then
|
||||
@@ -249,6 +318,14 @@ jobs:
|
||||
|
||||
# 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
|
||||
@@ -259,10 +336,5 @@ jobs:
|
||||
|
||||
rm -f /tmp/xbps_privkey.pem
|
||||
|
||||
- name: Deploy to gh-pages branch
|
||||
run: |
|
||||
cd gh-pages-repo
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add current/
|
||||
git diff --quiet && git diff --staged --quiet || (git commit -m "Update XBPS packages [skip ci]" && git push origin gh-pages)
|
||||
- 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@v1
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
@@ -126,3 +126,6 @@ __pycache__
|
||||
distro/void/temp/
|
||||
distro/void/hostdir/
|
||||
distro/void/masterdir*/
|
||||
|
||||
# Often gets built
|
||||
core/dms
|
||||
|
||||
@@ -6,8 +6,8 @@ BINARY_NAME=dms
|
||||
CORE_DIR=core
|
||||
BUILD_DIR=$(CORE_DIR)/bin
|
||||
PREFIX ?= /usr/local
|
||||
INSTALL_DIR=$(PREFIX)/bin
|
||||
DATA_DIR=$(PREFIX)/share
|
||||
INSTALL_DIR=$(DESTDIR)$(PREFIX)/bin
|
||||
DATA_DIR=$(DESTDIR)$(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|$(INSTALL_DIR)/dms|g' $(ASSETS_DIR)/systemd/dms.service > $(SYSTEMD_USER_DIR)/dms.service
|
||||
@sed 's|/usr/bin/dms|$(PREFIX)/bin/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"
|
||||
|
||||
+1
-1
@@ -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). niri only, no Hyprland support. Builds from source.
|
||||
Debian 13+ (Trixie), testing, and sid. Stable quickshell from Debian (trixie-backports on 13). Other companions via OBS.
|
||||
|
||||
**openSUSE**
|
||||
Most packages available in standard repos. Minimal building required.
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
@@ -35,9 +38,142 @@ 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 {
|
||||
|
||||
@@ -759,6 +759,7 @@ func getCommonCommands() []*cobra.Command {
|
||||
greeterCmd,
|
||||
setupCmd,
|
||||
colorCmd,
|
||||
qrCmd,
|
||||
screenshotCmd,
|
||||
notifyActionCmd,
|
||||
notifyCmd,
|
||||
|
||||
@@ -320,6 +320,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -334,6 +335,29 @@ 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")
|
||||
|
||||
@@ -1081,8 +1081,11 @@ func ensureGreetdEnabled() error {
|
||||
if !runitServiceInstalled("greetd") {
|
||||
return fmt.Errorf("greetd service not found in %s. Please install greetd first", runitSvDir)
|
||||
}
|
||||
// Seat + runtime-dir setup that logind handles automatically on systemd.
|
||||
if greeter.IsVoidLinux() {
|
||||
ensureVoidLogindGreeter("_greeter")
|
||||
} else {
|
||||
ensureRunitSeat("_greeter")
|
||||
}
|
||||
ensureGreetdPamRundir()
|
||||
if err := enableRunitService("greetd"); err != nil {
|
||||
return fmt.Errorf("failed to enable greetd: %w", err)
|
||||
@@ -1261,6 +1264,9 @@ 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
|
||||
@@ -1475,12 +1481,23 @@ func extractGreeterWrapperFromCommand(command string) string {
|
||||
if len(tokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
wrapper := strings.Trim(tokens[0], "\"")
|
||||
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], "\"")
|
||||
if wrapper == "" {
|
||||
return ""
|
||||
}
|
||||
if len(tokens) > 1 {
|
||||
next := strings.Trim(tokens[1], "\"")
|
||||
if wrapperIndex+1 < len(tokens) {
|
||||
next := strings.Trim(tokens[wrapperIndex+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)
|
||||
}
|
||||
@@ -1535,7 +1552,7 @@ func packageInstallHint() string {
|
||||
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://avengemedia.github.io/DankMaterialShell/current' | sudo tee /etc/xbps.d/dms.conf)"
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -101,6 +101,13 @@ func TestGreeterStatusStateDirHonorsExplicitOverrideOnNixOS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGreeterWrapperFromEnvCommand(t *testing.T) {
|
||||
command := "env LIBSEAT_BACKEND=logind DMS_VOID=1 /usr/bin/dms-greeter --command niri"
|
||||
if got := extractGreeterWrapperFromCommand(command); got != "/usr/bin/dms-greeter" {
|
||||
t.Fatalf("extractGreeterWrapperFromCommand() = %q, want %q", got, "/usr/bin/dms-greeter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectNixOSGreeterMutationBlocksImperativeCommands(t *testing.T) {
|
||||
origGreeterIsNixOSFn := greeterIsNixOSFn
|
||||
greeterIsNixOSFn = func() bool { return true }
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
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,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -27,8 +28,19 @@ 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",
|
||||
@@ -39,8 +51,9 @@ 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)
|
||||
window - Capture the focused window (Hyprland/Mango/niri)
|
||||
last - Capture the last selected region
|
||||
scroll - Select a region, then scroll to capture a stitched tall image
|
||||
|
||||
Output format (--format):
|
||||
png - PNG format (default)
|
||||
@@ -59,7 +72,10 @@ 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 -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`,
|
||||
}
|
||||
|
||||
var ssRegionCmd = &cobra.Command{
|
||||
@@ -97,10 +113,33 @@ 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 and Mango.`,
|
||||
Long: `Capture the currently focused window. Supported on Hyprland, Mango, and niri.`,
|
||||
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",
|
||||
@@ -128,8 +167,12 @@ 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)
|
||||
@@ -203,10 +246,39 @@ 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
|
||||
interactive := config.Mode == screenshot.ModeRegion || config.Mode == screenshot.ModeLastRegion || config.Mode == screenshot.ModeScroll
|
||||
if interactive {
|
||||
setPopoutScreenshotMode(true)
|
||||
defer setPopoutScreenshotMode(false)
|
||||
@@ -215,11 +287,13 @@ func runScreenshot(config screenshot.Config) {
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
exitScreenshotError("", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
if ssJSON {
|
||||
writeScreenshotJSON(screenshotMetadata{Status: "aborted", Error: "User cancelled selection"})
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
@@ -231,8 +305,7 @@ func runScreenshot(config screenshot.Config) {
|
||||
|
||||
if config.Stdout {
|
||||
if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing to stdout: %v\n", err)
|
||||
os.Exit(1)
|
||||
exitScreenshotError(" writing to stdout", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -252,22 +325,37 @@ 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 {
|
||||
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
|
||||
os.Exit(1)
|
||||
exitScreenshotError(" writing file", err)
|
||||
}
|
||||
if !ssJSON {
|
||||
fmt.Println(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
if config.Clipboard {
|
||||
if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error copying to clipboard: %v\n", err)
|
||||
os.Exit(1)
|
||||
exitScreenshotError(" copying to clipboard", err)
|
||||
}
|
||||
if !config.SaveFile {
|
||||
if !ssJSON && !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{
|
||||
@@ -378,6 +466,12 @@ 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)
|
||||
|
||||
@@ -47,6 +47,7 @@ var (
|
||||
sysUpdateJSON bool
|
||||
sysUpdateNoFlatpak bool
|
||||
sysUpdateNoAUR bool
|
||||
sysUpdateIgnore []string
|
||||
sysUpdateIntervalS int
|
||||
sysUpdateListPmTime = 5 * time.Minute
|
||||
)
|
||||
@@ -58,6 +59,7 @@ 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)
|
||||
@@ -192,6 +194,7 @@ func runSystemUpdateApply() {
|
||||
Targets: pkgs,
|
||||
IncludeFlatpak: !sysUpdateNoFlatpak,
|
||||
IncludeAUR: !sysUpdateNoAUR,
|
||||
Ignored: sysUpdateIgnore,
|
||||
DryRun: sysUpdateDry,
|
||||
UseSudo: true,
|
||||
}
|
||||
@@ -234,12 +237,19 @@ func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupd
|
||||
}
|
||||
|
||||
func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
|
||||
if !sysUpdateNoAUR {
|
||||
if !sysUpdateNoAUR && len(sysUpdateIgnore) == 0 {
|
||||
return pkgs
|
||||
}
|
||||
ignored := make(map[string]bool, len(sysUpdateIgnore))
|
||||
for _, name := range sysUpdateIgnore {
|
||||
ignored[name] = true
|
||||
}
|
||||
out := pkgs[:0]
|
||||
for _, p := range pkgs {
|
||||
if p.Repo == sysupdate.RepoAUR {
|
||||
if sysUpdateNoAUR && p.Repo == sysupdate.RepoAUR {
|
||||
continue
|
||||
}
|
||||
if ignored[p.Name] {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
)
|
||||
|
||||
@@ -83,9 +84,31 @@ func ensureRunitSeat(greeterUser string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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`.
|
||||
// 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.
|
||||
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)
|
||||
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
|
||||
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)
|
||||
authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd)
|
||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
rootCmd.AddCommand(getCommonCommands()...)
|
||||
|
||||
+10
-2
@@ -92,6 +92,14 @@ 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
|
||||
@@ -207,7 +215,7 @@ func runShellInteractive(session bool) {
|
||||
log.Infof("Spawning quickshell with -p %s", configPath)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
|
||||
cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
|
||||
cmd.Env = withDMSExecutable(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)
|
||||
@@ -461,7 +469,7 @@ func runShellDaemon(session bool) {
|
||||
log.Infof("Spawning quickshell with -p %s", configPath)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
|
||||
cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
|
||||
cmd.Env = withDMSExecutable(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)
|
||||
|
||||
@@ -11,7 +11,8 @@ end)
|
||||
|
||||
hl.config({
|
||||
input = {
|
||||
kb_layout = "us",
|
||||
-- empty inherits XKB_DEFAULT_LAYOUT (libxkbcommon), falls back to "us"
|
||||
kb_layout = "",
|
||||
numlock_by_default = true,
|
||||
follow_mouse = 0,
|
||||
touchpad = {
|
||||
|
||||
@@ -133,6 +133,11 @@ 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()
|
||||
@@ -152,6 +157,7 @@ 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))
|
||||
@@ -160,7 +166,14 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(w, "%s=%s\n", k, entries[k])
|
||||
v := entries[k]
|
||||
if !isSafeIniField(k) || !isSafeIniField(v) {
|
||||
if writeErr == nil {
|
||||
writeErr = fmt.Errorf("invalid mimeapps.list field %q=%q", k, v)
|
||||
}
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "%s=%s\n", k, v)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
@@ -177,6 +190,10 @@ 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
|
||||
}
|
||||
|
||||
@@ -161,7 +161,60 @@ func (d *DebianDistribution) getQuickshellMapping(variant deps.PackageVariant) P
|
||||
if forceQuickshellGit || variant == deps.VariantGit {
|
||||
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}
|
||||
}
|
||||
return PackageMapping{Name: "quickshell", 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)
|
||||
}
|
||||
|
||||
func (d *DebianDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
|
||||
@@ -266,6 +319,10 @@ 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{
|
||||
|
||||
@@ -7,7 +7,7 @@ type minimalInstallGroup struct {
|
||||
|
||||
func shouldPreferMinimalInstall(pkg string) bool {
|
||||
switch pkg {
|
||||
case "niri", "niri-git":
|
||||
case "niri", "niri-git", "hyprland", "hyprland-git":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
VoidDMSRepo = "https://avengemedia.github.io/DankMaterialShell/current"
|
||||
VoidDankLinuxRepo = "https://avengemedia.github.io/DankLinux/current"
|
||||
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"
|
||||
@@ -72,6 +72,7 @@ func (v *VoidDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
|
||||
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()...)
|
||||
@@ -142,6 +143,10 @@ 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"))
|
||||
}
|
||||
@@ -172,6 +177,7 @@ func (v *VoidDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
|
||||
"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},
|
||||
@@ -305,6 +311,7 @@ func (v *VoidDistribution) ensureSessionServices(ctx context.Context, sudoPasswo
|
||||
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))
|
||||
|
||||
@@ -132,7 +132,7 @@ func (c *GeoClueClient) startSignalPump() error {
|
||||
if err := c.dbusConn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(c.clientPath),
|
||||
dbus.WithMatchInterface(dbusGeoClueClientInterface),
|
||||
dbus.WithMatchSender(dbusGeoClueClientLocationUpdated),
|
||||
dbus.WithMatchMember("LocationUpdated"),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -115,6 +115,52 @@ func ensureRunitSeat(greeterUser, sudoPassword string, logFunc func(string)) {
|
||||
}
|
||||
}
|
||||
|
||||
// VoidGreetdRunScript orders greetd after dbus/elogind so the first greeter session can't race elogind's runtime-dir setup.
|
||||
const VoidGreetdRunScript = `#!/bin/sh
|
||||
sv check dbus >/dev/null || exit 1
|
||||
sv check elogind >/dev/null || exit 1
|
||||
exec greetd
|
||||
`
|
||||
|
||||
// EnsureVoidGreetdRunScript rewrites /etc/sv/greetd/run with dbus/elogind ordering (greetd updates restore stock; enable re-asserts).
|
||||
func EnsureVoidGreetdRunScript(logFunc func(string), sudoPassword string) {
|
||||
const runPath = "/etc/sv/greetd/run"
|
||||
if data, err := os.ReadFile(runPath); err == nil && strings.Contains(string(data), "sv check elogind") {
|
||||
logFunc("✓ greetd run script already waits for elogind")
|
||||
return
|
||||
}
|
||||
script := fmt.Sprintf("cat > %s <<'EOF'\n%sEOF\nchmod 755 %s", runPath, VoidGreetdRunScript, runPath)
|
||||
if err := privesc.Run(context.Background(), sudoPassword, "sh", "-c", script); err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ could not update %s: %v", runPath, err))
|
||||
return
|
||||
}
|
||||
logFunc("✓ greetd run script now waits for dbus/elogind")
|
||||
}
|
||||
|
||||
// ensureVoidLogindGreeter configures the elogind-backed greeter on Void.
|
||||
func ensureVoidLogindGreeter(greeterUser, sudoPassword string, logFunc func(string)) {
|
||||
for _, service := range []string{"dbus", "elogind"} {
|
||||
if err := enableRunitService(service, sudoPassword); err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ could not enable %s: %v", service, err))
|
||||
} else {
|
||||
logFunc(fmt.Sprintf("✓ %s enabled", service))
|
||||
}
|
||||
}
|
||||
EnsureVoidGreetdRunScript(logFunc, sudoPassword)
|
||||
if runitServiceEnabled("seatd") {
|
||||
if err := disableRunitService("seatd", sudoPassword); err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ could not disable seatd: %v", err))
|
||||
} else {
|
||||
logFunc("✓ seatd disabled (elogind manages the seat)")
|
||||
}
|
||||
}
|
||||
if err := privesc.Run(context.Background(), sudoPassword, "usermod", "-aG", "video,input", greeterUser); err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ could not add %s to video/input groups: %v", greeterUser, err))
|
||||
} else {
|
||||
logFunc(fmt.Sprintf("✓ %s added to video/input groups (elogind manages the seat)", greeterUser))
|
||||
}
|
||||
}
|
||||
|
||||
func ensureGreetdPamRundir(sudoPassword string, logFunc func(string)) {
|
||||
const pamPath = "/etc/pam.d/greetd"
|
||||
data, err := os.ReadFile(pamPath)
|
||||
@@ -483,11 +529,23 @@ 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)
|
||||
if strings.HasPrefix(trimmed, "Exec=") {
|
||||
return strings.TrimSpace(trimmed[len("Exec="):]), nil
|
||||
switch {
|
||||
case trimmed == "" || strings.HasPrefix(trimmed, "#"):
|
||||
continue
|
||||
case strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]"):
|
||||
inDesktopEntry = trimmed == "[Desktop Entry]"
|
||||
continue
|
||||
case !inDesktopEntry:
|
||||
continue
|
||||
}
|
||||
key, value, found := strings.Cut(trimmed, "=")
|
||||
if !found || strings.TrimSpace(key) != "Exec" {
|
||||
continue
|
||||
}
|
||||
return strings.TrimSpace(value), nil
|
||||
}
|
||||
return "", fmt.Errorf("no Exec= line found in %s", path)
|
||||
}
|
||||
@@ -990,7 +1048,7 @@ func TryInstallGreeterPackage(logFunc func(string), sudoPassword string) bool {
|
||||
failHint = fmt.Sprintf("⚠ dms-greeter install failed. Install from AUR: %s -S greetd-dms-greeter-git", aurHelper)
|
||||
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://avengemedia.github.io/DankMaterialShell/current' | sudo tee /etc/xbps.d/dms.conf\nsudo xbps-install -Sy dms-greeter"
|
||||
failHint = "⚠ dms-greeter install failed. Add the DMS XBPS repo manually:\necho 'repository=https://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))
|
||||
@@ -1740,6 +1798,10 @@ 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)
|
||||
@@ -2271,13 +2333,7 @@ vt = 1
|
||||
return fmt.Errorf("failed to read greetd config: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
commandValue := buildGreetdCommand(resolveGreeterWrapperPath(), compositor, dmsPath, IsVoidLinux())
|
||||
|
||||
commandLine := fmt.Sprintf(`command = "%s"`, commandValue)
|
||||
newConfig := upsertDefaultSession(configContent, greeterUser, commandLine)
|
||||
@@ -2289,6 +2345,84 @@ 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)
|
||||
@@ -2430,7 +2564,11 @@ func EnableGreetd(sudoPassword string, logFunc func(string)) error {
|
||||
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)
|
||||
|
||||
@@ -169,6 +169,56 @@ 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,6 +3,7 @@ package greeter
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -93,18 +94,95 @@ 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
|
||||
}
|
||||
execLine = strings.TrimSpace(stripDesktopExecCodes(execLine))
|
||||
if execLine == "" {
|
||||
|
||||
argv := parseExecString(strings.TrimSpace(execLine))
|
||||
if len(argv) == 0 {
|
||||
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("/bin/sh", []string{"sh", "-c", "exec " + execLine}, env)
|
||||
return syscall.Exec(resolved, argv, env)
|
||||
}
|
||||
|
||||
func LaunchSessionFromMemory(cacheDir, homeDir string) error {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -299,6 +299,9 @@ 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 {
|
||||
@@ -1046,18 +1049,26 @@ func luaActionStringFromHyprlangAction(action string) string {
|
||||
if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok {
|
||||
return expr
|
||||
}
|
||||
return action
|
||||
// Unrecognized dispatchers are freeform text, not Lua; forward them to
|
||||
// hyprctl quoted so a stray `"` can't produce broken Lua output.
|
||||
return luaHyprctlDispatchFunction(action)
|
||||
}
|
||||
|
||||
func luaExprToInternalAction(expr string) string {
|
||||
// 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) {
|
||||
d, p := luaExprToDispatcherParams(expr)
|
||||
if d == expr && p == "" {
|
||||
return expr, true
|
||||
}
|
||||
if d == "exec" && p != "" && !strings.HasPrefix(p, "hyprctl dispatch lua:") {
|
||||
return "exec " + p
|
||||
return "exec " + p, false
|
||||
}
|
||||
if p != "" {
|
||||
return d + " " + p
|
||||
return d + " " + p, false
|
||||
}
|
||||
return d
|
||||
return d, false
|
||||
}
|
||||
|
||||
func luaBindOptions(bind *hyprlandOverrideBind) []string {
|
||||
@@ -1075,20 +1086,25 @@ func luaBindOptions(bind *hyprlandOverrideBind) []string {
|
||||
}
|
||||
|
||||
func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
|
||||
key := formatLuaBindKey(bind.Key)
|
||||
key := strconv.Quote(formatLuaBindKey(bind.Key))
|
||||
if bind.Unbind {
|
||||
fmt.Fprintf(sb, `hl.unbind("%s")`, key)
|
||||
fmt.Fprintf(sb, `hl.unbind(%s)`, key)
|
||||
sb.WriteByte('\n')
|
||||
return
|
||||
}
|
||||
expr := luaActionStringFromHyprlangAction(bind.Action)
|
||||
var expr string
|
||||
if bind.RawLuaAction {
|
||||
expr = bind.Action
|
||||
} else {
|
||||
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')
|
||||
}
|
||||
@@ -1104,7 +1120,7 @@ func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) {
|
||||
}
|
||||
internalKey := luaKeyComboToInternalKey(kbc)
|
||||
|
||||
action := luaExprToInternalAction(actionExpr)
|
||||
action, isRawLua := luaExprToInternalAction(actionExpr)
|
||||
flags := luaBindOptFlags(optSuffix)
|
||||
description := luaBindOptDescription(optSuffix)
|
||||
if description == "" {
|
||||
@@ -1115,6 +1131,7 @@ func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) {
|
||||
Action: action,
|
||||
Description: description,
|
||||
Flags: flags,
|
||||
RawLuaAction: isRawLua,
|
||||
}, true
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,7 @@ func TestWriteLuaBindLineLeavesCustomLuaDispatcherRaw(t *testing.T) {
|
||||
Key: "Super+u",
|
||||
Action: "hl.dsp.no_op()",
|
||||
Description: "Custom Lua",
|
||||
RawLuaAction: true,
|
||||
})
|
||||
|
||||
want := `hl.unbind("SUPER + U")
|
||||
@@ -159,6 +160,24 @@ 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
|
||||
@@ -226,15 +245,12 @@ func TestParseLuaBindLineHandlesFunctionDispatcherFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaActionStringLeavesCustomLuaDispatcherRaw(t *testing.T) {
|
||||
func TestLuaActionStringFromHyprlangActionAlwaysQuotesUnrecognizedText(t *testing.T) {
|
||||
got := luaActionStringFromHyprlangAction("hl.dsp.no_op()")
|
||||
want := `hl.dsp.no_op()`
|
||||
want := `function() hl.exec_cmd("hyprctl dispatch hl.dsp.no_op()") end`
|
||||
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) {
|
||||
|
||||
@@ -124,7 +124,9 @@ func GetLogger() *Logger {
|
||||
logger = &Logger{base}
|
||||
|
||||
if path := os.Getenv("DMS_LOG_FILE"); path != "" {
|
||||
_ = SetLogFile(path)
|
||||
logMu.Lock()
|
||||
_ = setLogFile(logger, path)
|
||||
logMu.Unlock()
|
||||
}
|
||||
})
|
||||
return logger
|
||||
@@ -145,15 +147,18 @@ 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)
|
||||
|
||||
@@ -44,6 +44,7 @@ type TemplateDef struct {
|
||||
ID string
|
||||
Commands []string
|
||||
Flatpaks []string
|
||||
ConfigDirs []string
|
||||
ConfigFile string
|
||||
Kind TemplateKind
|
||||
RunUnconditionally bool
|
||||
@@ -60,9 +61,9 @@ var templateRegistry = []TemplateDef{
|
||||
{ID: "firefox", Commands: []string{"firefox"}, ConfigFile: "firefox.toml"},
|
||||
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
|
||||
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
|
||||
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
|
||||
{ID: "vencord", Commands: []string{"discord", "Discord", "discord-canary", "DiscordCanary"}, Flatpaks: []string{"com.discordapp.Discord", "com.discordapp.DiscordCanary"}, ConfigFile: "vencord.toml"},
|
||||
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
|
||||
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigDirs: []string{"vesktop"}, ConfigFile: "vesktop.toml"},
|
||||
{ID: "vencord", Commands: []string{"discord", "Discord", "discord-canary", "DiscordCanary"}, Flatpaks: []string{"com.discordapp.Discord", "com.discordapp.DiscordCanary"}, ConfigDirs: []string{"Vencord"}, ConfigFile: "vencord.toml"},
|
||||
{ID: "equibop", Commands: []string{"equibop"}, ConfigDirs: []string{"equibop"}, ConfigFile: "equibop.toml"},
|
||||
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
|
||||
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
|
||||
{ID: "foot", Commands: []string{"foot"}, ConfigFile: "foot.toml", Kind: TemplateKindTerminal},
|
||||
@@ -260,15 +261,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
|
||||
}
|
||||
@@ -459,9 +460,9 @@ output_path = '%s'
|
||||
case TemplateKindGTK:
|
||||
switch opts.Mode {
|
||||
case ColorModeLight:
|
||||
appendConfig(opts, cfgFile, nil, nil, "gtk3-light.toml")
|
||||
appendConfig(opts, cfgFile, nil, nil, nil, "gtk3-light.toml")
|
||||
default:
|
||||
appendConfig(opts, cfgFile, nil, nil, "gtk3-dark.toml")
|
||||
appendConfig(opts, cfgFile, nil, nil, nil, "gtk3-dark.toml")
|
||||
}
|
||||
case TemplateKindTerminal:
|
||||
appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
|
||||
@@ -474,10 +475,10 @@ output_path = '%s'
|
||||
appendVSCodeConfig(cfgFile, "vscode-insiders", filepath.Join(homeDir, ".vscode-insiders/extensions"), opts.ShellDir)
|
||||
case TemplateKindEmacs:
|
||||
if utils.EmacsConfigDir() != "" {
|
||||
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
|
||||
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
|
||||
}
|
||||
default:
|
||||
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
|
||||
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,13 +513,14 @@ func appendConfig(
|
||||
cfgFile *os.File,
|
||||
checkCmd []string,
|
||||
checkFlatpaks []string,
|
||||
checkConfigDirs []string,
|
||||
fileName string,
|
||||
) {
|
||||
configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName)
|
||||
if _, err := os.Stat(configPath); err != nil {
|
||||
return
|
||||
}
|
||||
if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) {
|
||||
if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) && !configDirExists(checkConfigDirs) {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(configPath)
|
||||
@@ -599,6 +601,20 @@ func templateSessionActive(tmpl TemplateDef) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func configDirExists(names []string) bool {
|
||||
configHome := utils.XDGConfigHome()
|
||||
if configHome == "" {
|
||||
return false
|
||||
}
|
||||
for _, name := range names {
|
||||
info, err := os.Stat(filepath.Join(configHome, name))
|
||||
if err == nil && info.IsDir() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func appExists(checker utils.AppChecker, checkCmd []string, checkFlatpaks []string) bool {
|
||||
// Both nil is treated as "skip check" / unconditionally run
|
||||
if checkCmd == nil && checkFlatpaks == nil {
|
||||
@@ -1006,6 +1022,13 @@ 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)
|
||||
}
|
||||
@@ -1089,7 +1112,7 @@ func CheckTemplates(checker utils.AppChecker) []TemplateCheck {
|
||||
case tmpl.Kind == TemplateKindEmacs:
|
||||
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks) && utils.EmacsConfigDir() != ""
|
||||
default:
|
||||
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks) && templateSessionActive(tmpl)
|
||||
detected = (appExists(checker, tmpl.Commands, tmpl.Flatpaks) || configDirExists(tmpl.ConfigDirs)) && templateSessionActive(tmpl)
|
||||
}
|
||||
|
||||
checks = append(checks, TemplateCheck{ID: tmpl.ID, Detected: detected})
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestAppendConfigBinaryExists(t *testing.T) {
|
||||
|
||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||
|
||||
appendConfig(opts, cfgFile, []string{"sh"}, nil, "test.toml")
|
||||
appendConfig(opts, cfgFile, []string{"sh"}, nil, nil, "test.toml")
|
||||
|
||||
cfgFile.Close()
|
||||
output, err := os.ReadFile(outFile)
|
||||
@@ -82,7 +82,7 @@ func TestAppendConfigBinaryDoesNotExist(t *testing.T) {
|
||||
|
||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||
|
||||
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{}, "test.toml")
|
||||
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{}, nil, "test.toml")
|
||||
|
||||
cfgFile.Close()
|
||||
output, err := os.ReadFile(outFile)
|
||||
@@ -122,7 +122,7 @@ func TestAppendConfigFlatpakExists(t *testing.T) {
|
||||
|
||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||
|
||||
appendConfig(opts, cfgFile, nil, []string{"app.zen_browser.zen"}, "test.toml")
|
||||
appendConfig(opts, cfgFile, nil, []string{"app.zen_browser.zen"}, nil, "test.toml")
|
||||
|
||||
cfgFile.Close()
|
||||
output, err := os.ReadFile(outFile)
|
||||
@@ -163,7 +163,7 @@ func TestAppendConfigFlatpakDoesNotExist(t *testing.T) {
|
||||
|
||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||
|
||||
appendConfig(opts, cfgFile, []string{}, []string{"com.nonexistent.flatpak"}, "test.toml")
|
||||
appendConfig(opts, cfgFile, []string{}, []string{"com.nonexistent.flatpak"}, nil, "test.toml")
|
||||
|
||||
cfgFile.Close()
|
||||
output, err := os.ReadFile(outFile)
|
||||
@@ -203,7 +203,7 @@ func TestAppendConfigBothExist(t *testing.T) {
|
||||
|
||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||
|
||||
appendConfig(opts, cfgFile, []string{"sh"}, []string{"app.zen_browser.zen"}, "test.toml")
|
||||
appendConfig(opts, cfgFile, []string{"sh"}, []string{"app.zen_browser.zen"}, nil, "test.toml")
|
||||
|
||||
cfgFile.Close()
|
||||
output, err := os.ReadFile(outFile)
|
||||
@@ -244,7 +244,7 @@ func TestAppendConfigNeitherExists(t *testing.T) {
|
||||
|
||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||
|
||||
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, "test.toml")
|
||||
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, nil, "test.toml")
|
||||
|
||||
cfgFile.Close()
|
||||
output, err := os.ReadFile(outFile)
|
||||
@@ -281,7 +281,7 @@ func TestAppendConfigNoChecks(t *testing.T) {
|
||||
|
||||
opts := &Options{ShellDir: shellDir}
|
||||
|
||||
appendConfig(opts, cfgFile, nil, nil, "test.toml")
|
||||
appendConfig(opts, cfgFile, nil, nil, nil, "test.toml")
|
||||
|
||||
cfgFile.Close()
|
||||
output, err := os.ReadFile(outFile)
|
||||
@@ -312,7 +312,7 @@ func TestAppendConfigFileDoesNotExist(t *testing.T) {
|
||||
|
||||
opts := &Options{ShellDir: shellDir}
|
||||
|
||||
appendConfig(opts, cfgFile, nil, nil, "nonexistent.toml")
|
||||
appendConfig(opts, cfgFile, nil, nil, nil, "nonexistent.toml")
|
||||
|
||||
cfgFile.Close()
|
||||
output, err := os.ReadFile(outFile)
|
||||
@@ -487,3 +487,90 @@ func TestBuildMergedConfigSkipsMangowcWithoutActiveSession(t *testing.T) {
|
||||
}
|
||||
assert.NotContains(t, string(output), "[templates.dmsmango]")
|
||||
}
|
||||
|
||||
func TestAppendConfigConfigDirExists(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
shellDir := filepath.Join(tempDir, "shell")
|
||||
configsDir := filepath.Join(shellDir, "matugen", "configs")
|
||||
if err := os.MkdirAll(configsDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create configs dir: %v", err)
|
||||
}
|
||||
|
||||
testConfig := "vencord config content"
|
||||
if err := os.WriteFile(filepath.Join(configsDir, "vencord.toml"), []byte(testConfig), 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
configHome := filepath.Join(tempDir, "config")
|
||||
if err := os.MkdirAll(filepath.Join(configHome, "Vencord"), 0o755); err != nil {
|
||||
t.Fatalf("failed to create Vencord config dir: %v", err)
|
||||
}
|
||||
t.Setenv("XDG_CONFIG_HOME", configHome)
|
||||
|
||||
outFile := filepath.Join(tempDir, "output.toml")
|
||||
cfgFile, err := os.Create(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create output file: %v", err)
|
||||
}
|
||||
defer cfgFile.Close()
|
||||
|
||||
mockChecker := mocks_utils.NewMockAppChecker(t)
|
||||
mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
|
||||
mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
|
||||
|
||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||
|
||||
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, []string{"Vencord"}, "vencord.toml")
|
||||
|
||||
cfgFile.Close()
|
||||
output, err := os.ReadFile(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, testConfig+"\n", string(output))
|
||||
}
|
||||
|
||||
func TestAppendConfigConfigDirDoesNotExist(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
shellDir := filepath.Join(tempDir, "shell")
|
||||
configsDir := filepath.Join(shellDir, "matugen", "configs")
|
||||
if err := os.MkdirAll(configsDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create configs dir: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(configsDir, "vencord.toml"), []byte("vencord config content"), 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
configHome := filepath.Join(tempDir, "config")
|
||||
if err := os.MkdirAll(configHome, 0o755); err != nil {
|
||||
t.Fatalf("failed to create config home: %v", err)
|
||||
}
|
||||
t.Setenv("XDG_CONFIG_HOME", configHome)
|
||||
|
||||
outFile := filepath.Join(tempDir, "output.toml")
|
||||
cfgFile, err := os.Create(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create output file: %v", err)
|
||||
}
|
||||
defer cfgFile.Close()
|
||||
|
||||
mockChecker := mocks_utils.NewMockAppChecker(t)
|
||||
mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
|
||||
mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
|
||||
|
||||
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
|
||||
|
||||
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, []string{"Vencord"}, "vencord.toml")
|
||||
|
||||
cfgFile.Close()
|
||||
output, err := os.ReadFile(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
assert.Empty(t, string(output))
|
||||
}
|
||||
|
||||
+504
-47
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -32,6 +33,32 @@ 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",
|
||||
@@ -47,6 +74,7 @@ type AuthSettings struct {
|
||||
EnableU2f bool `json:"enableU2f"`
|
||||
GreeterEnableFprint bool `json:"greeterEnableFprint"`
|
||||
GreeterEnableU2f bool `json:"greeterEnableU2f"`
|
||||
GreeterPamExternallyManaged bool `json:"greeterPamExternallyManaged"`
|
||||
}
|
||||
|
||||
type SyncAuthOptions struct {
|
||||
@@ -75,10 +103,50 @@ type lockscreenPamIncludeDirective struct {
|
||||
}
|
||||
|
||||
type lockscreenPamResolver struct {
|
||||
pamDir string
|
||||
baseDirs []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",
|
||||
@@ -169,6 +237,14 @@ 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
|
||||
}
|
||||
@@ -375,32 +451,10 @@ 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 := resolveLockscreenPamIncludePath(r.pamDir, serviceName)
|
||||
path, err := r.locate(serviceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to read PAM file %s: %w", serviceName, err)
|
||||
}
|
||||
|
||||
for _, seen := range stack {
|
||||
@@ -458,30 +512,74 @@ func (r lockscreenPamResolver) resolveService(serviceName string, filterType str
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func buildManagedLockscreenPamContent(pamDir string, readFile func(string) ([]byte, error)) (string, error) {
|
||||
resolver := lockscreenPamResolver{
|
||||
pamDir: pamDir,
|
||||
readFile: readFile,
|
||||
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
|
||||
}
|
||||
|
||||
resolvedLines, err := resolver.resolveService("login", "", nil)
|
||||
// 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()
|
||||
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")
|
||||
@@ -494,6 +592,365 @@ func buildManagedLockscreenPamContent(pamDir string, readFile func(string) ([]by
|
||||
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")
|
||||
@@ -508,7 +965,7 @@ func buildManagedLockscreenU2FPamContent() string {
|
||||
|
||||
func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error {
|
||||
if deps.isNixOS() {
|
||||
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.")
|
||||
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.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -522,7 +979,7 @@ func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string,
|
||||
return fmt.Errorf("failed to read %s: %w", deps.dankshellPath, err)
|
||||
}
|
||||
|
||||
content, err := buildManagedLockscreenPamContent(deps.pamDir, deps.readFile)
|
||||
content, err := buildManagedLockscreenPamContent([]string{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)
|
||||
}
|
||||
@@ -725,7 +1182,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=1 timeout=5")
|
||||
blockLines = append(blockLines, "auth sufficient pam_fprintd.so max-tries=2 timeout=10")
|
||||
}
|
||||
if wantU2f {
|
||||
blockLines = append(blockLines, "auth sufficient pam_u2f.so cue nouserok timeout=10")
|
||||
|
||||
@@ -246,6 +246,31 @@ 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{
|
||||
@@ -281,7 +306,7 @@ func TestBuildManagedLockscreenPamContent(t *testing.T) {
|
||||
env.writePamFile(t, name, content)
|
||||
}
|
||||
|
||||
content, err := buildManagedLockscreenPamContent(env.pamDir, os.ReadFile)
|
||||
content, err := buildManagedLockscreenPamContent([]string{env.pamDir}, os.ReadFile)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
||||
@@ -314,6 +339,192 @@ 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()
|
||||
|
||||
@@ -400,8 +611,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], "/etc/pam.d/login") {
|
||||
t.Fatalf("expected NixOS informational log mentioning /etc/pam.d/login, got %v", logs)
|
||||
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 _, err := os.Stat(env.dankshellPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected no dankshell file to be written on NixOS path, stat err = %v", err)
|
||||
@@ -512,7 +723,7 @@ func TestSyncGreeterPamConfigWithDeps(t *testing.T) {
|
||||
got := readFileString(t, env.greetdPath)
|
||||
for _, want := range []string{
|
||||
GreeterPamManagedBlockStart,
|
||||
"auth sufficient pam_fprintd.so max-tries=1 timeout=5",
|
||||
"auth sufficient pam_fprintd.so max-tries=2 timeout=10",
|
||||
"auth sufficient pam_u2f.so cue nouserok timeout=10",
|
||||
GreeterPamManagedBlockEnd,
|
||||
} {
|
||||
@@ -575,6 +786,301 @@ 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()
|
||||
|
||||
@@ -629,7 +1135,7 @@ func TestSyncAuthConfigWithDeps(t *testing.T) {
|
||||
}
|
||||
|
||||
greetd := readFileString(t, env.greetdPath)
|
||||
if !strings.Contains(greetd, "auth sufficient pam_fprintd.so max-tries=1 timeout=5") {
|
||||
if !strings.Contains(greetd, "auth sufficient pam_fprintd.so max-tries=2 timeout=10") {
|
||||
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") {
|
||||
@@ -637,6 +1143,39 @@ 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,7 +64,20 @@ 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 {
|
||||
@@ -507,6 +520,10 @@ 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 {
|
||||
|
||||
@@ -135,15 +135,14 @@ func EscapeSingleQuotes(s string) string {
|
||||
}
|
||||
|
||||
// MakeCommand returns a bash command string that runs `command` with the
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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(password, command string) string {
|
||||
func MakeCommand(command string) string {
|
||||
t, err := Detect()
|
||||
if err != nil {
|
||||
return failingShell(err)
|
||||
@@ -151,9 +150,6 @@ func MakeCommand(password, 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))
|
||||
@@ -166,9 +162,19 @@ func MakeCommand(password, 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.
|
||||
// command writing a clear error to stderr. A sudo password is piped via
|
||||
// stdin (sudo -S) so it never appears in argv.
|
||||
func ExecCommand(ctx context.Context, password, command string) *exec.Cmd {
|
||||
return exec.CommandContext(ctx, "bash", "-c", MakeCommand(password, command))
|
||||
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))
|
||||
}
|
||||
|
||||
// ExecArgv builds an exec.Cmd that runs argv as root via the detected tool.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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,3 +23,51 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
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,41 +30,30 @@ func DetectCompositor() Compositor {
|
||||
return detectedCompositor
|
||||
}
|
||||
|
||||
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")
|
||||
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},
|
||||
}
|
||||
|
||||
switch {
|
||||
case mangoSocket != "":
|
||||
if _, err := os.Stat(mangoSocket); err == nil {
|
||||
detectedCompositor = CompositorMango
|
||||
return detectedCompositor
|
||||
// A stale env var from a previous session must not mask the live compositor
|
||||
for _, c := range candidates {
|
||||
if c.socket == "" {
|
||||
continue
|
||||
}
|
||||
case niriSocket != "":
|
||||
if _, err := os.Stat(niriSocket); err == nil {
|
||||
detectedCompositor = CompositorNiri
|
||||
return detectedCompositor
|
||||
if c.needsStat {
|
||||
if _, err := os.Stat(c.socket); err != nil {
|
||||
continue
|
||||
}
|
||||
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
|
||||
detectedCompositor = c.compositor
|
||||
return detectedCompositor
|
||||
}
|
||||
|
||||
@@ -90,7 +79,7 @@ func GetActiveWindow() (*WindowGeometry, error) {
|
||||
case CompositorMango:
|
||||
return getMangoActiveWindow()
|
||||
default:
|
||||
return nil, fmt.Errorf("window capture requires Hyprland or Mango")
|
||||
return nil, fmt.Errorf("window capture requires Hyprland, Mango, or niri")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,58 @@ 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)
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
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,6 +95,9 @@ type RegionSelector struct {
|
||||
showCapturedCursor bool
|
||||
shiftHeld bool
|
||||
|
||||
phase selectorPhase
|
||||
scroll *scrollSession
|
||||
|
||||
running bool
|
||||
cancelled bool
|
||||
result Region
|
||||
@@ -167,20 +170,32 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
|
||||
|
||||
r.running = true
|
||||
for r.running {
|
||||
if err := r.ctx.Dispatch(); err != nil {
|
||||
if err := r.dispatchOrTick(); 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{
|
||||
@@ -188,6 +203,7 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
|
||||
Region: r.result,
|
||||
YInverted: yInverted,
|
||||
Format: format,
|
||||
Scale: scale,
|
||||
}, false, nil
|
||||
}
|
||||
|
||||
@@ -691,7 +707,9 @@ func (r *RegionSelector) initRenderBuffer(os *OutputSurface) {
|
||||
}
|
||||
slot.pool = pool
|
||||
|
||||
wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), os.screenFormat)
|
||||
// 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))
|
||||
if err != nil {
|
||||
log.Error("create render slot wl_buffer failed", "err", err)
|
||||
pool.Destroy()
|
||||
@@ -733,8 +751,9 @@ func (r *RegionSelector) applyPreSelection(os *OutputSurface) {
|
||||
|
||||
x1 := float64(r.preSelect.X-os.output.x) * scaleX
|
||||
y1 := float64(r.preSelect.Y-os.output.y) * 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
|
||||
// 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
|
||||
|
||||
r.selection.hasSelection = true
|
||||
r.selection.dragging = false
|
||||
@@ -764,10 +783,13 @@ func (r *RegionSelector) redrawSurface(os *OutputSurface) {
|
||||
return
|
||||
}
|
||||
|
||||
switch r.phase {
|
||||
case phaseScroll:
|
||||
r.drawScrollOverlay(os, slot.shm)
|
||||
default:
|
||||
slot.shm.CopyFrom(srcBuf)
|
||||
|
||||
// Draw overlay (dimming + selection) into this slot
|
||||
r.drawOverlay(os, slot.shm)
|
||||
}
|
||||
|
||||
if os.viewport != nil {
|
||||
_ = os.wlSurface.SetBufferScale(1)
|
||||
@@ -803,6 +825,8 @@ func (r *RegionSelector) cleanup() {
|
||||
r.cursorBuffer.Close()
|
||||
}
|
||||
|
||||
r.cleanupScroll()
|
||||
|
||||
for _, os := range r.surfaces {
|
||||
for _, slot := range os.slots {
|
||||
if slot == nil {
|
||||
|
||||
@@ -94,6 +94,20 @@ 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 {
|
||||
@@ -135,6 +149,17 @@ 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
|
||||
@@ -152,17 +177,15 @@ func (r *RegionSelector) setupKeyboardHandlers() {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *RegionSelector) finishSelection() {
|
||||
func (r *RegionSelector) selectionDeviceRect() (*OutputSurface, int, int, int, int) {
|
||||
if r.selection.surface == nil {
|
||||
r.running = false
|
||||
return
|
||||
return nil, 0, 0, 0, 0
|
||||
}
|
||||
|
||||
os := r.selection.surface
|
||||
srcBuf := r.getSourceBuffer(os)
|
||||
if srcBuf == nil {
|
||||
r.running = false
|
||||
return
|
||||
return nil, 0, 0, 0, 0
|
||||
}
|
||||
|
||||
x1, y1 := r.selection.anchorX, r.selection.anchorY
|
||||
@@ -181,24 +204,10 @@ func (r *RegionSelector) finishSelection() {
|
||||
scaleY = float64(srcBuf.Height) / float64(os.logicalH)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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)
|
||||
|
||||
w, h := bx2-bx1+1, by2-by1+1
|
||||
if r.shiftHeld && w != h {
|
||||
@@ -215,7 +224,23 @@ func (r *RegionSelector) finishSelection() {
|
||||
h = 1
|
||||
}
|
||||
|
||||
// Create cropped buffer and copy pixels directly
|
||||
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)
|
||||
|
||||
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 the entire buffer
|
||||
// dim, forcing alpha: the X-format source's padding byte is undefined
|
||||
for y := 0; y < h; y++ {
|
||||
off := y * stride
|
||||
for x := 0; x < w; x++ {
|
||||
@@ -68,6 +68,7 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +111,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] = srcData[si+3]
|
||||
data[di+3] = 255
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +127,81 @@ 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,6 +28,21 @@ 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 {
|
||||
@@ -77,7 +92,7 @@ func (s *Screenshoter) Run() (*CaptureResult, error) {
|
||||
switch s.config.Mode {
|
||||
case ModeLastRegion:
|
||||
return s.captureLastRegion()
|
||||
case ModeRegion:
|
||||
case ModeRegion, ModeScroll:
|
||||
return s.captureRegion()
|
||||
case ModeWindow:
|
||||
return s.captureWindow()
|
||||
@@ -130,6 +145,10 @@ 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
|
||||
@@ -255,6 +274,7 @@ func (s *Screenshoter) captureMangoWindow(output *WaylandOutput, region Region,
|
||||
Region: region,
|
||||
YInverted: false,
|
||||
Format: result.Format,
|
||||
Scale: scale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -430,6 +450,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -502,6 +523,7 @@ func (s *Screenshoter) captureWholeOutput(output *WaylandOutput) (*CaptureResult
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Scale = output.effectiveScale()
|
||||
|
||||
if result.YInverted {
|
||||
result.Buffer.FlipVertical()
|
||||
@@ -604,6 +626,7 @@ func (s *Screenshoter) captureAndCrop(output *WaylandOutput, region Region) (*Ca
|
||||
Region: region,
|
||||
YInverted: false,
|
||||
Format: result.Format,
|
||||
Scale: scale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -612,16 +635,7 @@ func (s *Screenshoter) captureRegionOnOutput(output *WaylandOutput, region Regio
|
||||
return s.captureRegionOnTransformedOutput(output, region)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
scale := output.effectiveScale()
|
||||
|
||||
localX := int32(float64(region.X-output.x) * scale)
|
||||
localY := int32(float64(region.Y-output.y) * scale)
|
||||
@@ -660,7 +674,12 @@ func (s *Screenshoter) captureRegionOnOutput(output *WaylandOutput, region Regio
|
||||
return nil, fmt.Errorf("capture region: %w", err)
|
||||
}
|
||||
|
||||
return s.processFrame(frame, region)
|
||||
result, err := s.processFrame(frame, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Scale = scale
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, region Region) (*CaptureResult, error) {
|
||||
@@ -669,16 +688,7 @@ func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, r
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
scale := output.effectiveScale()
|
||||
|
||||
localX := int(float64(region.X-output.x) * scale)
|
||||
localY := int(float64(region.Y-output.y) * scale)
|
||||
@@ -730,6 +740,7 @@ func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, r
|
||||
Region: region,
|
||||
YInverted: false,
|
||||
Format: result.Format,
|
||||
Scale: scale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -747,12 +758,16 @@ 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
|
||||
@@ -771,6 +786,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -779,6 +795,7 @@ func (s *Screenshoter) processFrame(frame *wlr_screencopy.ZwlrScreencopyFrameV1,
|
||||
pool.Destroy()
|
||||
pool = nil
|
||||
log.Error("failed to create wl_buffer", "err", err)
|
||||
failed = true
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package screenshot
|
||||
|
||||
// Frame stitcher after mark-shot's column-sampling design
|
||||
// (https://github.com/jswysnemc/mark-shot, src/scroll/stitcher_algorithm.cpp).
|
||||
// Only rows overhanging the captured range are committed; frames that match
|
||||
// nothing are dropped without touching state.
|
||||
|
||||
const (
|
||||
stitchMaxCanvasBytes = 256 << 20
|
||||
stitchMaxRowsCap = 30000
|
||||
|
||||
// mark-shot: StitchConfig{100, 9.0f, 15, 1.0f}
|
||||
stitchAcceptDiff = 9.0
|
||||
stitchApproxDiff = 1.0
|
||||
stitchMinCompare = 50
|
||||
stitchMinCanvas = 100
|
||||
stitchMinAppend = 15
|
||||
stitchCoarseStep = 8
|
||||
stitchPredictWindow = 160
|
||||
stitchBandSamples = 17
|
||||
|
||||
// mark-shot: kDuplicateAvgDiff=1.1f, kDuplicateMaxDiff=4, 18x24 grid
|
||||
stitchDupAvgDiff = 1.1
|
||||
stitchDupMaxDiff = 4.0
|
||||
stitchSigCols = 18
|
||||
stitchSigRows = 24
|
||||
|
||||
// blank rows agree at every offset and must not decide a match
|
||||
stitchActivityMin = 2.0
|
||||
stitchRowMatchTol = 4.0
|
||||
stitchMinActive = 12
|
||||
)
|
||||
|
||||
// mean luminance per band (8-32%, 34-66%, 68-92%); the outer 8% is chrome
|
||||
type rowCols [3]float32
|
||||
|
||||
type stitcher struct {
|
||||
stride int
|
||||
sampleOffs [3][]int
|
||||
|
||||
canvas []byte
|
||||
cols []rowCols
|
||||
|
||||
anchor int
|
||||
last []rowCols
|
||||
lastOffset int
|
||||
|
||||
maxRows int
|
||||
full bool
|
||||
}
|
||||
|
||||
func newStitcher(stride int) *stitcher {
|
||||
px := stride / 4
|
||||
st := &stitcher{
|
||||
stride: stride,
|
||||
maxRows: min(stitchMaxCanvasBytes/stride, stitchMaxRowsCap),
|
||||
}
|
||||
bands := [3][2]float64{{0.08, 0.32}, {0.34, 0.66}, {0.68, 0.92}}
|
||||
for b, band := range bands {
|
||||
lo := int(float64(px) * band[0])
|
||||
hi := max(int(float64(px)*band[1]), lo+1)
|
||||
n := min(stitchBandSamples, hi-lo)
|
||||
for s := range n {
|
||||
st.sampleOffs[b] = append(st.sampleOffs[b], (lo+(hi-lo)*s/n)*4)
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func (st *stitcher) rowSamples(data []byte) []rowCols {
|
||||
rows := len(data) / st.stride
|
||||
cols := make([]rowCols, rows)
|
||||
for y := range rows {
|
||||
row := data[y*st.stride:]
|
||||
for b := range 3 {
|
||||
var sum float32
|
||||
for _, off := range st.sampleOffs[b] {
|
||||
sum += 0.114*float32(row[off]) + 0.587*float32(row[off+1]) + 0.299*float32(row[off+2])
|
||||
}
|
||||
cols[y][b] = sum / float32(len(st.sampleOffs[b]))
|
||||
}
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
func (st *stitcher) frameSig(data []byte) []float32 {
|
||||
rows := len(data) / st.stride
|
||||
px := st.stride / 4
|
||||
sig := make([]float32, 0, stitchSigCols*stitchSigRows)
|
||||
for gy := range stitchSigRows {
|
||||
y := (2*gy + 1) * rows / (2 * stitchSigRows)
|
||||
for gx := range stitchSigCols {
|
||||
x := (2*gx + 1) * px / (2 * stitchSigCols)
|
||||
off := y*st.stride + x*4
|
||||
sig = append(sig, 0.114*float32(data[off])+0.587*float32(data[off+1])+0.299*float32(data[off+2]))
|
||||
}
|
||||
}
|
||||
return sig
|
||||
}
|
||||
|
||||
func (st *stitcher) rows() int {
|
||||
return len(st.cols)
|
||||
}
|
||||
|
||||
func rowColsDiff(a, b rowCols) float32 {
|
||||
return (abs32(a[0]-b[0]) + abs32(a[1]-b[1]) + abs32(a[2]-b[2])) / 3
|
||||
}
|
||||
|
||||
func duplicateFrame(a, b []float32) bool {
|
||||
if len(a) != len(b) || len(a) == 0 {
|
||||
return false
|
||||
}
|
||||
var sum, maxDiff float32
|
||||
for i := range a {
|
||||
d := abs32(a[i] - b[i])
|
||||
sum += d
|
||||
maxDiff = max(maxDiff, d)
|
||||
}
|
||||
return sum/float32(len(a)) <= stitchDupAvgDiff && maxDiff <= stitchDupMaxDiff
|
||||
}
|
||||
|
||||
// sticky header/footer zones, per mark-shot: 10% top, 8% bottom, min 16px
|
||||
func matchIgnores(h int) (top, bottom int) {
|
||||
if h < 80 {
|
||||
return 0, 0
|
||||
}
|
||||
return clamp(h/10, 16, h/4), clamp(h*8/100, 16, h/4)
|
||||
}
|
||||
|
||||
func activity(f []rowCols) []bool {
|
||||
active := make([]bool, len(f))
|
||||
for i := 1; i < len(f); i++ {
|
||||
active[i] = rowColsDiff(f[i], f[i-1]) > stitchActivityMin
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
func (st *stitcher) pushFrame(frame []byte, f []rowCols) (int, bool) {
|
||||
if st.full || len(f) == 0 {
|
||||
return 0, true
|
||||
}
|
||||
|
||||
h := len(f)
|
||||
if len(st.cols) == 0 {
|
||||
n := st.appendRows(frame, f, 0)
|
||||
st.anchor = 0
|
||||
st.last = f
|
||||
st.lastOffset = 0
|
||||
return n, true
|
||||
}
|
||||
|
||||
pos, ok := st.locateFrame(f, activity(f))
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
delta := pos - st.anchor
|
||||
added := 0
|
||||
if over := pos + h - len(st.cols); over >= stitchMinAppend {
|
||||
added += st.appendRows(frame, f, h-over)
|
||||
}
|
||||
if over := -pos; over >= stitchMinAppend {
|
||||
n := st.prependRows(frame, f, over)
|
||||
added += n
|
||||
pos += n
|
||||
}
|
||||
|
||||
st.anchor = pos
|
||||
st.last = f
|
||||
st.lastOffset = delta
|
||||
return added, true
|
||||
}
|
||||
|
||||
// seamAppend starts a new segment after a jump capture couldn't follow.
|
||||
func (st *stitcher) seamAppend(frame []byte, f []rowCols) int {
|
||||
if st.full || len(f) == 0 {
|
||||
return 0
|
||||
}
|
||||
pos := len(st.cols)
|
||||
n := st.appendRows(frame, f, 0)
|
||||
st.anchor = pos
|
||||
st.last = f
|
||||
st.lastOffset = 0
|
||||
return n
|
||||
}
|
||||
|
||||
func (st *stitcher) locateFrame(f []rowCols, active []bool) (int, bool) {
|
||||
d, diff := st.adjacentOffset(f, active)
|
||||
pred := st.anchor + d
|
||||
|
||||
if diff <= stitchAcceptDiff {
|
||||
if _, ok := st.verifyAt(f, active, pred); ok {
|
||||
return pred, true
|
||||
}
|
||||
}
|
||||
if pos, _, ok := st.scanPositions(f, active, pred, true); ok {
|
||||
return pos, true
|
||||
}
|
||||
pos, _, ok := st.scanPositions(f, active, pred, false)
|
||||
return pos, ok
|
||||
}
|
||||
|
||||
func (st *stitcher) verifyAt(f []rowCols, active []bool, pos int) (float32, bool) {
|
||||
diff, count, activeMatches := st.canvasDiff(f, active, pos)
|
||||
ok := count >= stitchMinCanvas && diff <= stitchAcceptDiff && activeMatches >= stitchMinActive
|
||||
return diff, ok
|
||||
}
|
||||
|
||||
// signed deltas searched outward from the previous one (mark-shot's
|
||||
// predictOffsetIter), early-exiting once a diff beats approxDiff
|
||||
func (st *stitcher) adjacentOffset(f []rowCols, active []bool) (int, float32) {
|
||||
h := len(f)
|
||||
if len(st.last) != h {
|
||||
return 0, float32(1e9)
|
||||
}
|
||||
limit := max(h-stitchMinCompare-1, 0)
|
||||
|
||||
bestD, bestDiff := 0, float32(1e9)
|
||||
countdown := -1
|
||||
try := func(d int) bool {
|
||||
if d < -limit || d > limit {
|
||||
return false
|
||||
}
|
||||
diff, activeMatches := st.pairDiff(f, active, d)
|
||||
if activeMatches >= stitchMinActive && diff < bestDiff {
|
||||
bestDiff, bestD = diff, d
|
||||
}
|
||||
switch {
|
||||
case bestDiff < stitchApproxDiff/4:
|
||||
return true
|
||||
case bestDiff < stitchApproxDiff && countdown < 0:
|
||||
countdown = 10
|
||||
}
|
||||
if countdown > 0 {
|
||||
countdown--
|
||||
}
|
||||
return countdown == 0
|
||||
}
|
||||
|
||||
if try(st.lastOffset) {
|
||||
return bestD, bestDiff
|
||||
}
|
||||
for k := 1; ; k++ {
|
||||
lo, hi := st.lastOffset-k, st.lastOffset+k
|
||||
if lo < -limit && hi > limit {
|
||||
break
|
||||
}
|
||||
if try(hi) || try(lo) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return bestD, bestDiff
|
||||
}
|
||||
|
||||
func (st *stitcher) pairDiff(f []rowCols, active []bool, d int) (float32, int) {
|
||||
h := len(f)
|
||||
top, bottom := matchIgnores(h)
|
||||
lo := max(top, -d)
|
||||
hi := min(h-bottom, h-d)
|
||||
|
||||
count := hi - lo
|
||||
if count < stitchMinCompare {
|
||||
return float32(1e9), 0
|
||||
}
|
||||
var sum float32
|
||||
activeMatches := 0
|
||||
for i := lo; i < hi; i++ {
|
||||
rd := rowColsDiff(f[i], st.last[i+d])
|
||||
sum += rd
|
||||
if active[i] && rd <= stitchRowMatchTol {
|
||||
activeMatches++
|
||||
}
|
||||
}
|
||||
return sum / float32(count), activeMatches
|
||||
}
|
||||
|
||||
func (st *stitcher) canvasDiff(f []rowCols, active []bool, pos int) (float32, int, int) {
|
||||
h := len(f)
|
||||
top, bottom := matchIgnores(h)
|
||||
lo := max(top, -pos)
|
||||
hi := min(h-bottom, len(st.cols)-pos)
|
||||
|
||||
count := hi - lo
|
||||
if count < 1 {
|
||||
return float32(1e9), 0, 0
|
||||
}
|
||||
var sum float32
|
||||
activeMatches := 0
|
||||
for i := lo; i < hi; i++ {
|
||||
rd := rowColsDiff(f[i], st.cols[pos+i])
|
||||
sum += rd
|
||||
if active[i] && rd <= stitchRowMatchTol {
|
||||
activeMatches++
|
||||
}
|
||||
}
|
||||
return sum / float32(count), count, activeMatches
|
||||
}
|
||||
|
||||
// mark-shot's findEdgePosition (nearOnly: edges + prediction window, 1px) and
|
||||
// findKnownPosition (coarse sweep refined around the winner)
|
||||
func (st *stitcher) scanPositions(f []rowCols, active []bool, pred int, nearOnly bool) (int, float32, bool) {
|
||||
h := len(f)
|
||||
C := len(st.cols)
|
||||
minPos := stitchMinCanvas - h
|
||||
maxPos := C - stitchMinCanvas
|
||||
|
||||
bestPos, bestDiff := 0, float32(1e9)
|
||||
bestDist := 1 << 30
|
||||
consider := func(pos int) {
|
||||
if pos < minPos || pos > maxPos {
|
||||
return
|
||||
}
|
||||
diff, ok := st.verifyAt(f, active, pos)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
dist := pos - pred
|
||||
if dist < 0 {
|
||||
dist = -dist
|
||||
}
|
||||
better := diff < bestDiff
|
||||
if !nearOnly {
|
||||
better = dist < bestDist || dist == bestDist && diff < bestDiff
|
||||
}
|
||||
if better {
|
||||
bestPos, bestDiff, bestDist = pos, diff, dist
|
||||
}
|
||||
}
|
||||
|
||||
if nearOnly {
|
||||
for pos := pred - stitchPredictWindow; pos <= pred+stitchPredictWindow; pos++ {
|
||||
consider(pos)
|
||||
}
|
||||
for pos := C - h; pos <= maxPos; pos++ {
|
||||
consider(pos)
|
||||
}
|
||||
for pos := minPos; pos <= 0; pos++ {
|
||||
consider(pos)
|
||||
}
|
||||
if bestDiff > stitchAcceptDiff {
|
||||
return 0, 0, false
|
||||
}
|
||||
return bestPos, bestDiff, true
|
||||
}
|
||||
|
||||
for pos := minPos; pos <= maxPos; pos += stitchCoarseStep {
|
||||
consider(pos)
|
||||
}
|
||||
if bestDiff > stitchAcceptDiff {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
refined, refinedDiff := bestPos, bestDiff
|
||||
for pos := bestPos - stitchCoarseStep + 1; pos < bestPos+stitchCoarseStep; pos++ {
|
||||
if pos == bestPos {
|
||||
continue
|
||||
}
|
||||
if diff, ok := st.verifyAt(f, active, pos); ok && diff < refinedDiff {
|
||||
refined, refinedDiff = pos, diff
|
||||
}
|
||||
}
|
||||
return refined, refinedDiff, true
|
||||
}
|
||||
|
||||
func (st *stitcher) appendRows(frame []byte, f []rowCols, from int) int {
|
||||
n := len(f) - from
|
||||
if room := st.maxRows - len(st.cols); n > room {
|
||||
n = room
|
||||
st.full = true
|
||||
}
|
||||
if n <= 0 {
|
||||
st.full = true
|
||||
return 0
|
||||
}
|
||||
|
||||
st.canvas = append(st.canvas, frame[from*st.stride:(from+n)*st.stride]...)
|
||||
st.cols = append(st.cols, f[from:from+n]...)
|
||||
return n
|
||||
}
|
||||
|
||||
func (st *stitcher) prependRows(frame []byte, f []rowCols, n int) int {
|
||||
if room := st.maxRows - len(st.cols); n > room {
|
||||
n = room
|
||||
st.full = true
|
||||
}
|
||||
if n <= 0 {
|
||||
st.full = true
|
||||
return 0
|
||||
}
|
||||
|
||||
canvas := make([]byte, n*st.stride+len(st.canvas))
|
||||
copy(canvas, frame[:n*st.stride])
|
||||
copy(canvas[n*st.stride:], st.canvas)
|
||||
st.canvas = canvas
|
||||
|
||||
cols := make([]rowCols, 0, n+len(st.cols))
|
||||
cols = append(cols, f[:n]...)
|
||||
st.cols = append(cols, st.cols...)
|
||||
return n
|
||||
}
|
||||
|
||||
func abs32(f float32) float32 {
|
||||
if f < 0 {
|
||||
return -f
|
||||
}
|
||||
return f
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package screenshot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math/rand"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
testStride = 512
|
||||
testFrameH = 240
|
||||
)
|
||||
|
||||
func makePage(t *testing.T, rows int) []byte {
|
||||
t.Helper()
|
||||
rng := rand.New(rand.NewSource(42))
|
||||
page := make([]byte, rows*testStride)
|
||||
rng.Read(page)
|
||||
return page
|
||||
}
|
||||
|
||||
func frameAt(page []byte, top int) []byte {
|
||||
return page[top*testStride : (top+testFrameH)*testStride]
|
||||
}
|
||||
|
||||
func pushFrame(st *stitcher, frame []byte) int {
|
||||
n, _ := st.pushFrame(frame, st.rowSamples(frame))
|
||||
return n
|
||||
}
|
||||
|
||||
func TestStitchSlidingWindows(t *testing.T) {
|
||||
page := makePage(t, 1000)
|
||||
|
||||
for _, delta := range []int{20, 60, 110} {
|
||||
st := newStitcher(testStride)
|
||||
lastTop := 0
|
||||
for top := 0; top+testFrameH <= 900; top += delta {
|
||||
lastTop = top
|
||||
pushFrame(st, frameAt(page, top))
|
||||
}
|
||||
|
||||
wantRows := lastTop + testFrameH
|
||||
if st.rows() != wantRows {
|
||||
t.Fatalf("delta %d: got %d rows, want %d", delta, st.rows(), wantRows)
|
||||
}
|
||||
if !bytes.Equal(st.canvas, page[:wantRows*testStride]) {
|
||||
t.Fatalf("delta %d: canvas does not match source rows", delta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStitchDropsNoOverlap(t *testing.T) {
|
||||
page := makePage(t, 1000)
|
||||
st := newStitcher(testStride)
|
||||
|
||||
pushFrame(st, frameAt(page, 0))
|
||||
if appended := pushFrame(st, frameAt(page, testFrameH+50)); appended != 0 {
|
||||
t.Fatalf("unmatched jump appended %d rows", appended)
|
||||
}
|
||||
if !bytes.Equal(st.canvas, page[:testFrameH*testStride]) {
|
||||
t.Fatal("canvas changed on unmatched frame")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStitchNoGrowthCases(t *testing.T) {
|
||||
page := makePage(t, 1000)
|
||||
blank := make([]byte, testFrameH*testStride)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
first, second []byte
|
||||
}{
|
||||
{"identical frame", frameAt(page, 0), frameAt(page, 0)},
|
||||
{"jitter below min append", frameAt(page, 0), frameAt(page, stitchMinAppend-5)},
|
||||
{"blank on blank", blank, blank},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
st := newStitcher(testStride)
|
||||
pushFrame(st, tc.first)
|
||||
if appended := pushFrame(st, tc.second); appended != 0 {
|
||||
t.Fatalf("%s: appended %d rows", tc.name, appended)
|
||||
}
|
||||
if st.rows() != testFrameH {
|
||||
t.Fatalf("%s: got %d rows, want %d", tc.name, st.rows(), testFrameH)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStitchRevisitNeverDuplicates(t *testing.T) {
|
||||
page := makePage(t, 1000)
|
||||
st := newStitcher(testStride)
|
||||
|
||||
pushFrame(st, frameAt(page, 0))
|
||||
pushFrame(st, frameAt(page, 100))
|
||||
pushFrame(st, frameAt(page, 200))
|
||||
|
||||
for _, top := range []int{150, 60, 0, 80, 190} {
|
||||
if appended := pushFrame(st, frameAt(page, top)); appended != 0 {
|
||||
t.Fatalf("revisited frame at %d appended %d rows", top, appended)
|
||||
}
|
||||
}
|
||||
pushFrame(st, frameAt(page, 300))
|
||||
|
||||
wantRows := 300 + testFrameH
|
||||
if st.rows() != wantRows {
|
||||
t.Fatalf("got %d rows, want %d", st.rows(), wantRows)
|
||||
}
|
||||
if !bytes.Equal(st.canvas, page[:wantRows*testStride]) {
|
||||
t.Fatal("canvas corrupted by revisited frames")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStitchScrollUpPrepends(t *testing.T) {
|
||||
page := makePage(t, 1000)
|
||||
st := newStitcher(testStride)
|
||||
|
||||
pushFrame(st, frameAt(page, 500))
|
||||
if appended := pushFrame(st, frameAt(page, 420)); appended != 80 {
|
||||
t.Fatalf("upward frame appended %d rows, want 80", appended)
|
||||
}
|
||||
pushFrame(st, frameAt(page, 560))
|
||||
|
||||
if !bytes.Equal(st.canvas, page[420*testStride:(560+testFrameH)*testStride]) {
|
||||
t.Fatal("canvas does not match page range after prepend + append")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStitchNoisyChromeStillMatches(t *testing.T) {
|
||||
page := makePage(t, 1000)
|
||||
st := newStitcher(testStride)
|
||||
|
||||
addChrome := func(frame []byte, seed byte) []byte {
|
||||
f := slices.Clone(frame)
|
||||
for y := range testFrameH {
|
||||
for x := range 32 {
|
||||
f[y*testStride+x] = seed + byte(y)
|
||||
}
|
||||
}
|
||||
for y := 100; y < 124; y++ {
|
||||
for x := testStride / 2; x < testStride/2+40; x++ {
|
||||
f[y*testStride+x] ^= 0x08
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
pushFrame(st, addChrome(frameAt(page, 0), 1))
|
||||
if appended := pushFrame(st, addChrome(frameAt(page, 90), 2)); appended != 90 {
|
||||
t.Fatalf("appended %d rows, want 90", appended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStitchMaxRowsCap(t *testing.T) {
|
||||
page := makePage(t, 1000)
|
||||
st := newStitcher(testStride)
|
||||
st.maxRows = testFrameH + 10
|
||||
|
||||
pushFrame(st, frameAt(page, 0))
|
||||
if appended := pushFrame(st, frameAt(page, 100)); appended != 10 {
|
||||
t.Fatalf("appended %d rows past cap, want 10", appended)
|
||||
}
|
||||
if !st.full {
|
||||
t.Fatal("stitcher not marked full at cap")
|
||||
}
|
||||
if pushFrame(st, frameAt(page, 300)) != 0 {
|
||||
t.Fatal("push after full appended rows")
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const (
|
||||
ModeAllScreens
|
||||
ModeOutput
|
||||
ModeLastRegion
|
||||
ModeScroll
|
||||
)
|
||||
|
||||
type Format int
|
||||
@@ -62,6 +63,7 @@ type Config struct {
|
||||
SaveFile bool
|
||||
Notify bool
|
||||
Stdout bool
|
||||
IntervalMs int
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
|
||||
@@ -43,10 +43,10 @@ func NewManager() (*Manager, error) {
|
||||
broker := NewSubscriptionBroker(m.broadcastPairingPrompt)
|
||||
m.promptBroker = broker
|
||||
|
||||
adapter, err := m.findAdapter()
|
||||
adapter, err := findAdapter(conn)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("no bluetooth adapter found: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
m.adapterPath = adapter
|
||||
|
||||
@@ -74,12 +74,12 @@ func NewManager() (*Manager, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Manager) findAdapter() (dbus.ObjectPath, error) {
|
||||
obj := m.dbusConn.Object(bluezService, dbus.ObjectPath("/"))
|
||||
func findAdapter(conn *dbus.Conn) (dbus.ObjectPath, error) {
|
||||
obj := conn.Object(bluezService, dbus.ObjectPath("/"))
|
||||
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
|
||||
|
||||
if err := obj.Call(objectMgrIface+".GetManagedObjects", 0).Store(&objects); err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("%w: %v", ErrNoAdapter, err)
|
||||
}
|
||||
|
||||
for path, interfaces := range objects {
|
||||
@@ -89,7 +89,7 @@ func (m *Manager) findAdapter() (dbus.ObjectPath, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no adapter found")
|
||||
return "", ErrNoAdapter
|
||||
}
|
||||
|
||||
func (m *Manager) initialize() error {
|
||||
@@ -487,6 +487,11 @@ func (m *Manager) StopDiscovery() error {
|
||||
}
|
||||
|
||||
func (m *Manager) SetPowered(powered bool) error {
|
||||
if powered {
|
||||
if err := rfkillUnblockBluetooth(); err != nil {
|
||||
log.Debugf("[BluezManager] rfkill unblock failed: %v", err)
|
||||
}
|
||||
}
|
||||
obj := m.dbusConn.Object(bluezService, m.adapterPath)
|
||||
return obj.Call(propertiesIface+".Set", 0, adapter1Iface, "Powered", dbus.MakeVariant(powered)).Err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package bluez
|
||||
|
||||
func rfkillUnblockBluetooth() error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package bluez
|
||||
|
||||
import "os"
|
||||
|
||||
// linux/rfkill.h: struct rfkill_event { __u32 idx; __u8 type; __u8 op; __u8 soft; __u8 hard; },
|
||||
// RFKILL_TYPE_BLUETOOTH=2, RFKILL_OP_CHANGE_ALL=3
|
||||
func rfkillUnblockBluetooth() error {
|
||||
f, err := os.OpenFile("/dev/rfkill", os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var event [8]byte
|
||||
event[4] = 2
|
||||
event[5] = 3
|
||||
_, err = f.Write(event[:])
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package bluez
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
var ErrNoAdapter = errors.New("no bluetooth adapter found")
|
||||
|
||||
func WaitForAdapter() error {
|
||||
conn, err := dbus.ConnectSystemBus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.AddMatchSignal(
|
||||
dbus.WithMatchInterface(objectMgrIface),
|
||||
dbus.WithMatchMember("InterfacesAdded"),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
signals := make(chan *dbus.Signal, 64)
|
||||
conn.Signal(signals)
|
||||
|
||||
if _, err := findAdapter(conn); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for sig := range signals {
|
||||
if sig == nil || sig.Name != objectMgrIface+".InterfacesAdded" || len(sig.Body) < 2 {
|
||||
continue
|
||||
}
|
||||
ifaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := ifaces[adapter1Iface]; ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("dbus signal stream closed")
|
||||
}
|
||||
@@ -138,6 +138,7 @@ func handleCopyEntry(conn net.Conn, req models.Request, m *Manager) {
|
||||
return
|
||||
}
|
||||
|
||||
if entry.AltMimeType == "" {
|
||||
filePath := m.EntryToFile(entry)
|
||||
if filePath != "" {
|
||||
if err := m.CopyFile(filePath); err != nil {
|
||||
@@ -150,8 +151,9 @@ func handleCopyEntry(conn net.Conn, req models.Request, m *Manager) {
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.SetClipboard(entry.Data, entry.MimeType); err != nil {
|
||||
if err := m.SetClipboardEntry(entry); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -279,7 +279,29 @@ func (m *Manager) setupDataDeviceSync() {
|
||||
}
|
||||
w.Close()
|
||||
|
||||
go m.readAndStore(r, preferredMime)
|
||||
altMime := ""
|
||||
if m.isImageMimeType(preferredMime) && !slices.Contains(mimes, "x-special/gnome-copied-files") {
|
||||
altMime = selectAltTextMimeType(mimes)
|
||||
}
|
||||
if altMime == "" {
|
||||
go m.readAndStore(r, preferredMime, nil, "")
|
||||
return
|
||||
}
|
||||
|
||||
altR, altW, err := os.Pipe()
|
||||
if err != nil {
|
||||
go m.readAndStore(r, preferredMime, nil, "")
|
||||
return
|
||||
}
|
||||
if err := typedOffer.Receive(altMime, int(altW.Fd())); err != nil {
|
||||
altR.Close()
|
||||
altW.Close()
|
||||
go m.readAndStore(r, preferredMime, nil, "")
|
||||
return
|
||||
}
|
||||
altW.Close()
|
||||
|
||||
go m.readAndStore(r, preferredMime, altR, altMime)
|
||||
})
|
||||
|
||||
if err := dataMgr.GetDataDeviceWithProxy(dataDevice, m.seat); err != nil {
|
||||
@@ -324,22 +346,42 @@ func (m *Manager) releaseCurrentSource() {
|
||||
source.Destroy()
|
||||
}
|
||||
|
||||
func (m *Manager) readAndStore(r *os.File, mimeType string) {
|
||||
defer r.Close()
|
||||
|
||||
cfg := m.getConfig()
|
||||
|
||||
func readPipeTimeout(r *os.File) []byte {
|
||||
done := make(chan []byte, 1)
|
||||
go func() {
|
||||
data, _ := io.ReadAll(r)
|
||||
done <- data
|
||||
}()
|
||||
|
||||
var data []byte
|
||||
select {
|
||||
case data = <-done:
|
||||
case data := <-done:
|
||||
return data
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
return
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) readAndStore(r *os.File, mimeType string, altR *os.File, altMime string) {
|
||||
defer r.Close()
|
||||
|
||||
cfg := m.getConfig()
|
||||
|
||||
altCh := make(chan []byte, 1)
|
||||
switch altR {
|
||||
case nil:
|
||||
altCh <- nil
|
||||
default:
|
||||
go func() {
|
||||
defer altR.Close()
|
||||
altCh <- readPipeTimeout(altR)
|
||||
}()
|
||||
}
|
||||
|
||||
data := readPipeTimeout(r)
|
||||
altData := <-altCh
|
||||
|
||||
if len(bytes.TrimSpace(altData)) == 0 || int64(len(altData)) > cfg.MaxEntrySize {
|
||||
altData, altMime = nil, ""
|
||||
}
|
||||
|
||||
if len(data) == 0 || int64(len(data)) > cfg.MaxEntrySize {
|
||||
@@ -350,14 +392,14 @@ func (m *Manager) readAndStore(r *os.File, mimeType string) {
|
||||
}
|
||||
|
||||
if !cfg.Disabled && m.db != nil {
|
||||
m.storeClipboardEntry(data, mimeType)
|
||||
m.storeClipboardEntry(data, mimeType, altData, altMime)
|
||||
}
|
||||
|
||||
m.updateState()
|
||||
m.notifySubscribers()
|
||||
}
|
||||
|
||||
func (m *Manager) storeClipboardEntry(data []byte, mimeType string) {
|
||||
func (m *Manager) storeClipboardEntry(data []byte, mimeType string, altData []byte, altMime string) {
|
||||
if mimeType == "text/uri-list" {
|
||||
if imgData, imgMime, ok := m.tryReadImageFromURI(data); ok {
|
||||
data = imgData
|
||||
@@ -371,6 +413,8 @@ func (m *Manager) storeClipboardEntry(data []byte, mimeType string) {
|
||||
Size: len(data),
|
||||
Timestamp: time.Now(),
|
||||
IsImage: m.isImageMimeType(mimeType),
|
||||
AltData: altData,
|
||||
AltMimeType: altMime,
|
||||
}
|
||||
|
||||
switch {
|
||||
@@ -483,6 +527,12 @@ func encodeEntry(e Entry) ([]byte, error) {
|
||||
} else {
|
||||
buf.WriteByte(0)
|
||||
}
|
||||
if e.AltMimeType != "" {
|
||||
binary.Write(buf, binary.BigEndian, uint32(len(e.AltMimeType)))
|
||||
buf.WriteString(e.AltMimeType)
|
||||
binary.Write(buf, binary.BigEndian, uint32(len(e.AltData)))
|
||||
buf.Write(e.AltData)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -547,6 +597,21 @@ func decodeEntryFields(data []byte, withData bool) (Entry, error) {
|
||||
e.Pinned = pinnedByte == 1
|
||||
}
|
||||
|
||||
if buf.Len() >= 4 {
|
||||
var altMimeLen uint32
|
||||
binary.Read(buf, binary.BigEndian, &altMimeLen)
|
||||
altMimeBytes := make([]byte, altMimeLen)
|
||||
buf.Read(altMimeBytes)
|
||||
e.AltMimeType = string(altMimeBytes)
|
||||
|
||||
var altDataLen uint32
|
||||
binary.Read(buf, binary.BigEndian, &altDataLen)
|
||||
if withData {
|
||||
e.AltData = make([]byte, altDataLen)
|
||||
buf.Read(e.AltData)
|
||||
}
|
||||
}
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
@@ -563,10 +628,27 @@ func computeHash(data []byte) uint64 {
|
||||
}
|
||||
|
||||
func extractHash(data []byte) uint64 {
|
||||
if len(data) < 9 {
|
||||
buf := bytes.NewReader(data)
|
||||
if _, err := buf.Seek(8, io.SeekStart); err != nil {
|
||||
return 0
|
||||
}
|
||||
return binary.BigEndian.Uint64(data[len(data)-9 : len(data)-1])
|
||||
for range 3 { // data, mime type, preview
|
||||
var length uint32
|
||||
if binary.Read(buf, binary.BigEndian, &length) != nil {
|
||||
return 0
|
||||
}
|
||||
if _, err := buf.Seek(int64(length), io.SeekCurrent); err != nil {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
if _, err := buf.Seek(4+8+1, io.SeekCurrent); err != nil { // size, timestamp, isImage
|
||||
return 0
|
||||
}
|
||||
var hash uint64
|
||||
if binary.Read(buf, binary.BigEndian, &hash) != nil {
|
||||
return 0
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
|
||||
@@ -611,6 +693,23 @@ func (m *Manager) selectMimeType(mimes []string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
var altTextMimeTypes = []string{
|
||||
"text/plain;charset=utf-8",
|
||||
"text/plain",
|
||||
"UTF8_STRING",
|
||||
"STRING",
|
||||
"TEXT",
|
||||
}
|
||||
|
||||
func selectAltTextMimeType(mimes []string) string {
|
||||
for _, pref := range altTextMimeTypes {
|
||||
if slices.Contains(mimes, pref) {
|
||||
return pref
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *Manager) isImageMimeType(mime string) bool {
|
||||
return strings.HasPrefix(mime, "image/")
|
||||
}
|
||||
@@ -936,6 +1035,8 @@ func (m *Manager) CreateHistoryEntryFromPinned(pinnedEntry *Entry) error {
|
||||
IsImage: pinnedEntry.IsImage,
|
||||
Preview: pinnedEntry.Preview,
|
||||
Pinned: false,
|
||||
AltData: pinnedEntry.AltData,
|
||||
AltMimeType: pinnedEntry.AltMimeType,
|
||||
}
|
||||
|
||||
if err := m.storeEntry(newEntry); err != nil {
|
||||
@@ -1061,6 +1162,23 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetClipboardEntry takes the selection serving the entry's primary
|
||||
// representation plus its stored alternate, so history restores keep
|
||||
// both the text and image sides pasteable.
|
||||
func (m *Manager) SetClipboardEntry(entry *Entry) error {
|
||||
if int64(len(entry.Data)) > m.config.MaxEntrySize {
|
||||
return fmt.Errorf("data too large")
|
||||
}
|
||||
|
||||
offers := clipboardstore.ExpandOffers(slices.Clone(entry.Data), entry.MimeType)
|
||||
if entry.AltMimeType != "" {
|
||||
offers = append(offers, clipboardstore.ExpandOffers(slices.Clone(entry.AltData), entry.AltMimeType)...)
|
||||
}
|
||||
|
||||
m.takeSelection(offers)
|
||||
return nil
|
||||
}
|
||||
|
||||
// takeSelection makes the daemon the selection owner, serving the given
|
||||
// offers until another client claims the clipboard.
|
||||
func (m *Manager) takeSelection(offers []clipboardstore.Offer) {
|
||||
@@ -1154,16 +1272,20 @@ func (m *Manager) PasteText() (string, error) {
|
||||
}
|
||||
|
||||
entry := history[0]
|
||||
if entry.IsImage {
|
||||
return "", fmt.Errorf("clipboard contains image, not text")
|
||||
}
|
||||
|
||||
fullEntry, err := m.GetEntry(entry.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch {
|
||||
case !fullEntry.IsImage:
|
||||
return string(fullEntry.Data), nil
|
||||
case fullEntry.AltMimeType != "":
|
||||
return string(fullEntry.AltData), nil
|
||||
default:
|
||||
return "", fmt.Errorf("clipboard contains image, not text")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Close() {
|
||||
@@ -1839,21 +1961,34 @@ func (m *Manager) EntryToFile(entry *Entry) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *Manager) dbusConnForFlatpak() (*dbus.Conn, error) {
|
||||
m.dbusConnMutex.Lock()
|
||||
defer m.dbusConnMutex.Unlock()
|
||||
|
||||
if m.dbusConn != nil {
|
||||
return m.dbusConn, nil
|
||||
}
|
||||
|
||||
conn, err := dbus.ConnectSessionBus()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect session bus: %w", err)
|
||||
}
|
||||
if !conn.SupportsUnixFDs() {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("D-Bus connection does not support Unix FD passing")
|
||||
}
|
||||
m.dbusConn = conn
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (m *Manager) ExportFileForFlatpak(filePath string) (string, error) {
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
return "", fmt.Errorf("file not found: %w", err)
|
||||
}
|
||||
|
||||
if m.dbusConn == nil {
|
||||
conn, err := dbus.ConnectSessionBus()
|
||||
dbusConn, err := m.dbusConnForFlatpak()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("connect session bus: %w", err)
|
||||
}
|
||||
if !conn.SupportsUnixFDs() {
|
||||
conn.Close()
|
||||
return "", fmt.Errorf("D-Bus connection does not support Unix FD passing")
|
||||
}
|
||||
m.dbusConn = conn
|
||||
return "", err
|
||||
}
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
@@ -1862,7 +1997,7 @@ func (m *Manager) ExportFileForFlatpak(filePath string) (string, error) {
|
||||
}
|
||||
fd := int(file.Fd())
|
||||
|
||||
portal := m.dbusConn.Object("org.freedesktop.portal.Documents", "/org/freedesktop/portal/documents")
|
||||
portal := dbusConn.Object("org.freedesktop.portal.Documents", "/org/freedesktop/portal/documents")
|
||||
|
||||
var docIds []string
|
||||
var extra map[string]dbus.Variant
|
||||
|
||||
@@ -144,6 +144,72 @@ func TestEncodeDecodeEntry_LargeData(t *testing.T) {
|
||||
assert.Equal(t, original.Size, decoded.Size)
|
||||
}
|
||||
|
||||
func TestEncodeDecodeEntry_AltRepresentation(t *testing.T) {
|
||||
original := Entry{
|
||||
ID: 555,
|
||||
Data: []byte{0x42, 0x4D, 0x01, 0x02},
|
||||
MimeType: "image/bmp",
|
||||
Preview: "[[ image 4 B bmp 85x19 ]]",
|
||||
Size: 4,
|
||||
Timestamp: time.Now().Truncate(time.Second),
|
||||
IsImage: true,
|
||||
Hash: computeHash([]byte{0x42, 0x4D, 0x01, 0x02}),
|
||||
Pinned: true,
|
||||
AltData: []byte("real text from OneNote"),
|
||||
AltMimeType: "text/plain;charset=utf-8",
|
||||
}
|
||||
|
||||
encoded, err := encodeEntry(original)
|
||||
assert.NoError(t, err)
|
||||
|
||||
decoded, err := decodeEntry(encoded)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, original.Data, decoded.Data)
|
||||
assert.Equal(t, original.MimeType, decoded.MimeType)
|
||||
assert.True(t, decoded.Pinned)
|
||||
assert.Equal(t, original.AltData, decoded.AltData)
|
||||
assert.Equal(t, original.AltMimeType, decoded.AltMimeType)
|
||||
|
||||
meta, err := decodeEntryMeta(encoded)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, meta.Data)
|
||||
assert.Equal(t, original.AltMimeType, meta.AltMimeType)
|
||||
|
||||
assert.Equal(t, original.Hash, extractHash(encoded))
|
||||
}
|
||||
|
||||
func TestExtractHash_NoAlt(t *testing.T) {
|
||||
entry := Entry{
|
||||
ID: 1,
|
||||
Data: []byte("plain entry"),
|
||||
MimeType: "text/plain",
|
||||
Preview: "plain entry",
|
||||
Size: 11,
|
||||
Timestamp: time.Now().Truncate(time.Second),
|
||||
Hash: computeHash([]byte("plain entry")),
|
||||
}
|
||||
|
||||
encoded, err := encodeEntry(entry)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, entry.Hash, extractHash(encoded))
|
||||
}
|
||||
|
||||
func TestSelectAltTextMimeType(t *testing.T) {
|
||||
tests := []struct {
|
||||
mimes []string
|
||||
expected string
|
||||
}{
|
||||
{[]string{"image/bmp", "TEXT", "text/html", "text/plain", "text/plain;charset=utf-8", "UTF8_STRING"}, "text/plain;charset=utf-8"},
|
||||
{[]string{"image/png", "UTF8_STRING"}, "UTF8_STRING"},
|
||||
{[]string{"image/png", "text/html"}, ""},
|
||||
{[]string{"image/png"}, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
assert.Equal(t, tt.expected, selectAltTextMimeType(tt.mimes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateEqual_BothNil(t *testing.T) {
|
||||
assert.False(t, stateEqual(nil, nil))
|
||||
}
|
||||
|
||||
@@ -104,6 +104,8 @@ type Entry struct {
|
||||
IsImage bool `json:"isImage"`
|
||||
Hash uint64 `json:"hash,omitempty"`
|
||||
Pinned bool `json:"pinned"`
|
||||
AltData []byte `json:"altData,omitempty"`
|
||||
AltMimeType string `json:"altMimeType,omitempty"`
|
||||
}
|
||||
|
||||
type State struct {
|
||||
@@ -153,7 +155,9 @@ type Manager struct {
|
||||
notifierWg sync.WaitGroup
|
||||
lastState *State
|
||||
|
||||
// lazily created by dbusConnForFlatpak under dbusConnMutex
|
||||
dbusConn *dbus.Conn
|
||||
dbusConnMutex sync.Mutex
|
||||
}
|
||||
|
||||
func (m *Manager) GetState() State {
|
||||
|
||||
@@ -229,6 +229,9 @@ func (m *Manager) snapshotState() CUPSState {
|
||||
func (m *Manager) Subscribe(id string) chan CUPSState {
|
||||
ch := make(chan CUPSState, 64)
|
||||
|
||||
m.subLifecycleMu.Lock()
|
||||
defer m.subLifecycleMu.Unlock()
|
||||
|
||||
wasEmpty := true
|
||||
m.subscribers.Range(func(key string, ch chan CUPSState) bool {
|
||||
wasEmpty = false
|
||||
@@ -237,19 +240,25 @@ func (m *Manager) Subscribe(id string) chan CUPSState {
|
||||
|
||||
m.subscribers.Store(id, ch)
|
||||
|
||||
if wasEmpty && m.subscription != nil {
|
||||
if !wasEmpty || m.subscription == nil {
|
||||
return ch
|
||||
}
|
||||
|
||||
if err := m.subscription.Start(); err != nil {
|
||||
log.Warnf("[CUPS] Failed to start subscription manager: %v", err)
|
||||
} else {
|
||||
return ch
|
||||
}
|
||||
|
||||
m.eventWG.Add(1)
|
||||
go m.eventHandler()
|
||||
}
|
||||
}
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
func (m *Manager) Unsubscribe(id string) {
|
||||
m.subLifecycleMu.Lock()
|
||||
defer m.subLifecycleMu.Unlock()
|
||||
|
||||
if val, ok := m.subscribers.LoadAndDelete(id); ok {
|
||||
close(val)
|
||||
}
|
||||
@@ -260,18 +269,22 @@ func (m *Manager) Unsubscribe(id string) {
|
||||
return false
|
||||
})
|
||||
|
||||
if isEmpty && m.subscription != nil {
|
||||
if !isEmpty || m.subscription == nil {
|
||||
return
|
||||
}
|
||||
|
||||
m.subscription.Stop()
|
||||
m.eventWG.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Close() {
|
||||
close(m.stopChan)
|
||||
|
||||
m.subLifecycleMu.Lock()
|
||||
if m.subscription != nil {
|
||||
m.subscription.Stop()
|
||||
}
|
||||
m.subLifecycleMu.Unlock()
|
||||
|
||||
m.eventWG.Wait()
|
||||
m.notifierWg.Wait()
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package cups
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
mocks_cups "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/cups"
|
||||
@@ -75,6 +78,67 @@ func TestManager_Subscribe(t *testing.T) {
|
||||
assert.Equal(t, 0, count)
|
||||
}
|
||||
|
||||
// mirrors the real managers: eventChan guarded by mu, conn/running deliberately
|
||||
// unsynchronized so overlapping Start/Stop trips the race detector
|
||||
type stubSubscription struct {
|
||||
mu sync.Mutex
|
||||
events chan SubscriptionEvent
|
||||
conn *int
|
||||
running bool
|
||||
}
|
||||
|
||||
func (s *stubSubscription) Start() error {
|
||||
if s.running {
|
||||
return errors.New("already running")
|
||||
}
|
||||
s.running = true
|
||||
|
||||
s.mu.Lock()
|
||||
s.events = make(chan SubscriptionEvent)
|
||||
s.mu.Unlock()
|
||||
|
||||
v := 0
|
||||
s.conn = &v
|
||||
*s.conn++
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubSubscription) Stop() {
|
||||
if !s.running {
|
||||
return
|
||||
}
|
||||
s.running = false
|
||||
s.conn = nil
|
||||
|
||||
s.mu.Lock()
|
||||
close(s.events)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *stubSubscription) Events() <-chan SubscriptionEvent {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.events
|
||||
}
|
||||
|
||||
func TestManager_SubscribeUnsubscribeRace(t *testing.T) {
|
||||
m := NewTestManager(mocks_cups.NewMockCUPSClientInterface(t), nil)
|
||||
m.subscription = &stubSubscription{}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := range 8 {
|
||||
wg.Go(func() {
|
||||
id := fmt.Sprintf("client-%d", i)
|
||||
for range 50 {
|
||||
m.Subscribe(id)
|
||||
m.Unsubscribe(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_Close(t *testing.T) {
|
||||
mockClient := mocks_cups.NewMockCUPSClientInterface(t)
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ func (sm *SubscriptionManager) Start() error {
|
||||
return fmt.Errorf("subscription manager already running")
|
||||
}
|
||||
sm.running = true
|
||||
// replace the channel closed by the previous Stop(); doing it here rather
|
||||
// than in Stop() guarantees a lagging eventHandler still observes the close
|
||||
sm.eventChan = make(chan SubscriptionEvent, 100)
|
||||
sm.mu.Unlock()
|
||||
|
||||
subID, err := sm.createSubscription()
|
||||
@@ -206,6 +209,8 @@ func (sm *SubscriptionManager) parseEvent(attrs ipp.Attributes) SubscriptionEven
|
||||
}
|
||||
|
||||
func (sm *SubscriptionManager) Events() <-chan SubscriptionEvent {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
return sm.eventChan
|
||||
}
|
||||
|
||||
@@ -228,6 +233,13 @@ func (sm *SubscriptionManager) Stop() {
|
||||
}
|
||||
|
||||
sm.stopChan = make(chan struct{})
|
||||
|
||||
// the writer (notificationLoop) joined above, so closing is safe; without
|
||||
// this close Manager.eventHandler never returns and Unsubscribe deadlocks
|
||||
// on eventWG.Wait(). Start() allocates the replacement.
|
||||
sm.mu.Lock()
|
||||
close(sm.eventChan)
|
||||
sm.mu.Unlock()
|
||||
}
|
||||
|
||||
func (sm *SubscriptionManager) cancelSubscription() {
|
||||
|
||||
@@ -38,6 +38,8 @@ func (sm *DBusSubscriptionManager) Start() error {
|
||||
return fmt.Errorf("subscription manager already running")
|
||||
}
|
||||
sm.running = true
|
||||
// replaced here rather than in Stop(); see SubscriptionManager.Start()
|
||||
sm.eventChan = make(chan SubscriptionEvent, 100)
|
||||
sm.mu.Unlock()
|
||||
|
||||
conn, err := dbus.ConnectSystemBus()
|
||||
@@ -252,6 +254,8 @@ func (sm *DBusSubscriptionManager) parseDBusSignal(sig *dbus.Signal) Subscriptio
|
||||
}
|
||||
|
||||
func (sm *DBusSubscriptionManager) Events() <-chan SubscriptionEvent {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
return sm.eventChan
|
||||
}
|
||||
|
||||
@@ -278,6 +282,12 @@ func (sm *DBusSubscriptionManager) Stop() {
|
||||
}
|
||||
|
||||
sm.stopChan = make(chan struct{})
|
||||
|
||||
// the writer (dbusListenerLoop) joined above, so closing is safe; see
|
||||
// SubscriptionManager.Stop()
|
||||
sm.mu.Lock()
|
||||
close(sm.eventChan)
|
||||
sm.mu.Unlock()
|
||||
}
|
||||
|
||||
func (sm *DBusSubscriptionManager) cancelSubscription() {
|
||||
|
||||
@@ -79,6 +79,7 @@ type Manager struct {
|
||||
client CUPSClientInterface
|
||||
pkHelper PkHelper
|
||||
subscription SubscriptionManagerInterface
|
||||
subLifecycleMu sync.Mutex
|
||||
stateMutex sync.RWMutex
|
||||
subscribers syncmap.Map[string, chan CUPSState]
|
||||
stopChan chan struct{}
|
||||
|
||||
@@ -201,6 +201,10 @@ func handleListNames(conn net.Conn, req models.Request, m *Manager) {
|
||||
}
|
||||
|
||||
func handleSubscribe(conn net.Conn, req models.Request, m *Manager, clientID string) {
|
||||
if id := params.StringOpt(req.Params, "clientId", ""); id != "" {
|
||||
clientID = id
|
||||
}
|
||||
|
||||
bus, err := params.String(req.Params, "bus")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
|
||||
@@ -137,22 +137,25 @@ func (m *Manager) consumeSelfEcho(value uint32) bool {
|
||||
}
|
||||
|
||||
func (m *Manager) watchSettingsChanges() {
|
||||
conn, err := dbus.ConnectSessionBus()
|
||||
if err != nil {
|
||||
log.Warnf("color-scheme watcher: session bus connect: %v", err)
|
||||
// reuse the shared session connection; a dedicated one was unreachable
|
||||
// from Close() and leaked with this goroutine
|
||||
if m.sessionConn == nil {
|
||||
return
|
||||
}
|
||||
conn := m.sessionConn
|
||||
|
||||
if err := conn.AddMatchSignal(
|
||||
dbus.WithMatchInterface(dbusPortalSettingsInterface),
|
||||
dbus.WithMatchMember("SettingChanged"),
|
||||
); err != nil {
|
||||
log.Warnf("Failed to watch portal settings changes: %v", err)
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
signals := make(chan *dbus.Signal, 64)
|
||||
m.stateMutex.Lock()
|
||||
m.settingsSignals = signals
|
||||
m.stateMutex.Unlock()
|
||||
conn.Signal(signals)
|
||||
|
||||
for sig := range signals {
|
||||
@@ -309,6 +312,18 @@ func (m *Manager) Close() {
|
||||
m.systemConn.Close()
|
||||
}
|
||||
if m.sessionConn != nil {
|
||||
m.sessionConn.RemoveMatchSignal(
|
||||
dbus.WithMatchInterface(dbusPortalSettingsInterface),
|
||||
dbus.WithMatchMember("SettingChanged"),
|
||||
)
|
||||
m.stateMutex.Lock()
|
||||
signals := m.settingsSignals
|
||||
m.settingsSignals = nil
|
||||
m.stateMutex.Unlock()
|
||||
if signals != nil {
|
||||
m.sessionConn.RemoveSignal(signals)
|
||||
close(signals)
|
||||
}
|
||||
m.sessionConn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,4 +71,6 @@ type Manager struct {
|
||||
screensaverGnomeClaimed bool
|
||||
selfEchoMu sync.Mutex
|
||||
selfEchoes []colorSchemeEcho
|
||||
// registered on sessionConn by watchSettingsChanges; guarded by stateMutex
|
||||
settingsSignals chan *dbus.Signal
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ type IWDAgent struct {
|
||||
prompts PromptBroker
|
||||
onUserCanceled func()
|
||||
onPromptRetry func(ssid string)
|
||||
takePendingSecret func(ssid string) (string, bool)
|
||||
lastRequestSSID string
|
||||
stateChecker ConnectionStateChecker
|
||||
}
|
||||
@@ -119,6 +120,13 @@ func (a *IWDAgent) RequestPassphrase(network dbus.ObjectPath) (string, *dbus.Err
|
||||
return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
|
||||
}
|
||||
|
||||
if a.takePendingSecret != nil {
|
||||
if psk, ok := a.takePendingSecret(ssid); ok {
|
||||
a.lastRequestSSID = ssid
|
||||
return psk, nil
|
||||
}
|
||||
}
|
||||
|
||||
if a.prompts == nil {
|
||||
if a.onUserCanceled != nil {
|
||||
a.onUserCanceled()
|
||||
@@ -126,19 +134,24 @@ func (a *IWDAgent) RequestPassphrase(network dbus.ObjectPath) (string, *dbus.Err
|
||||
return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
|
||||
}
|
||||
|
||||
if a.lastRequestSSID == ssid {
|
||||
if a.onPromptRetry != nil {
|
||||
retry := a.lastRequestSSID == ssid
|
||||
if retry && a.onPromptRetry != nil {
|
||||
a.onPromptRetry(ssid)
|
||||
}
|
||||
}
|
||||
a.lastRequestSSID = ssid
|
||||
|
||||
reason := ""
|
||||
if retry {
|
||||
reason = "wrong-password"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
token, err := a.prompts.Ask(ctx, PromptRequest{
|
||||
SSID: ssid,
|
||||
Fields: []string{"psk"},
|
||||
Reason: reason,
|
||||
})
|
||||
if err != nil {
|
||||
if a.onUserCanceled != nil {
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
type connectAttempt struct {
|
||||
ssid string
|
||||
netPath dbus.ObjectPath
|
||||
saved bool
|
||||
start time.Time
|
||||
deadline time.Time
|
||||
sawAuthish bool
|
||||
@@ -53,6 +54,37 @@ type IWDBackend struct {
|
||||
attemptMutex sync.RWMutex
|
||||
recentScans map[string]time.Time
|
||||
recentScansMu sync.Mutex
|
||||
pendingPSK *pendingReplacementPSK
|
||||
pendingPSKMu sync.Mutex
|
||||
}
|
||||
|
||||
type pendingReplacementPSK struct {
|
||||
ssid string
|
||||
psk string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
func (b *IWDBackend) storePendingPSK(ssid, psk string) {
|
||||
b.pendingPSKMu.Lock()
|
||||
b.pendingPSK = &pendingReplacementPSK{
|
||||
ssid: ssid,
|
||||
psk: psk,
|
||||
expires: time.Now().Add(30 * time.Second),
|
||||
}
|
||||
b.pendingPSKMu.Unlock()
|
||||
}
|
||||
|
||||
func (b *IWDBackend) takePendingPSK(ssid string) (string, bool) {
|
||||
b.pendingPSKMu.Lock()
|
||||
defer b.pendingPSKMu.Unlock()
|
||||
|
||||
pending := b.pendingPSK
|
||||
if pending == nil || pending.ssid != ssid || time.Now().After(pending.expires) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
b.pendingPSK = nil
|
||||
return pending.psk, true
|
||||
}
|
||||
|
||||
func NewIWDBackend() (*IWDBackend, error) {
|
||||
|
||||
@@ -18,6 +18,7 @@ func (b *IWDBackend) StartMonitoring(onStateChange func()) error {
|
||||
}
|
||||
agent.onUserCanceled = b.OnUserCanceledPrompt
|
||||
agent.onPromptRetry = b.OnPromptRetry
|
||||
agent.takePendingSecret = b.takePendingPSK
|
||||
b.iwdAgent = agent
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -314,6 +315,133 @@ func TestConnectAttempt_Finalization(t *testing.T) {
|
||||
backend.stateMutex.RUnlock()
|
||||
}
|
||||
|
||||
func TestIWDBackend_PendingPSK(t *testing.T) {
|
||||
backend, _ := NewIWDBackend()
|
||||
|
||||
_, ok := backend.takePendingPSK("Home")
|
||||
assert.False(t, ok)
|
||||
|
||||
backend.storePendingPSK("Home", "newpass")
|
||||
|
||||
_, ok = backend.takePendingPSK("Other")
|
||||
assert.False(t, ok, "pending PSK should not match a different SSID")
|
||||
|
||||
psk, ok := backend.takePendingPSK("Home")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "newpass", psk)
|
||||
|
||||
_, ok = backend.takePendingPSK("Home")
|
||||
assert.False(t, ok, "pending PSK should be consumed on take")
|
||||
|
||||
backend.storePendingPSK("Home", "newpass")
|
||||
backend.pendingPSKMu.Lock()
|
||||
backend.pendingPSK.expires = time.Now().Add(-time.Second)
|
||||
backend.pendingPSKMu.Unlock()
|
||||
|
||||
_, ok = backend.takePendingPSK("Home")
|
||||
assert.False(t, ok, "expired pending PSK should not be returned")
|
||||
}
|
||||
|
||||
type fakePromptBroker struct {
|
||||
asked chan PromptRequest
|
||||
reply PromptReply
|
||||
replyErr error
|
||||
}
|
||||
|
||||
func (f *fakePromptBroker) Ask(ctx context.Context, req PromptRequest) (string, error) {
|
||||
f.asked <- req
|
||||
return "token", nil
|
||||
}
|
||||
|
||||
func (f *fakePromptBroker) Wait(ctx context.Context, token string) (PromptReply, error) {
|
||||
return f.reply, f.replyErr
|
||||
}
|
||||
|
||||
func (f *fakePromptBroker) Resolve(token string, reply PromptReply) error { return nil }
|
||||
|
||||
func (f *fakePromptBroker) Cancel(path string, setting string) error { return nil }
|
||||
|
||||
func TestIWDBackend_BadCredentialsSavedNetwork_PromptsReplacement(t *testing.T) {
|
||||
backend, _ := NewIWDBackend()
|
||||
backend.state = &BackendState{}
|
||||
broker := &fakePromptBroker{
|
||||
asked: make(chan PromptRequest, 1),
|
||||
reply: PromptReply{Cancel: true},
|
||||
}
|
||||
backend.promptBroker = broker
|
||||
|
||||
att := &connectAttempt{
|
||||
ssid: "Home",
|
||||
netPath: "/test",
|
||||
saved: true,
|
||||
start: time.Now(),
|
||||
deadline: time.Now().Add(15 * time.Second),
|
||||
}
|
||||
|
||||
backend.finalizeAttempt(att, "bad-credentials")
|
||||
|
||||
select {
|
||||
case req := <-broker.asked:
|
||||
assert.Equal(t, "Home", req.SSID)
|
||||
assert.Equal(t, "wrong-password", req.Reason)
|
||||
assert.Equal(t, []string{"psk"}, req.Fields)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected replacement credentials prompt for saved network")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIWDBackend_BadCredentialsUnsavedNetwork_NoReplacementPrompt(t *testing.T) {
|
||||
backend, _ := NewIWDBackend()
|
||||
backend.state = &BackendState{}
|
||||
broker := &fakePromptBroker{
|
||||
asked: make(chan PromptRequest, 1),
|
||||
reply: PromptReply{Cancel: true},
|
||||
}
|
||||
backend.promptBroker = broker
|
||||
|
||||
att := &connectAttempt{
|
||||
ssid: "Home",
|
||||
netPath: "/test",
|
||||
start: time.Now(),
|
||||
deadline: time.Now().Add(15 * time.Second),
|
||||
}
|
||||
|
||||
backend.finalizeAttempt(att, "bad-credentials")
|
||||
|
||||
select {
|
||||
case <-broker.asked:
|
||||
t.Fatal("unsaved network should not trigger a replacement prompt")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestIWDBackend_BadCredentialsAfterPromptRetry_NoReplacementPrompt(t *testing.T) {
|
||||
backend, _ := NewIWDBackend()
|
||||
backend.state = &BackendState{}
|
||||
broker := &fakePromptBroker{
|
||||
asked: make(chan PromptRequest, 1),
|
||||
reply: PromptReply{Cancel: true},
|
||||
}
|
||||
backend.promptBroker = broker
|
||||
|
||||
att := &connectAttempt{
|
||||
ssid: "Home",
|
||||
netPath: "/test",
|
||||
saved: true,
|
||||
sawPromptRetry: true,
|
||||
start: time.Now(),
|
||||
deadline: time.Now().Add(15 * time.Second),
|
||||
}
|
||||
|
||||
backend.finalizeAttempt(att, "bad-credentials")
|
||||
|
||||
select {
|
||||
case <-broker.asked:
|
||||
t.Fatal("attempt that already prompted should not trigger a replacement prompt")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectAttempt_DoubleFinalization(t *testing.T) {
|
||||
backend, _ := NewIWDBackend()
|
||||
backend.state = &BackendState{}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
@@ -476,6 +478,73 @@ func (b *IWDBackend) finalizeAttempt(att *connectAttempt, code string) {
|
||||
if b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
|
||||
if code == errdefs.ErrBadCredentials {
|
||||
b.maybeReplaceSavedPSK(att)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *IWDBackend) maybeReplaceSavedPSK(att *connectAttempt) {
|
||||
if b.promptBroker == nil || !att.saved {
|
||||
return
|
||||
}
|
||||
|
||||
att.mu.Lock()
|
||||
prompted := att.sawPromptRetry
|
||||
att.mu.Unlock()
|
||||
if prompted {
|
||||
return
|
||||
}
|
||||
|
||||
b.sigWG.Add(1)
|
||||
go func() {
|
||||
defer b.sigWG.Done()
|
||||
b.requestReplacementPSK(att.ssid)
|
||||
}()
|
||||
}
|
||||
|
||||
func (b *IWDBackend) requestReplacementPSK(ssid string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-b.stopChan:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
token, err := b.promptBroker.Ask(ctx, PromptRequest{
|
||||
SSID: ssid,
|
||||
SettingName: "802-11-wireless-security",
|
||||
Fields: []string{"psk"},
|
||||
Reason: "wrong-password",
|
||||
})
|
||||
if err != nil {
|
||||
log.Warnf("failed to request replacement credentials for %s: %v", ssid, err)
|
||||
return
|
||||
}
|
||||
|
||||
reply, err := b.promptBroker.Wait(ctx, token)
|
||||
if err != nil || reply.Cancel {
|
||||
return
|
||||
}
|
||||
|
||||
psk, ok := reply.Secrets["psk"]
|
||||
if !ok || psk == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := b.ForgetWiFiNetwork(ssid); err != nil {
|
||||
log.Warnf("failed to forget %s before credential replacement: %v", ssid, err)
|
||||
}
|
||||
|
||||
b.storePendingPSK(ssid, psk)
|
||||
|
||||
if err := b.ConnectWiFi(ConnectionRequest{SSID: ssid}); err != nil {
|
||||
log.Warnf("failed to reconnect %s with replacement credentials: %v", ssid, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *IWDBackend) startAttemptWatchdog(att *connectAttempt) {
|
||||
@@ -560,7 +629,7 @@ func (b *IWDBackend) ConnectWiFi(req ConnectionRequest) error {
|
||||
return fmt.Errorf("no WiFi device available")
|
||||
}
|
||||
|
||||
networkPath, err := b.findNetworkPath(req.SSID)
|
||||
networkPath, saved, err := b.findNetworkPath(req.SSID)
|
||||
if err != nil {
|
||||
b.setConnectError(errdefs.ErrNoSuchSSID)
|
||||
if b.onStateChange != nil {
|
||||
@@ -572,6 +641,7 @@ func (b *IWDBackend) ConnectWiFi(req ConnectionRequest) error {
|
||||
att := &connectAttempt{
|
||||
ssid: req.SSID,
|
||||
netPath: networkPath,
|
||||
saved: saved,
|
||||
start: time.Now(),
|
||||
deadline: time.Now().Add(15 * time.Second),
|
||||
}
|
||||
@@ -619,26 +689,39 @@ func (b *IWDBackend) ConnectWiFi(req ConnectionRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IWDBackend) findNetworkPath(ssid string) (dbus.ObjectPath, error) {
|
||||
func (b *IWDBackend) findNetworkPath(ssid string) (dbus.ObjectPath, bool, error) {
|
||||
obj := b.conn.Object(iwdBusName, iwdObjectPath)
|
||||
|
||||
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
|
||||
err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
var netPath dbus.ObjectPath
|
||||
saved := false
|
||||
for path, interfaces := range objects {
|
||||
if netProps, ok := interfaces[iwdNetworkInterface]; ok {
|
||||
if nameVar, ok := netProps["Name"]; ok {
|
||||
if name, ok := nameVar.Value().(string); ok && name == ssid {
|
||||
return path, nil
|
||||
netPath = path
|
||||
}
|
||||
}
|
||||
}
|
||||
if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok {
|
||||
if nameVar, ok := knownProps["Name"]; ok {
|
||||
if name, ok := nameVar.Value().(string); ok && name == ssid {
|
||||
saved = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("network not found")
|
||||
if netPath == "" {
|
||||
return "", false, fmt.Errorf("network not found")
|
||||
}
|
||||
|
||||
return netPath, saved, nil
|
||||
}
|
||||
|
||||
func (b *IWDBackend) DisconnectWiFi() error {
|
||||
@@ -661,6 +744,32 @@ func (b *IWDBackend) DisconnectWiFi() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IWDBackend) abortInFlightConnection(ssid string) {
|
||||
b.stateMutex.Lock()
|
||||
if !b.state.IsConnecting || b.state.ConnectingSSID != ssid {
|
||||
b.stateMutex.Unlock()
|
||||
return
|
||||
}
|
||||
b.state.IsConnecting = false
|
||||
b.state.ConnectingSSID = ""
|
||||
b.state.LastError = ""
|
||||
b.stateMutex.Unlock()
|
||||
|
||||
b.attemptMutex.RLock()
|
||||
att := b.curAttempt
|
||||
b.attemptMutex.RUnlock()
|
||||
|
||||
if att != nil && att.ssid == ssid {
|
||||
att.mu.Lock()
|
||||
att.finalized = true
|
||||
att.mu.Unlock()
|
||||
}
|
||||
|
||||
if err := b.DisconnectWiFi(); err != nil {
|
||||
log.Warnf("[abortInFlightConnection] failed to abort connection to %s: %v", ssid, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *IWDBackend) ForgetWiFiNetwork(ssid string) error {
|
||||
b.stateMutex.RLock()
|
||||
currentSSID := b.state.WiFiSSID
|
||||
@@ -729,6 +838,10 @@ func (b *IWDBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error {
|
||||
return fmt.Errorf("failed to set autoconnect: %w", call.Err)
|
||||
}
|
||||
|
||||
if !autoconnect {
|
||||
b.abortInFlightConnection(ssid)
|
||||
}
|
||||
|
||||
b.updateState()
|
||||
|
||||
if b.onStateChange != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package network
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"sync"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
@@ -57,6 +58,11 @@ type NetworkManagerBackend struct {
|
||||
wifiDev any
|
||||
wifiDevices map[string]*wifiDeviceInfo
|
||||
|
||||
// devMutex guards ethernetDevices/wifiDevices (written by the signal pump,
|
||||
// read by request handlers). Not reentrant — never hold it across calls
|
||||
// into other backend methods.
|
||||
devMutex sync.RWMutex
|
||||
|
||||
dbusConn *dbus.Conn
|
||||
signals chan *dbus.Signal
|
||||
sigWG sync.WaitGroup
|
||||
@@ -185,12 +191,12 @@ func (b *NetworkManagerBackend) Initialize() error {
|
||||
}
|
||||
hwAddr, _ := w.GetPropertyHwAddress()
|
||||
|
||||
b.ethernetDevices[iface] = ðernetDeviceInfo{
|
||||
b.setEthernetDeviceInfo(iface, ðernetDeviceInfo{
|
||||
device: dev,
|
||||
wired: w,
|
||||
name: iface,
|
||||
hwAddress: hwAddr,
|
||||
}
|
||||
})
|
||||
|
||||
if b.ethernetDevice == nil {
|
||||
b.ethernetDevice = dev
|
||||
@@ -214,12 +220,12 @@ func (b *NetworkManagerBackend) Initialize() error {
|
||||
}
|
||||
hwAddr, _ := w.GetPropertyHwAddress()
|
||||
|
||||
b.wifiDevices[iface] = &wifiDeviceInfo{
|
||||
b.setWifiDeviceInfo(iface, &wifiDeviceInfo{
|
||||
device: dev,
|
||||
wireless: w,
|
||||
name: iface,
|
||||
hwAddress: hwAddr,
|
||||
}
|
||||
})
|
||||
|
||||
if b.wifiDevice == nil {
|
||||
b.wifiDevice = dev
|
||||
@@ -267,6 +273,80 @@ func (b *NetworkManagerBackend) Initialize() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) ethernetDevicesSnapshot() map[string]*ethernetDeviceInfo {
|
||||
b.devMutex.RLock()
|
||||
defer b.devMutex.RUnlock()
|
||||
out := make(map[string]*ethernetDeviceInfo, len(b.ethernetDevices))
|
||||
maps.Copy(out, b.ethernetDevices)
|
||||
return out
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) wifiDevicesSnapshot() map[string]*wifiDeviceInfo {
|
||||
b.devMutex.RLock()
|
||||
defer b.devMutex.RUnlock()
|
||||
out := make(map[string]*wifiDeviceInfo, len(b.wifiDevices))
|
||||
maps.Copy(out, b.wifiDevices)
|
||||
return out
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) ethernetDeviceByIface(iface string) (*ethernetDeviceInfo, bool) {
|
||||
b.devMutex.RLock()
|
||||
defer b.devMutex.RUnlock()
|
||||
info, ok := b.ethernetDevices[iface]
|
||||
return info, ok
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) wifiDeviceByIface(iface string) (*wifiDeviceInfo, bool) {
|
||||
b.devMutex.RLock()
|
||||
defer b.devMutex.RUnlock()
|
||||
info, ok := b.wifiDevices[iface]
|
||||
return info, ok
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) setEthernetDeviceInfo(iface string, info *ethernetDeviceInfo) {
|
||||
b.devMutex.Lock()
|
||||
b.ethernetDevices[iface] = info
|
||||
b.devMutex.Unlock()
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) setWifiDeviceInfo(iface string, info *wifiDeviceInfo) {
|
||||
b.devMutex.Lock()
|
||||
b.wifiDevices[iface] = info
|
||||
b.devMutex.Unlock()
|
||||
}
|
||||
|
||||
// removeEthernetDeviceByPath deletes the device and returns a snapshot of
|
||||
// what's left so the caller can pick a replacement without holding devMutex
|
||||
func (b *NetworkManagerBackend) removeEthernetDeviceByPath(path dbus.ObjectPath) (removed *ethernetDeviceInfo, remaining map[string]*ethernetDeviceInfo, found bool) {
|
||||
b.devMutex.Lock()
|
||||
defer b.devMutex.Unlock()
|
||||
for iface, info := range b.ethernetDevices {
|
||||
if info.device.GetPath() != path {
|
||||
continue
|
||||
}
|
||||
delete(b.ethernetDevices, iface)
|
||||
remaining = make(map[string]*ethernetDeviceInfo, len(b.ethernetDevices))
|
||||
maps.Copy(remaining, b.ethernetDevices)
|
||||
return info, remaining, true
|
||||
}
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) removeWifiDeviceByPath(path dbus.ObjectPath) (removed *wifiDeviceInfo, remaining map[string]*wifiDeviceInfo, found bool) {
|
||||
b.devMutex.Lock()
|
||||
defer b.devMutex.Unlock()
|
||||
for iface, info := range b.wifiDevices {
|
||||
if info.device.GetPath() != path {
|
||||
continue
|
||||
}
|
||||
delete(b.wifiDevices, iface)
|
||||
remaining = make(map[string]*wifiDeviceInfo, len(b.wifiDevices))
|
||||
maps.Copy(remaining, b.wifiDevices)
|
||||
return info, remaining, true
|
||||
}
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) Close() {
|
||||
close(b.stopChan)
|
||||
b.StopMonitoring()
|
||||
|
||||
@@ -323,7 +323,7 @@ func (b *NetworkManagerBackend) GetEthernetDevices() []EthernetDevice {
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) DisconnectEthernetDevice(device string) error {
|
||||
info, ok := b.ethernetDevices[device]
|
||||
info, ok := b.ethernetDeviceByIface(device)
|
||||
if !ok {
|
||||
return fmt.Errorf("ethernet device %s not found", device)
|
||||
}
|
||||
@@ -345,9 +345,10 @@ func (b *NetworkManagerBackend) DisconnectEthernetDevice(device string) error {
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) updateAllEthernetDevices() {
|
||||
devices := make([]EthernetDevice, 0, len(b.ethernetDevices))
|
||||
ethernetDevices := b.ethernetDevicesSnapshot()
|
||||
devices := make([]EthernetDevice, 0, len(ethernetDevices))
|
||||
|
||||
for name, info := range b.ethernetDevices {
|
||||
for name, info := range ethernetDevices {
|
||||
state, _ := info.device.GetPropertyState()
|
||||
connected := state == gonetworkmanager.NmDeviceStateActivated
|
||||
driver, _ := info.device.GetPropertyDriver()
|
||||
|
||||
@@ -112,7 +112,7 @@ func (b *NetworkManagerBackend) startSignalPump() error {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, info := range b.wifiDevices {
|
||||
for _, info := range b.wifiDevicesSnapshot() {
|
||||
if err := conn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
|
||||
dbus.WithMatchInterface(dbusPropsInterface),
|
||||
@@ -124,7 +124,7 @@ func (b *NetworkManagerBackend) startSignalPump() error {
|
||||
}
|
||||
}
|
||||
|
||||
for _, info := range b.ethernetDevices {
|
||||
for _, info := range b.ethernetDevicesSnapshot() {
|
||||
if err := conn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
|
||||
dbus.WithMatchInterface(dbusPropsInterface),
|
||||
@@ -227,7 +227,7 @@ func (b *NetworkManagerBackend) stopSignalPump() {
|
||||
dbus.WithMatchMember("StateChanged"),
|
||||
)
|
||||
|
||||
for _, info := range b.wifiDevices {
|
||||
for _, info := range b.wifiDevicesSnapshot() {
|
||||
b.dbusConn.RemoveMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
|
||||
dbus.WithMatchInterface(dbusPropsInterface),
|
||||
@@ -235,7 +235,7 @@ func (b *NetworkManagerBackend) stopSignalPump() {
|
||||
)
|
||||
}
|
||||
|
||||
for _, info := range b.ethernetDevices {
|
||||
for _, info := range b.ethernetDevicesSnapshot() {
|
||||
b.dbusConn.RemoveMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
|
||||
dbus.WithMatchInterface(dbusPropsInterface),
|
||||
@@ -550,12 +550,12 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
|
||||
}
|
||||
hwAddr, _ := w.GetPropertyHwAddress()
|
||||
|
||||
b.ethernetDevices[iface] = ðernetDeviceInfo{
|
||||
b.setEthernetDeviceInfo(iface, ðernetDeviceInfo{
|
||||
device: dev,
|
||||
wired: w,
|
||||
name: iface,
|
||||
hwAddress: hwAddr,
|
||||
}
|
||||
})
|
||||
|
||||
if b.ethernetDevice == nil {
|
||||
b.ethernetDevice = dev
|
||||
@@ -573,12 +573,12 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
|
||||
}
|
||||
hwAddr, _ := w.GetPropertyHwAddress()
|
||||
|
||||
b.wifiDevices[iface] = &wifiDeviceInfo{
|
||||
b.setWifiDeviceInfo(iface, &wifiDeviceInfo{
|
||||
device: dev,
|
||||
wireless: w,
|
||||
name: iface,
|
||||
hwAddress: hwAddr,
|
||||
}
|
||||
})
|
||||
|
||||
if b.wifiDevice == nil {
|
||||
b.wifiDevice = dev
|
||||
@@ -603,16 +603,13 @@ func (b *NetworkManagerBackend) handleDeviceRemoved(devicePath dbus.ObjectPath)
|
||||
)
|
||||
}
|
||||
|
||||
for iface, info := range b.ethernetDevices {
|
||||
if info.device.GetPath() == devicePath {
|
||||
delete(b.ethernetDevices, iface)
|
||||
|
||||
if _, remaining, found := b.removeEthernetDeviceByPath(devicePath); found {
|
||||
if b.ethernetDevice != nil {
|
||||
dev := b.ethernetDevice.(gonetworkmanager.Device)
|
||||
if dev.GetPath() == devicePath {
|
||||
b.ethernetDevice = nil
|
||||
for _, remaining := range b.ethernetDevices {
|
||||
b.ethernetDevice = remaining.device
|
||||
for _, r := range remaining {
|
||||
b.ethernetDevice = r.device
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -628,20 +625,16 @@ func (b *NetworkManagerBackend) handleDeviceRemoved(devicePath dbus.ObjectPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for iface, info := range b.wifiDevices {
|
||||
if info.device.GetPath() == devicePath {
|
||||
delete(b.wifiDevices, iface)
|
||||
|
||||
if _, remaining, found := b.removeWifiDeviceByPath(devicePath); found {
|
||||
if b.wifiDevice != nil {
|
||||
dev := b.wifiDevice.(gonetworkmanager.Device)
|
||||
if dev.GetPath() == devicePath {
|
||||
b.wifiDevice = nil
|
||||
b.wifiDev = nil
|
||||
for _, remaining := range b.wifiDevices {
|
||||
b.wifiDevice = remaining.device
|
||||
b.wifiDev = remaining.wireless
|
||||
for _, r := range remaining {
|
||||
b.wifiDevice = r.device
|
||||
b.wifiDev = r.wireless
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -655,5 +648,4 @@ func (b *NetworkManagerBackend) handleDeviceRemoved(devicePath dbus.ObjectPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ func (b *NetworkManagerBackend) updateEthernetState() error {
|
||||
var connectedIP string
|
||||
var anyConnected bool
|
||||
|
||||
for name, info := range b.ethernetDevices {
|
||||
for name, info := range b.ethernetDevicesSnapshot() {
|
||||
state, err := info.device.GetPropertyState()
|
||||
if err != nil {
|
||||
continue
|
||||
|
||||
@@ -359,6 +359,24 @@ func (b *NetworkManagerBackend) DisconnectWiFi() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) abortInFlightConnection(ssid string) {
|
||||
b.stateMutex.Lock()
|
||||
if !b.state.IsConnecting || b.state.ConnectingSSID != ssid {
|
||||
b.stateMutex.Unlock()
|
||||
return
|
||||
}
|
||||
b.state.IsConnecting = false
|
||||
b.state.ConnectingSSID = ""
|
||||
b.state.LastError = ""
|
||||
b.stateMutex.Unlock()
|
||||
|
||||
b.clearCachedWiFiSecretBySSID(ssid)
|
||||
|
||||
if err := b.DisconnectWiFi(); err != nil {
|
||||
log.Warnf("[abortInFlightConnection] failed to abort connection to %s: %v", ssid, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) ForgetWiFiNetwork(ssid string) error {
|
||||
conn, err := b.findConnection(ssid)
|
||||
if err != nil {
|
||||
@@ -963,6 +981,10 @@ func (b *NetworkManagerBackend) SetWiFiAutoconnect(ssid string, autoconnect bool
|
||||
return fmt.Errorf("failed to update connection: %w", err)
|
||||
}
|
||||
|
||||
if !autoconnect {
|
||||
b.abortInFlightConnection(ssid)
|
||||
}
|
||||
|
||||
b.updateWiFiNetworks()
|
||||
|
||||
if b.onStateChange != nil {
|
||||
@@ -973,7 +995,7 @@ func (b *NetworkManagerBackend) SetWiFiAutoconnect(ssid string, autoconnect bool
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) ScanWiFiDevice(device string) error {
|
||||
devInfo, ok := b.wifiDevices[device]
|
||||
devInfo, ok := b.wifiDeviceByIface(device)
|
||||
if !ok {
|
||||
return fmt.Errorf("WiFi device not found: %s", device)
|
||||
}
|
||||
@@ -995,7 +1017,7 @@ func (b *NetworkManagerBackend) ScanWiFiDevice(device string) error {
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) DisconnectWiFiDevice(device string) error {
|
||||
devInfo, ok := b.wifiDevices[device]
|
||||
devInfo, ok := b.wifiDeviceByIface(device)
|
||||
if !ok {
|
||||
return fmt.Errorf("WiFi device not found: %s", device)
|
||||
}
|
||||
@@ -1047,7 +1069,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
|
||||
wifiConnected := b.state.WiFiConnected
|
||||
b.stateMutex.RUnlock()
|
||||
|
||||
for name, devInfo := range b.wifiDevices {
|
||||
for name, devInfo := range b.wifiDevicesSnapshot() {
|
||||
state, _ := devInfo.device.GetPropertyState()
|
||||
connected := state == gonetworkmanager.NmDeviceStateActivated
|
||||
|
||||
@@ -1211,7 +1233,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
|
||||
|
||||
func (b *NetworkManagerBackend) getWifiDeviceForConnection(deviceName string) (*wifiDeviceInfo, error) {
|
||||
if deviceName != "" {
|
||||
devInfo, ok := b.wifiDevices[deviceName]
|
||||
devInfo, ok := b.wifiDeviceByIface(deviceName)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("WiFi device not found: %s", deviceName)
|
||||
}
|
||||
@@ -1224,7 +1246,7 @@ func (b *NetworkManagerBackend) getWifiDeviceForConnection(deviceName string) (*
|
||||
|
||||
dev := b.wifiDevice.(gonetworkmanager.Device)
|
||||
iface, _ := dev.GetPropertyInterface()
|
||||
if devInfo, ok := b.wifiDevices[iface]; ok {
|
||||
if devInfo, ok := b.wifiDeviceByIface(iface); ok {
|
||||
return devInfo, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
|
||||
handleGetNetworkInfo(conn, req, manager)
|
||||
case "network.qrcode":
|
||||
handleGetNetworkQRCode(conn, req, manager)
|
||||
case "network.qrcode-content":
|
||||
handleGetNetworkQRCodeContent(conn, req, manager)
|
||||
case "network.delete-qrcode":
|
||||
handleDeleteQRCode(conn, req, manager)
|
||||
case "network.ethernet.info":
|
||||
@@ -341,6 +343,22 @@ func handleGetNetworkQRCode(conn net.Conn, req models.Request, manager *Manager)
|
||||
models.Respond(conn, req.ID, content)
|
||||
}
|
||||
|
||||
func handleGetNetworkQRCodeContent(conn net.Conn, req models.Request, manager *Manager) {
|
||||
ssid, err := params.String(req.Params, "ssid")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
content, err := manager.GetWiFiQRContent(ssid)
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, content)
|
||||
}
|
||||
|
||||
func handleDeleteQRCode(conn net.Conn, req models.Request, _ *Manager) {
|
||||
path, err := params.String(req.Params, "path")
|
||||
if err != nil {
|
||||
|
||||
@@ -473,6 +473,10 @@ func (m *Manager) GetNetworkInfoDetailed(ssid string) (*NetworkInfoResponse, err
|
||||
return m.backend.GetWiFiNetworkDetails(ssid)
|
||||
}
|
||||
|
||||
func (m *Manager) GetWiFiQRContent(ssid string) (string, error) {
|
||||
return m.backend.GetWiFiQRCodeContent(ssid)
|
||||
}
|
||||
|
||||
func (m *Manager) GetNetworkQRCode(ssid string) ([2]string, error) {
|
||||
content, err := m.backend.GetWiFiQRCodeContent(ssid)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/qrcode"
|
||||
)
|
||||
|
||||
const qrCodeTmpPrefix = "/tmp/dank-wifi-qrcode-"
|
||||
|
||||
func FormatWiFiQRString(securityType, ssid, password string) string {
|
||||
return fmt.Sprintf("WIFI:T:%s;S:%s;P:%s;;", securityType, ssid, password)
|
||||
return qrcode.WiFiString(securityType, ssid, password, false)
|
||||
}
|
||||
|
||||
func qrCodePaths(ssid string) (themed, normal string) {
|
||||
|
||||
@@ -117,11 +117,12 @@ func RouteRequest(conn net.Conn, req models.Request) {
|
||||
}
|
||||
|
||||
if strings.HasPrefix(req.Method, "cups.") {
|
||||
if cupsManager == nil {
|
||||
mgr, err := ensureCupsManager()
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, "CUPS manager not initialized")
|
||||
return
|
||||
}
|
||||
cups.HandleRequest(conn, req, cupsManager)
|
||||
cups.HandleRequest(conn, req, mgr)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+104
-38
@@ -8,10 +8,10 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -41,7 +41,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||
)
|
||||
|
||||
const APIVersion = 27
|
||||
const APIVersion = 28
|
||||
|
||||
var CLIVersion = "dev"
|
||||
|
||||
@@ -84,8 +84,8 @@ var geoClientInstance geolocation.Client
|
||||
const dbusClientID = "dms-dbus-client"
|
||||
|
||||
var capabilitySubscribers syncmap.Map[string, chan ServerInfo]
|
||||
var cupsSubscribers syncmap.Map[string, bool]
|
||||
var cupsSubscriberCount atomic.Int32
|
||||
var cupsMu sync.Mutex
|
||||
var cupsSubscriberCount int
|
||||
|
||||
func getSocketDir() string {
|
||||
if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" {
|
||||
@@ -242,17 +242,55 @@ func InitializeAppPickerManager() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitializeCupsManager() error {
|
||||
func initializeCupsManagerLocked() (bool, error) {
|
||||
if cupsManager != nil {
|
||||
return false, nil
|
||||
}
|
||||
manager, err := cups.NewManager()
|
||||
if err != nil {
|
||||
log.Warnf("Failed to initialize cups manager: %v", err)
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
|
||||
cupsManager = manager
|
||||
|
||||
log.Info("CUPS manager initialized")
|
||||
return nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func ensureCupsManager() (*cups.Manager, error) {
|
||||
cupsMu.Lock()
|
||||
created, err := initializeCupsManagerLocked()
|
||||
mgr := cupsManager
|
||||
cupsMu.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if created {
|
||||
notifyCapabilityChange()
|
||||
}
|
||||
return mgr, nil
|
||||
}
|
||||
|
||||
func cupsAvailable() bool {
|
||||
cupsMu.Lock()
|
||||
defer cupsMu.Unlock()
|
||||
return cupsManager != nil
|
||||
}
|
||||
|
||||
func releaseCupsSubscriber() {
|
||||
cupsMu.Lock()
|
||||
cupsSubscriberCount--
|
||||
var mgr *cups.Manager
|
||||
if cupsSubscriberCount == 0 && cupsManager != nil {
|
||||
mgr = cupsManager
|
||||
cupsManager = nil
|
||||
}
|
||||
cupsMu.Unlock()
|
||||
if mgr == nil {
|
||||
return
|
||||
}
|
||||
log.Info("Last CUPS subscriber disconnected, shutting down CUPS manager")
|
||||
mgr.Close()
|
||||
notifyCapabilityChange()
|
||||
}
|
||||
|
||||
func InitializeBrightnessManager() error {
|
||||
@@ -398,6 +436,11 @@ func InitializeSysUpdateManager() error {
|
||||
|
||||
func handleConnection(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Errorf("handleConnection panic recovered: panic=%v\n%s", r, debug.Stack())
|
||||
}
|
||||
}()
|
||||
|
||||
caps := getCapabilities()
|
||||
capsData, _ := json.Marshal(caps)
|
||||
@@ -415,10 +458,21 @@ func handleConnection(conn net.Conn) {
|
||||
continue
|
||||
}
|
||||
|
||||
go RouteRequest(conn, req)
|
||||
go routeRequestRecovered(conn, req)
|
||||
}
|
||||
}
|
||||
|
||||
// routeRequestRecovered keeps a panicking handler from taking down the whole daemon
|
||||
func routeRequestRecovered(conn net.Conn, req models.Request) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Errorf("RouteRequest panic recovered: method=%s panic=%v\n%s", req.Method, r, debug.Stack())
|
||||
models.RespondError(conn, req.ID, "internal server error")
|
||||
}
|
||||
}()
|
||||
RouteRequest(conn, req)
|
||||
}
|
||||
|
||||
func getCapabilities() Capabilities {
|
||||
caps := []string{"plugins"}
|
||||
|
||||
@@ -446,7 +500,7 @@ func getCapabilities() Capabilities {
|
||||
caps = append(caps, "browser")
|
||||
}
|
||||
|
||||
if cupsManager != nil {
|
||||
if cupsAvailable() {
|
||||
caps = append(caps, "cups")
|
||||
}
|
||||
|
||||
@@ -516,7 +570,7 @@ func getServerInfo() ServerInfo {
|
||||
caps = append(caps, "browser")
|
||||
}
|
||||
|
||||
if cupsManager != nil {
|
||||
if cupsAvailable() {
|
||||
caps = append(caps, "cups")
|
||||
}
|
||||
|
||||
@@ -581,6 +635,11 @@ func notifyCapabilityChange() {
|
||||
func handleSubscribe(conn net.Conn, req models.Request) {
|
||||
clientID := fmt.Sprintf("meta-client-%p", conn)
|
||||
|
||||
dbusClient := dbusClientID
|
||||
if id, ok := models.Get[string](req, "clientId"); ok && id != "" {
|
||||
dbusClient = id
|
||||
}
|
||||
|
||||
var services []string
|
||||
if servicesParam, ok := models.Get[[]any](req, "services"); ok {
|
||||
for _, s := range servicesParam {
|
||||
@@ -975,38 +1034,31 @@ func handleSubscribe(conn net.Conn, req models.Request) {
|
||||
}
|
||||
|
||||
if shouldSubscribe("cups") {
|
||||
cupsSubscribers.Store(clientID+"-cups", true)
|
||||
count := cupsSubscriberCount.Add(1)
|
||||
cupsMu.Lock()
|
||||
cupsSubscriberCount++
|
||||
created, err := initializeCupsManagerLocked()
|
||||
mgr := cupsManager
|
||||
cupsMu.Unlock()
|
||||
|
||||
if count == 1 {
|
||||
if err := InitializeCupsManager(); err != nil {
|
||||
if err != nil {
|
||||
log.Warnf("Failed to initialize CUPS manager for subscription: %v", err)
|
||||
} else {
|
||||
} else if created {
|
||||
notifyCapabilityChange()
|
||||
}
|
||||
}
|
||||
|
||||
if cupsManager != nil {
|
||||
if mgr == nil {
|
||||
releaseCupsSubscriber()
|
||||
} else {
|
||||
wg.Add(1)
|
||||
cupsChan := cupsManager.Subscribe(clientID + "-cups")
|
||||
cupsChan := mgr.Subscribe(clientID + "-cups")
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
cupsManager.Unsubscribe(clientID + "-cups")
|
||||
cupsSubscribers.Delete(clientID + "-cups")
|
||||
count := cupsSubscriberCount.Add(-1)
|
||||
|
||||
if count == 0 {
|
||||
log.Info("Last CUPS subscriber disconnected, shutting down CUPS manager")
|
||||
if cupsManager != nil {
|
||||
cupsManager.Close()
|
||||
cupsManager = nil
|
||||
notifyCapabilityChange()
|
||||
}
|
||||
}
|
||||
mgr.Unsubscribe(clientID + "-cups")
|
||||
releaseCupsSubscriber()
|
||||
}()
|
||||
|
||||
initialState := cupsManager.GetState()
|
||||
initialState := mgr.GetState()
|
||||
select {
|
||||
case eventChan <- ServiceEvent{Service: "cups", Data: initialState}:
|
||||
case <-stopChan:
|
||||
@@ -1249,10 +1301,10 @@ func handleSubscribe(conn net.Conn, req models.Request) {
|
||||
|
||||
if shouldSubscribe("dbus") && dbusManager != nil {
|
||||
wg.Add(1)
|
||||
dbusChan := dbusManager.SubscribeSignals(dbusClientID)
|
||||
dbusChan := dbusManager.SubscribeSignals(dbusClient)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer dbusManager.UnsubscribeSignals(dbusClientID)
|
||||
defer dbusManager.UnsubscribeSignals(dbusClient)
|
||||
|
||||
for {
|
||||
select {
|
||||
@@ -1316,9 +1368,12 @@ func cleanupManagers() {
|
||||
if appPickerManager != nil {
|
||||
appPickerManager.Close()
|
||||
}
|
||||
cupsMu.Lock()
|
||||
if cupsManager != nil {
|
||||
cupsManager.Close()
|
||||
cupsManager = nil
|
||||
}
|
||||
cupsMu.Unlock()
|
||||
if brightnessManager != nil {
|
||||
brightnessManager.Close()
|
||||
}
|
||||
@@ -1676,10 +1731,21 @@ func Start(printDocs bool) error {
|
||||
}()
|
||||
|
||||
go func() {
|
||||
if err := InitializeBluezManager(); err != nil {
|
||||
log.Warnf("Bluez manager unavailable: %v", err)
|
||||
} else {
|
||||
for {
|
||||
err := InitializeBluezManager()
|
||||
if err == nil {
|
||||
notifyCapabilityChange()
|
||||
return
|
||||
}
|
||||
log.Warnf("Bluez manager unavailable: %v", err)
|
||||
if !errors.Is(err, bluez.ErrNoAdapter) {
|
||||
return
|
||||
}
|
||||
if err := bluez.WaitForAdapter(); err != nil {
|
||||
log.Warnf("Bluetooth adapter watch failed: %v", err)
|
||||
return
|
||||
}
|
||||
log.Info("Bluetooth adapter appeared, initializing bluez manager")
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package sysupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -31,7 +32,36 @@ func (aptBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseAptUpgradable(string(out)), nil
|
||||
return filterAptHeld(parseAptUpgradable(string(out)), aptHeldPackages(ctx)), nil
|
||||
}
|
||||
|
||||
// aptHeldPackages returns held packages, which apt-get upgrade never applies.
|
||||
func aptHeldPackages(ctx context.Context) map[string]bool {
|
||||
out, err := exec.CommandContext(ctx, "apt-mark", "showhold").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
held := make(map[string]bool)
|
||||
for line := range strings.SplitSeq(string(out), "\n") {
|
||||
if name := strings.TrimSpace(line); name != "" {
|
||||
held[name] = true
|
||||
}
|
||||
}
|
||||
return held
|
||||
}
|
||||
|
||||
func filterAptHeld(pkgs []Package, held map[string]bool) []Package {
|
||||
if len(held) == 0 {
|
||||
return pkgs
|
||||
}
|
||||
out := pkgs[:0]
|
||||
for _, p := range pkgs {
|
||||
if held[p.Name] {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||
@@ -52,7 +82,22 @@ func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(
|
||||
}
|
||||
|
||||
func aptUpgradeArgv(bin string, opts UpgradeOptions) []string {
|
||||
ignored := shellSafeNames(opts.Ignored)
|
||||
if len(ignored) == 0 {
|
||||
return privilegedArgv(opts, "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "upgrade", "-y")
|
||||
}
|
||||
return privilegedArgv(opts, "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", "sh", "-c", aptHoldScript(bin, ignored))
|
||||
}
|
||||
|
||||
// aptHoldScript holds ignored packages only for the upgrade, leaving pre-existing user holds untouched.
|
||||
func aptHoldScript(bin string, ignored []string) string {
|
||||
names := strings.Join(ignored, " ")
|
||||
return fmt.Sprintf(
|
||||
`new=""; for p in %s; do apt-mark showhold | grep -qx "$p" || new="$new $p"; done; `+
|
||||
`[ -n "$new" ] && apt-mark hold $new; `+
|
||||
`%s upgrade -y; rc=$?; `+
|
||||
`[ -n "$new" ] && apt-mark unhold $new; exit $rc`,
|
||||
names, bin)
|
||||
}
|
||||
|
||||
func parseAptUpgradable(text string) []Package {
|
||||
|
||||
@@ -70,3 +70,22 @@ libsdl2-2.0-0/stable 2.30.0+dfsg-1 amd64 [upgradable from: 2.28.5+dfsg-1]`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterAptHeld(t *testing.T) {
|
||||
pkgs := []Package{
|
||||
{Name: "bash", Repo: RepoSystem, Backend: "apt"},
|
||||
{Name: "linux-image-generic", Repo: RepoSystem, Backend: "apt"},
|
||||
{Name: "zsh", Repo: RepoSystem, Backend: "apt"},
|
||||
}
|
||||
|
||||
got := filterAptHeld(append([]Package(nil), pkgs...), map[string]bool{"linux-image-generic": true})
|
||||
want := []Package{pkgs[0], pkgs[2]}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("filterAptHeld() = %#v\nwant %#v", got, want)
|
||||
}
|
||||
|
||||
unfiltered := filterAptHeld(append([]Package(nil), pkgs...), nil)
|
||||
if !reflect.DeepEqual(unfiltered, pkgs) {
|
||||
t.Errorf("filterAptHeld(nil held) = %#v\nwant %#v", unfiltered, pkgs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package sysupdate
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
@@ -52,7 +53,11 @@ func (b dnfBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine fun
|
||||
}
|
||||
|
||||
func dnfUpgradeArgv(bin string, opts UpgradeOptions) []string {
|
||||
return privilegedArgv(opts, bin, "upgrade", "--refresh", "-y")
|
||||
argv := []string{bin, "upgrade", "--refresh", "-y"}
|
||||
if len(opts.Ignored) > 0 {
|
||||
argv = append(argv, "--exclude="+strings.Join(opts.Ignored, ","))
|
||||
}
|
||||
return privilegedArgv(opts, argv...)
|
||||
}
|
||||
|
||||
func dnfListUpgrades(ctx context.Context, bin string) (string, error) {
|
||||
@@ -65,9 +70,22 @@ func dnfListUpgrades(ctx context.Context, bin string) (string, error) {
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 100 {
|
||||
return string(out), nil
|
||||
}
|
||||
if detail := lastNonEmptyLine(string(out)); detail != "" {
|
||||
return "", fmt.Errorf("%w: %s", err, detail)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
func lastNonEmptyLine(text string) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
if line := strings.TrimSpace(lines[i]); line != "" {
|
||||
return line
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func dnfCheckUpdatesArgv(bin string) []string {
|
||||
subcommand := "check-update"
|
||||
if bin == "dnf5" {
|
||||
|
||||
@@ -95,11 +95,21 @@ func (flatpakBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine f
|
||||
if !BackendHasTargets(flatpakBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||
return nil
|
||||
}
|
||||
return Run(ctx, flatpakUpgradeArgv(), RunOptions{OnLine: onLine})
|
||||
return Run(ctx, flatpakUpgradeArgv(opts), RunOptions{OnLine: onLine})
|
||||
}
|
||||
|
||||
func flatpakUpgradeArgv() []string {
|
||||
return []string{"flatpak", "update", "-y", "--noninteractive"}
|
||||
func flatpakUpgradeArgv(opts UpgradeOptions) []string {
|
||||
argv := []string{"flatpak", "update", "-y", "--noninteractive"}
|
||||
if len(opts.Ignored) == 0 {
|
||||
return argv
|
||||
}
|
||||
// No exclude flag; update the already-filtered refs explicitly.
|
||||
for _, p := range opts.Targets {
|
||||
if p.Repo == RepoFlatpak && p.Ref != "" {
|
||||
argv = append(argv, p.Ref)
|
||||
}
|
||||
}
|
||||
return argv
|
||||
}
|
||||
|
||||
func parseFlatpakUpdateOutput(text string, installed map[string]flatpakInstalledEntry) []Package {
|
||||
|
||||
@@ -53,6 +53,41 @@ func pacmanUpgradeArgv(opts UpgradeOptions) []string {
|
||||
return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed")
|
||||
}
|
||||
|
||||
// Dont allow partial updates on arch, if they wanna break their system they can do it outside of DMS:
|
||||
// https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported
|
||||
// AUR packages are exempt — holding those cannot break the repo dependency graph.
|
||||
func dropPacmanRepoIgnores(ignored []string, pending []Package) []string {
|
||||
if len(ignored) == 0 {
|
||||
return ignored
|
||||
}
|
||||
repoPending := make(map[string]bool, len(pending))
|
||||
for _, p := range pending {
|
||||
if p.Repo == RepoSystem {
|
||||
repoPending[p.Name] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(ignored))
|
||||
for _, name := range ignored {
|
||||
if repoPending[name] {
|
||||
continue
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isPacmanFamily(b Backend) bool {
|
||||
if b == nil {
|
||||
return false
|
||||
}
|
||||
switch b.ID() {
|
||||
case "pacman", "paru", "yay":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type archHelperBackend struct {
|
||||
id string
|
||||
}
|
||||
@@ -99,23 +134,27 @@ func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onL
|
||||
return nil
|
||||
}
|
||||
if os.Getenv("DMS_FORCE_PKEXEC") == "1" {
|
||||
argv := append([]string{"pkexec"}, archHelperUpgradeArgv(b.id, opts.IncludeAUR)...)
|
||||
argv := append([]string{"pkexec"}, archHelperUpgradeArgv(b.id, opts.IncludeAUR, opts.Ignored)...)
|
||||
return Run(ctx, argv, RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
|
||||
}
|
||||
term := findTerminal(opts.Terminal)
|
||||
if term == "" {
|
||||
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
|
||||
}
|
||||
cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR), " ")
|
||||
cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR, opts.Ignored), " ")
|
||||
title := fmt.Sprintf("DMS — System Update (%s)", b.id)
|
||||
return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine})
|
||||
return Run(ctx, wrapInTerminal(term, title, cmd, opts.TerminalArgs), RunOptions{OnLine: onLine})
|
||||
}
|
||||
|
||||
func archHelperUpgradeArgv(id string, includeAUR bool) []string {
|
||||
func archHelperUpgradeArgv(id string, includeAUR bool, ignored []string) []string {
|
||||
argv := []string{id, "-Syu", "--noconfirm", "--needed"}
|
||||
if !includeAUR {
|
||||
argv = append(argv, "--repo")
|
||||
}
|
||||
ignored = shellSafeNames(ignored)
|
||||
if len(ignored) > 0 {
|
||||
argv = append(argv, "--ignore", strings.Join(ignored, ","))
|
||||
}
|
||||
return argv
|
||||
}
|
||||
|
||||
@@ -248,6 +287,10 @@ func parseArchUpdates(text, backendID string, repo RepoKind) []Package {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// pacman -Qu / paru -Qua flag IgnorePkg entries with a trailing marker
|
||||
if strings.HasSuffix(line, "[ignored]") {
|
||||
continue
|
||||
}
|
||||
m := archUpdateLine.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
|
||||
@@ -92,6 +92,17 @@ foo`,
|
||||
{Name: "bat", Repo: RepoSystem, Backend: "pacman", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skips IgnorePkg entries",
|
||||
input: `bat 0.26.0-1 -> 0.26.1-2
|
||||
linux 6.18.0-1 -> 6.18.1-1 [ignored]
|
||||
discord 0.0.108-1 -> 0.0.109-1 [ignored]`,
|
||||
backendID: "pacman",
|
||||
repo: RepoSystem,
|
||||
want: []Package{
|
||||
{Name: "bat", Repo: RepoSystem, Backend: "pacman", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "extra whitespace tolerated",
|
||||
input: " bat 0.26.0-1 -> 0.26.1-2 ",
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -81,5 +83,20 @@ func (zypperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine fu
|
||||
}
|
||||
|
||||
func zypperUpgradeArgv(opts UpgradeOptions) []string {
|
||||
ignored := shellSafeNames(opts.Ignored)
|
||||
if len(ignored) == 0 {
|
||||
return privilegedArgv(opts, "zypper", "--non-interactive", "update")
|
||||
}
|
||||
return privilegedArgv(opts, "sh", "-c", zypperLockScript(ignored))
|
||||
}
|
||||
|
||||
// zypperLockScript locks ignored packages only for the update, leaving pre-existing user locks untouched.
|
||||
func zypperLockScript(ignored []string) string {
|
||||
names := strings.Join(ignored, " ")
|
||||
return fmt.Sprintf(
|
||||
`new=""; for p in %s; do grep -qsE "^solvable_name:[[:space:]]*$p$" /etc/zypp/locks || new="$new $p"; done; `+
|
||||
`[ -n "$new" ] && zypper --non-interactive al $new; `+
|
||||
`zypper --non-interactive update; rc=$?; `+
|
||||
`[ -n "$new" ] && zypper --non-interactive rl $new; exit $rc`,
|
||||
names)
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ func findTerminal(override string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func wrapInTerminal(term, title, shellCmd string) []string {
|
||||
func wrapInTerminal(term, title, shellCmd string, extraArgs []string) []string {
|
||||
const appID = "com.danklinux.dms"
|
||||
banner := fmt.Sprintf(
|
||||
`printf '\033[1;36m=== %s ===\033[0m\n'; printf '\033[2m$ %s\033[0m\n'; printf '\033[33mYou may be prompted for your sudo password to apply system updates.\033[0m\n\n'`,
|
||||
@@ -129,24 +129,26 @@ func wrapInTerminal(term, title, shellCmd string) []string {
|
||||
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
|
||||
full := export + banner + "; " + shellCmd + "; " + closer
|
||||
|
||||
var argv []string
|
||||
execFlag := "-e"
|
||||
switch term {
|
||||
case "kitty":
|
||||
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||
case "alacritty":
|
||||
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||
case "kitty", "alacritty", "wezterm":
|
||||
argv = []string{term, "--class", appID, "-T", title}
|
||||
case "foot":
|
||||
return []string{term, "--app-id=" + appID, "--title=" + title, "-e", "sh", "-c", full}
|
||||
argv = []string{term, "--app-id=" + appID, "--title=" + title}
|
||||
case "ghostty":
|
||||
return []string{term, "--class=" + appID, "--title=" + title, "-e", "sh", "-c", full}
|
||||
case "wezterm":
|
||||
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||
argv = []string{term, "--class=" + appID, "--title=" + title}
|
||||
case "xterm":
|
||||
return []string{term, "-class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||
argv = []string{term, "-class", appID, "-T", title}
|
||||
case "konsole":
|
||||
return []string{term, "-p", "tabtitle=" + title, "-e", "sh", "-c", full}
|
||||
argv = []string{term, "-p", "tabtitle=" + title}
|
||||
case "gnome-terminal":
|
||||
return []string{term, "--title=" + title, "--", "sh", "-c", full}
|
||||
// --wait: the factory process otherwise returns immediately
|
||||
argv = []string{term, "--wait", "--title=" + title}
|
||||
execFlag = "--"
|
||||
default:
|
||||
return []string{term, "-e", "sh", "-c", full}
|
||||
argv = []string{term}
|
||||
}
|
||||
argv = append(argv, extraArgs...)
|
||||
return append(argv, execFlag, "sh", "-c", full)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user