mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-02 03:28:28 -04:00
Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cc574ea5b | |||
| cdaedad969 | |||
| bf408f8d00 | |||
| b169fe0d77 | |||
| 9b67cedaa1 | |||
| 2edf70a144 | |||
| 72a71cacba | |||
| 0440e40a82 | |||
| ec27d4643c | |||
| f18d36f6c2 | |||
| 3254cc6a1e | |||
| 4af3225f21 | |||
| 3c5245914f | |||
| 89814a2c65 | |||
| 5d2093e54a | |||
| 3cd52ca327 | |||
| 5b41d699fa | |||
| 544599bf1c | |||
| 296b3a3d7e | |||
| 729a990fa7 | |||
| c3fa7b2e1d | |||
| 2c5a1a2804 | |||
| 1973526c4e | |||
| bba5502960 | |||
| 52740290b2 | |||
| 27703575bc | |||
| e6504add7b | |||
| f1e9121295 | |||
| 9ff751b82a | |||
| 21eaaef056 | |||
| bb0be2b215 | |||
| 25847c3f03 | |||
| 7b5c25c50f | |||
| 7535b70fa6 | |||
| e3034e4e94 | |||
| 3da19e5c15 | |||
| ca89e12963 | |||
| 4fb6995796 | |||
| 3f5a54aa88 | |||
| d379d251b9 | |||
| 7ab0e01573 | |||
| 2cb48aaf6b | |||
| e4657aa5f9 | |||
| 197d17ac4e | |||
| 31ea83584b | |||
| ea66b136ba | |||
| 06c0ea2afb | |||
| f4f47c0bc5 | |||
| 8a0ed8a50f | |||
| 63eea01243 | |||
| f590a2965a | |||
| 4ab03deded | |||
| 846d07d86a | |||
| 55d88d8dfb | |||
| a803f96f41 | |||
| da7cc5648b | |||
| 75443758f9 | |||
| eaecff93a4 | |||
| 6e7c20c59c | |||
| 406dcfe110 | |||
| e2b3a2e3ca | |||
| 71ab752e1b | |||
| 204ecd0461 | |||
| 0fdfac366e | |||
| 52123458c7 | |||
| 26b2955cf3 | |||
| d82d86df5c | |||
| c445597f83 | |||
| 6a58adfb29 | |||
| 05feb211ba | |||
| a3b2167e58 | |||
| 0b69feaa1a | |||
| 494144a7c7 | |||
| 2a2c1ca9e6 | |||
| 56b7ecb008 | |||
| 867102b82c | |||
| 4bdb7d17b2 | |||
| c44ffae751 | |||
| ce1595d62d | |||
| 45f6232e32 | |||
| 9cf2ca7196 | |||
| c0eeed4e89 | |||
| d0a4c1c56e | |||
| cb0dc9c68d | |||
| 0439d017b9 | |||
| 8008238ca0 | |||
| a095d0ed90 | |||
| a48cce59d4 | |||
| 4c806f83f1 | |||
| bdfd565b72 | |||
| f3b698590c | |||
| 7826d42ce4 | |||
| 6cc30c289b | |||
| 43cc8e69d1 | |||
| a62ae336e0 | |||
| e94af2a7aa | |||
| bb9e69e0c1 | |||
| 1cc9218ff6 | |||
| 56712f46fa | |||
| 93168ee073 |
@@ -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@v2
|
||||
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@v2
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app_token.outputs.token }}
|
||||
|
||||
- name: Create release branch
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${GITHUB_REF_NAME}" =~ ^v([0-9]+)\.([0-9]+)\.0$ ]]; then
|
||||
echo "not a vX.Y.0 tag, no release branch to cut"
|
||||
exit 0
|
||||
fi
|
||||
branch="stable-${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
if git ls-remote --exit-code origin "refs/heads/${branch}" >/dev/null 2>&1; then
|
||||
echo "${branch} already exists"
|
||||
exit 0
|
||||
fi
|
||||
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" "HEAD:refs/heads/${branch}"
|
||||
echo "created ${branch} at ${GITHUB_REF_NAME}"
|
||||
GH_TOKEN="${{ secrets.GITHUB_TOKEN }}" gh label create "port ${branch}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--description "Auto-port merged PR to ${branch}" --color 0e8a16 ||
|
||||
echo "label 'port ${branch}' may already exist"
|
||||
|
||||
@@ -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,98 @@
|
||||
name: Point release
|
||||
|
||||
# Cuts vX.Y.Z from stable-X.Y: runs the port audit (warn-only), bumps
|
||||
# quickshell/VERSION, tags, and dispatches the Release workflow. Distro
|
||||
# builds are dispatched separately.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Point release version (e.g. 1.5.1)"
|
||||
required: true
|
||||
type: string
|
||||
notify_issues:
|
||||
description: "Comment on open related/fixes issues asking reporters to retest"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
|
||||
concurrency:
|
||||
group: point-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
steps:
|
||||
- name: Validate version and derive branch
|
||||
id: derive
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::version must be X.Y.Z (got '$VERSION')"; exit 1
|
||||
fi
|
||||
echo "branch=stable-${VERSION%.*}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create GitHub App token
|
||||
id: app_token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout release branch
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ steps.derive.outputs.branch }}
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app_token.outputs.token }}
|
||||
|
||||
- name: Port audit (informational)
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
run: |
|
||||
bash scripts/port-audit.sh "${{ steps.derive.outputs.branch }}" ||
|
||||
echo "::warning::port audit failed; continuing"
|
||||
|
||||
- name: Bump VERSION, tag, and push
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
TAG: ${{ steps.derive.outputs.tag }}
|
||||
BRANCH: ${{ steps.derive.outputs.branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
|
||||
echo "::error::tag ${TAG} already exists"; exit 1
|
||||
fi
|
||||
git config user.name "dms-ci[bot]"
|
||||
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||
|
||||
echo "${TAG}" > quickshell/VERSION
|
||||
git add quickshell/VERSION
|
||||
git commit -m "bump VERSION to ${TAG}"
|
||||
git tag "${TAG}"
|
||||
git push "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" "HEAD:${BRANCH}" "refs/tags/${TAG}"
|
||||
|
||||
- name: Dispatch Release workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
run: |
|
||||
gh workflow run release.yml --ref "${{ steps.derive.outputs.tag }}" \
|
||||
-f tag="${{ steps.derive.outputs.tag }}" \
|
||||
-f notify_issues="${{ inputs.notify_issues }}"
|
||||
|
||||
- name: Next steps
|
||||
run: |
|
||||
{
|
||||
echo "## ${{ steps.derive.outputs.tag }} tagged on ${{ steps.derive.outputs.branch }} — Release workflow dispatched"
|
||||
echo ""
|
||||
echo "Distro builds are manual: run the per-distro workflows (COPR/OBS/PPA/XBPS) once the release is published."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -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@v2
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
# Base-repo code only; PR head code is never checked out or executed.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: master
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app_token.outputs.token }}
|
||||
|
||||
- name: Port trailer-flagged commits
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
COMMITS: ${{ toJSON(github.event.commits) }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "dms-ci[bot]"
|
||||
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||
|
||||
for sha in $(jq -r '.[].id' <<<"$COMMITS"); do
|
||||
git cat-file -e "$sha" 2>/dev/null || continue
|
||||
# skip merge commits (handled by the label path)
|
||||
[ "$(git rev-list --no-walk --count --min-parents=2 "$sha")" -eq 0 ] || continue
|
||||
|
||||
# own-line form (supports comma lists), validated version-shaped
|
||||
t1=$(git log -1 --format=%B "$sha" |
|
||||
{ grep -iE '^Port[:/ -]' || true; } | sed -E 's|^port[-:/ ]+||I' | tr ',' '\n' |
|
||||
sed 's/[[:space:]]//g; /^$/d' | sed -E 's,^stable-,,I' |
|
||||
{ grep -E '^[0-9]+\.[0-9]+(\.[0-9]+)?$' || true; })
|
||||
# mid-line form, e.g. "fix thing fixes #2802 port 1.5"
|
||||
t2=$(git log -1 --format=%B "$sha" |
|
||||
{ grep -oiE '\bport[-: /]+(stable-)?[0-9]+\.[0-9]+(\.[0-9]+)?\b' || true; } |
|
||||
sed -E 's|^port[-:/ ]+||I' | sed -E 's,^stable-,,I')
|
||||
targets=$(printf '%s\n%s\n' "$t1" "$t2" | sed '/^$/d' | sort -u)
|
||||
for ver in $targets; do
|
||||
echo "::group::port $sha -> stable-$ver"
|
||||
bash scripts/port.sh "stable-$ver" "$sha"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
done
|
||||
|
||||
- name: Port label-flagged PR
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
(github.event.action == 'closed' ||
|
||||
(github.event.action == 'labeled' && startsWith(github.event.label.name, 'port ')))
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
|
||||
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
PORT_SOURCE_PR: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "dms-ci[bot]"
|
||||
git config user.email "dms-ci[bot]@users.noreply.github.com"
|
||||
|
||||
targets=$(jq -r '.[] | select(startswith("port ")) | sub("^port +"; "")' <<<"$LABELS" |
|
||||
sed -E 's,^stable-,,' | sort -u)
|
||||
[ -n "$targets" ] || { echo "no port labels, nothing to do"; exit 0; }
|
||||
[ -n "$MERGE_SHA" ] || { echo "::error::PR has no merge commit sha"; exit 1; }
|
||||
|
||||
for ver in $targets; do
|
||||
echo "::group::port PR #${PORT_SOURCE_PR} ($MERGE_SHA) -> stable-$ver"
|
||||
bash scripts/port.sh "stable-$ver" "$MERGE_SHA"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
@@ -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,13 +211,23 @@ 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 "")
|
||||
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/^[^|]*|/- /')
|
||||
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'
|
||||
@@ -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@v2
|
||||
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.
|
||||
|
||||
@@ -23,6 +23,8 @@ var (
|
||||
replaceConfigs []string
|
||||
replaceConfigsAll bool
|
||||
yes bool
|
||||
danksearch bool
|
||||
dankcalendar bool
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
@@ -49,6 +51,8 @@ func init() {
|
||||
rootCmd.Flags().StringSliceVar(&replaceConfigs, "replace-configs", []string{}, "Deploy only named configs (e.g. niri,ghostty)")
|
||||
rootCmd.Flags().BoolVar(&replaceConfigsAll, "replace-configs-all", false, "Deploy and replace all configurations")
|
||||
rootCmd.Flags().BoolVarP(&yes, "yes", "y", false, "Auto-confirm all prompts")
|
||||
rootCmd.Flags().BoolVar(&danksearch, "danksearch", false, "Install danksearch and enable its user indexing service")
|
||||
rootCmd.Flags().BoolVar(&dankcalendar, "dankcalendar", false, "Install dankcalendar")
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -74,6 +78,8 @@ func runDankinstall(cmd *cobra.Command, args []string) error {
|
||||
"replace-configs",
|
||||
"replace-configs-all",
|
||||
"yes",
|
||||
"danksearch",
|
||||
"dankcalendar",
|
||||
}
|
||||
var set []string
|
||||
for _, name := range headlessOnly {
|
||||
@@ -109,6 +115,8 @@ func runHeadless() error {
|
||||
ReplaceConfigs: replaceConfigs,
|
||||
ReplaceConfigsAll: replaceConfigsAll,
|
||||
Yes: yes,
|
||||
DankSearch: danksearch,
|
||||
DankCalendar: dankcalendar,
|
||||
}
|
||||
|
||||
runner := headless.NewRunner(cfg)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
@@ -35,9 +38,128 @@ var authSyncCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var authResolveLockCmd = &cobra.Command{
|
||||
Use: "resolve-lock",
|
||||
Short: "Generate the lock-screen PAM config from the system auth stack",
|
||||
Long: "Resolve the distribution's PAM auth stack into a self-contained lock-screen config under the user state directory.\n" +
|
||||
"Runs unprivileged (reads /etc/pam.d, writes to the user's state dir) and is used by the shell as a fallback when /etc/pam.d/dankshell is not managed.\n" +
|
||||
"Prints the path of the generated file.",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
logFunc := func(msg string) {
|
||||
if !quiet {
|
||||
fmt.Println(msg)
|
||||
}
|
||||
}
|
||||
path, err := sharedpam.WriteUserLockscreenPamConfig(logFunc)
|
||||
if err != nil {
|
||||
log.Fatalf("Error resolving lock-screen PAM config: %v", err)
|
||||
}
|
||||
fmt.Println(path)
|
||||
},
|
||||
}
|
||||
|
||||
var authListServicesCmd = &cobra.Command{
|
||||
Use: "list-services",
|
||||
Short: "List candidate lock-screen PAM services available on this system",
|
||||
Long: "Enumerate the lock-screen PAM services that exist on this system and report their resolved auth stack (whether it has an auth directive and whether fingerprint/U2F modules appear inline).",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
asJSON, _ := cmd.Flags().GetBool("json")
|
||||
services := sharedpam.ListLockscreenPamServices()
|
||||
|
||||
if asJSON {
|
||||
payload := struct {
|
||||
Services []sharedpam.LockscreenPamServiceInfo `json:"services"`
|
||||
}{Services: services}
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("Error encoding services: %v", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
return
|
||||
}
|
||||
|
||||
if len(services) == 0 {
|
||||
fmt.Println("No candidate lock-screen PAM services found.")
|
||||
return
|
||||
}
|
||||
for _, s := range services {
|
||||
fmt.Printf("%-20s %-30s auth=%-5t fingerprint=%-5t u2f=%t\n", s.Name, s.Path, s.HasAuth, s.InlineFingerprint, s.InlineU2f)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var authValidateCmd = &cobra.Command{
|
||||
Use: "validate",
|
||||
Short: "Validate a PAM service file for use as the DMS lock-screen password stack",
|
||||
Long: "Validate one PAM service (by --service NAME or --path /abs/file) for use as the DMS lock-screen password stack. Exits 1 when the file is not usable.",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
path, _ := cmd.Flags().GetString("path")
|
||||
service, _ := cmd.Flags().GetString("service")
|
||||
asJSON, _ := cmd.Flags().GetBool("json")
|
||||
|
||||
if (path == "") == (service == "") {
|
||||
log.Fatalf("Error: exactly one of --path or --service is required")
|
||||
}
|
||||
|
||||
var result sharedpam.LockscreenPamValidation
|
||||
switch {
|
||||
case service != "":
|
||||
result = sharedpam.ValidateLockscreenPamService(service)
|
||||
case !filepath.IsAbs(path):
|
||||
result = sharedpam.LockscreenPamValidation{
|
||||
Path: path,
|
||||
MissingModules: []string{},
|
||||
Warnings: []string{},
|
||||
Errors: []string{"--path must be an absolute file path"},
|
||||
}
|
||||
default:
|
||||
result = sharedpam.ValidateLockscreenPamPath(path)
|
||||
}
|
||||
|
||||
if asJSON {
|
||||
data, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("Error encoding validation: %v", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
} else {
|
||||
printLockscreenPamValidation(result)
|
||||
}
|
||||
|
||||
if !result.Valid {
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func printLockscreenPamValidation(result sharedpam.LockscreenPamValidation) {
|
||||
fmt.Printf("Path: %s\n", result.Path)
|
||||
fmt.Printf("Valid: %t\n", result.Valid)
|
||||
fmt.Printf("Has auth: %t\n", result.HasAuth)
|
||||
fmt.Printf("Inline fingerprint: %t\n", result.InlineFingerprint)
|
||||
fmt.Printf("Inline U2F: %t\n", result.InlineU2f)
|
||||
if len(result.MissingModules) > 0 {
|
||||
fmt.Printf("Missing modules: %s\n", strings.Join(result.MissingModules, ", "))
|
||||
}
|
||||
for _, w := range result.Warnings {
|
||||
fmt.Println("⚠ " + w)
|
||||
}
|
||||
for _, e := range result.Errors {
|
||||
fmt.Println("✗ " + e)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
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().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")
|
||||
@@ -886,6 +910,7 @@ func checkOptionalDependencies() []checkResult {
|
||||
{"cava", "cava", "Audio visualizer", true},
|
||||
{"khal", "khal", "Calendar events", false},
|
||||
{"danksearch", "dsearch", "File search", false},
|
||||
{"dankcalendar", "dcal", "Calendar app", false},
|
||||
{"fprintd", "fprintd-list", "Fingerprint auth", false},
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
ensureRunitSeat("_greeter")
|
||||
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",
|
||||
@@ -59,7 +71,8 @@ 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`,
|
||||
}
|
||||
|
||||
var ssRegionCmd = &cobra.Command{
|
||||
@@ -128,6 +141,7 @@ 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")
|
||||
|
||||
screenshotCmd.AddCommand(ssRegionCmd)
|
||||
screenshotCmd.AddCommand(ssFullCmd)
|
||||
@@ -203,7 +217,36 @@ 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
|
||||
@@ -215,11 +258,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 +276,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 +296,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)
|
||||
}
|
||||
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{
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -51,24 +51,24 @@ binds {
|
||||
XF86AudioMicMute allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "audio" "micmute";
|
||||
}
|
||||
XF86AudioPause allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "playPause";
|
||||
}
|
||||
XF86AudioPlay allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "playPause";
|
||||
}
|
||||
XF86AudioPrev allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "previous";
|
||||
}
|
||||
XF86AudioNext allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "next";
|
||||
}
|
||||
Ctrl+XF86AudioRaiseVolume allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "increment" "3";
|
||||
}
|
||||
Ctrl+XF86AudioLowerVolume allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "decrement" "3";
|
||||
}
|
||||
XF86AudioPause allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "playPause";
|
||||
}
|
||||
XF86AudioPlay allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "playPause";
|
||||
}
|
||||
XF86AudioPrev allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "previous";
|
||||
}
|
||||
XF86AudioNext allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "next";
|
||||
}
|
||||
Ctrl+XF86AudioRaiseVolume allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "increment" "3";
|
||||
}
|
||||
Ctrl+XF86AudioLowerVolume allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "mpris" "decrement" "3";
|
||||
}
|
||||
|
||||
// === Brightness Controls ===
|
||||
XF86MonBrightnessUp allow-when-locked=true {
|
||||
|
||||
@@ -265,9 +265,9 @@ recent-windows {
|
||||
}
|
||||
|
||||
// Include dms files
|
||||
include "dms/colors.kdl"
|
||||
include "dms/layout.kdl"
|
||||
include "dms/alttab.kdl"
|
||||
include "dms/binds.kdl"
|
||||
include "dms/outputs.kdl"
|
||||
include "dms/cursor.kdl"
|
||||
include optional=true "dms/colors.kdl"
|
||||
include optional=true "dms/layout.kdl"
|
||||
include optional=true "dms/alttab.kdl"
|
||||
include optional=true "dms/binds.kdl"
|
||||
include optional=true "dms/outputs.kdl"
|
||||
include optional=true "dms/cursor.kdl"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -119,10 +119,30 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
|
||||
|
||||
dependencies = append(dependencies, a.detectMatugen())
|
||||
dependencies = append(dependencies, a.detectDgop())
|
||||
dependencies = append(dependencies, a.detectDanksearch())
|
||||
dependencies = append(dependencies, a.detectDankCalendar())
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) detectDanksearch() deps.Dependency {
|
||||
dep := a.BaseDistribution.detectDanksearch()
|
||||
dep.CanToggle = true
|
||||
if a.packageInstalled("dsearch-git") {
|
||||
dep.Variant = deps.VariantGit
|
||||
}
|
||||
return dep
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) detectDankCalendar() deps.Dependency {
|
||||
dep := a.BaseDistribution.detectDankCalendar()
|
||||
dep.CanToggle = true
|
||||
if a.packageInstalled("dankcalendar-git") {
|
||||
dep.Variant = deps.VariantGit
|
||||
}
|
||||
return dep
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) detectXDGPortal() deps.Dependency {
|
||||
return a.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", a.packageInstalled("xdg-desktop-portal-gtk"))
|
||||
}
|
||||
@@ -132,7 +152,13 @@ func (a *ArchDistribution) detectAccountsService() deps.Dependency {
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) detectDMSGreeter() deps.Dependency {
|
||||
return a.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", a.packageInstalled("greetd-dms-greeter-git"))
|
||||
installed := a.packageInstalled("greetd-dms-greeter-git") || a.packageInstalled("greetd-dms-greeter-bin")
|
||||
dep := a.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", installed)
|
||||
dep.CanToggle = true
|
||||
if a.packageInstalled("greetd-dms-greeter-git") {
|
||||
dep.Variant = deps.VariantGit
|
||||
}
|
||||
return dep
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) packageInstalled(pkg string) bool {
|
||||
@@ -191,7 +217,7 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
|
||||
"dms (DankMaterialShell)": a.getDMSMapping(variants["dms (DankMaterialShell)"]),
|
||||
"git": {Name: "git", Repository: RepoTypeSystem},
|
||||
"quickshell": a.getQuickshellMapping(variants["quickshell"]),
|
||||
"dms-greeter": {Name: "greetd-dms-greeter-git", Repository: RepoTypeAUR},
|
||||
"dms-greeter": a.getDMSGreeterMapping(variants["dms-greeter"]),
|
||||
"matugen": a.getMatugenMapping(variants["matugen"]),
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeSystem},
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
|
||||
@@ -199,6 +225,8 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
|
||||
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
|
||||
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
|
||||
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
|
||||
"danksearch": a.getDanksearchMapping(variants["danksearch"]),
|
||||
"dankcalendar": a.getDankCalendarMapping(variants["dankcalendar"]),
|
||||
}
|
||||
|
||||
switch wm {
|
||||
@@ -253,6 +281,27 @@ func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) Packag
|
||||
return PackageMapping{Name: "matugen", Repository: RepoTypeSystem}
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) getDanksearchMapping(variant deps.PackageVariant) PackageMapping {
|
||||
if variant == deps.VariantGit {
|
||||
return PackageMapping{Name: "dsearch-git", Repository: RepoTypeAUR}
|
||||
}
|
||||
return PackageMapping{Name: "dsearch-bin", Repository: RepoTypeAUR}
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) getDankCalendarMapping(variant deps.PackageVariant) PackageMapping {
|
||||
if variant == deps.VariantGit {
|
||||
return PackageMapping{Name: "dankcalendar-git", Repository: RepoTypeAUR}
|
||||
}
|
||||
return PackageMapping{Name: "dankcalendar-bin", Repository: RepoTypeAUR}
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) getDMSGreeterMapping(variant deps.PackageVariant) PackageMapping {
|
||||
if variant == deps.VariantGit {
|
||||
return PackageMapping{Name: "greetd-dms-greeter-git", Repository: RepoTypeAUR}
|
||||
}
|
||||
return PackageMapping{Name: "greetd-dms-greeter-bin", Repository: RepoTypeAUR}
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMapping {
|
||||
if forceDMSGit || variant == deps.VariantGit {
|
||||
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
|
||||
|
||||
@@ -107,6 +107,14 @@ func (b *BaseDistribution) detectDgop() deps.Dependency {
|
||||
return b.detectCommand("dgop", "Desktop portal management tool")
|
||||
}
|
||||
|
||||
func (b *BaseDistribution) detectDanksearch() deps.Dependency {
|
||||
return b.detectOptionalPackage("danksearch", "File indexing and search service", b.commandExists("dsearch") || b.commandExists("danksearch"))
|
||||
}
|
||||
|
||||
func (b *BaseDistribution) detectDankCalendar() deps.Dependency {
|
||||
return b.detectOptionalPackage("dankcalendar", "Calendar application", b.commandExists("dcal") || b.commandExists("dankcalendar"))
|
||||
}
|
||||
|
||||
func (b *BaseDistribution) detectDMS() deps.Dependency {
|
||||
dmsPath := filepath.Join(os.Getenv("HOME"), ".config/quickshell/dms")
|
||||
|
||||
|
||||
@@ -71,6 +71,8 @@ func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
||||
|
||||
dependencies = append(dependencies, d.detectMatugen())
|
||||
dependencies = append(dependencies, d.detectDgop())
|
||||
dependencies = append(dependencies, d.detectDanksearch())
|
||||
dependencies = append(dependencies, d.detectDankCalendar())
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
@@ -135,6 +137,8 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
}
|
||||
|
||||
if wm == deps.WindowManagerNiri {
|
||||
@@ -157,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 {
|
||||
@@ -262,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{
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package distros
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// SetupDsearchService enables the dsearch.service user unit. Enablement failures
|
||||
// are returned for the caller to surface as a non-fatal warning.
|
||||
func SetupDsearchService(ctx context.Context, logf func(string)) error {
|
||||
if logf == nil {
|
||||
logf = func(string) {}
|
||||
}
|
||||
|
||||
if err := runSystemctlUser(ctx, "daemon-reload"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := runSystemctlUser(ctx, "enable", "--now", "dsearch.service"); err != nil {
|
||||
return err
|
||||
}
|
||||
logf("Enabled dsearch.service")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSystemctlUser(ctx context.Context, args ...string) error {
|
||||
cmd := exec.CommandContext(ctx, "systemctl", append([]string{"--user"}, args...)...)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("systemctl --user %v failed: %w: %s", args, err, string(output))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -104,6 +104,8 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
||||
|
||||
dependencies = append(dependencies, f.detectMatugen())
|
||||
dependencies = append(dependencies, f.detectDgop())
|
||||
dependencies = append(dependencies, f.detectDanksearch())
|
||||
dependencies = append(dependencies, f.detectDankCalendar())
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
@@ -138,6 +140,8 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
"dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
"danksearch": {Name: "danksearch", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
}
|
||||
|
||||
switch wm {
|
||||
|
||||
@@ -113,6 +113,7 @@ func (g *GentooDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
||||
|
||||
dependencies = append(dependencies, g.detectMatugen())
|
||||
dependencies = append(dependencies, g.detectDgop())
|
||||
dependencies = append(dependencies, g.detectDanksearch())
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
@@ -171,6 +172,7 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
"matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
||||
"dms (DankMaterialShell)": g.getDmsMapping(),
|
||||
"dgop": {Name: "gui-apps/dgop", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
||||
"danksearch": {Name: "gui-apps/danksearch", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
||||
}
|
||||
|
||||
switch wm {
|
||||
|
||||
@@ -91,6 +91,8 @@ func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Contex
|
||||
|
||||
dependencies = append(dependencies, o.detectMatugen())
|
||||
dependencies = append(dependencies, o.detectDgop())
|
||||
dependencies = append(dependencies, o.detectDanksearch())
|
||||
dependencies = append(dependencies, o.detectDankCalendar())
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
@@ -129,6 +131,8 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
}
|
||||
|
||||
switch wm {
|
||||
|
||||
@@ -80,6 +80,8 @@ func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
||||
|
||||
dependencies = append(dependencies, u.detectMatugen())
|
||||
dependencies = append(dependencies, u.detectDgop())
|
||||
dependencies = append(dependencies, u.detectDanksearch())
|
||||
dependencies = append(dependencies, u.detectDankCalendar())
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
@@ -124,6 +126,8 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
"matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"dgop": {Name: "dgop", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"danksearch": {Name: "danksearch", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"dankcalendar": {Name: "dankcalendar-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
}
|
||||
|
||||
switch wm {
|
||||
|
||||
@@ -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()...)
|
||||
@@ -83,6 +84,8 @@ func (v *VoidDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
|
||||
|
||||
dependencies = append(dependencies, v.detectMatugen())
|
||||
dependencies = append(dependencies, v.detectDgop())
|
||||
dependencies = append(dependencies, v.detectDanksearch())
|
||||
dependencies = append(dependencies, v.detectDankCalendar())
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
@@ -140,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"))
|
||||
}
|
||||
@@ -170,12 +177,15 @@ 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},
|
||||
"dms (DankMaterialShell)": v.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeXBPS, RepoURL: VoidDMSRepo},
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
|
||||
"danksearch": {Name: "danksearch", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
|
||||
"dankcalendar": {Name: "dankcalendar", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
|
||||
}
|
||||
|
||||
switch wm {
|
||||
@@ -301,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)
|
||||
}
|
||||
ensureRunitSeat(DetectGreeterUser(), sudoPassword, logFunc)
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,8 @@ type Config struct {
|
||||
ReplaceConfigs []string // specific configs to deploy (e.g. "niri", "ghostty")
|
||||
ReplaceConfigsAll bool // deploy/replace all configurations
|
||||
Yes bool
|
||||
DankSearch bool // install danksearch and enable its user service
|
||||
DankCalendar bool // install dankcalendar
|
||||
}
|
||||
|
||||
// Runner orchestrates unattended (headless) installation.
|
||||
@@ -214,6 +216,11 @@ func (r *Runner) Run() error {
|
||||
return fmt.Errorf("package installation failed: %w", err)
|
||||
}
|
||||
|
||||
useSystemd := true
|
||||
if distroConfig, exists := distros.Registry[osInfo.Distribution.ID]; exists && distroConfig.Family == distros.FamilyVoid {
|
||||
useSystemd = false
|
||||
}
|
||||
|
||||
// 9. Greeter setup (if dms-greeter was included)
|
||||
if !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
|
||||
compositorName := "niri"
|
||||
@@ -231,15 +238,24 @@ func (r *Runner) Run() error {
|
||||
}
|
||||
}
|
||||
|
||||
// 9b. danksearch service setup (if danksearch was included)
|
||||
if useSystemd && !disabledItems["danksearch"] && r.depExists(dependencies, "danksearch") {
|
||||
fmt.Fprintln(os.Stdout, "Enabling danksearch service...")
|
||||
logFunc := func(line string) {
|
||||
r.log(line)
|
||||
fmt.Fprintf(os.Stdout, " danksearch: %s\n", line)
|
||||
}
|
||||
if err := distros.SetupDsearchService(context.Background(), logFunc); err != nil {
|
||||
// Non-fatal, matching greeter behavior
|
||||
fmt.Fprintf(os.Stderr, "Warning: danksearch service setup issue (non-fatal): %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Deploy configurations
|
||||
fmt.Fprintln(os.Stdout, "Deploying configurations...")
|
||||
r.log("Starting configuration deployment")
|
||||
|
||||
deployer := config.NewConfigDeployer(r.logChan)
|
||||
useSystemd := true
|
||||
if distroConfig, exists := distros.Registry[osInfo.Distribution.ID]; exists && distroConfig.Family == distros.FamilyVoid {
|
||||
useSystemd = false
|
||||
}
|
||||
results, err := deployer.DeployConfigurationsSelectiveWithReinstallsAndSystemd(
|
||||
context.Background(),
|
||||
wm,
|
||||
@@ -272,19 +288,31 @@ func (r *Runner) Run() error {
|
||||
}
|
||||
|
||||
// buildDisabledItems computes the set of dependencies that should be skipped
|
||||
// during installation, applying the --include-deps and --exclude-deps filters.
|
||||
// dms-greeter is disabled by default (opt-in), matching TUI behavior.
|
||||
// during installation. Optional components are opt-in (disabled by default),
|
||||
// then re-enabled by the dedicated flags and --include-deps.
|
||||
func (r *Runner) buildDisabledItems(dependencies []deps.Dependency) (map[string]bool, error) {
|
||||
disabledItems := make(map[string]bool)
|
||||
|
||||
// dms-greeter is opt-in (disabled by default), matching TUI behavior
|
||||
for i := range dependencies {
|
||||
if dependencies[i].Name == "dms-greeter" {
|
||||
disabledItems["dms-greeter"] = true
|
||||
break
|
||||
if !dependencies[i].Required {
|
||||
disabledItems[dependencies[i].Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Dedicated flags resolve before include/exclude
|
||||
if r.cfg.DankSearch {
|
||||
if !r.depExists(dependencies, "danksearch") {
|
||||
return nil, fmt.Errorf("--danksearch: not available on this distribution")
|
||||
}
|
||||
delete(disabledItems, "danksearch")
|
||||
}
|
||||
if r.cfg.DankCalendar {
|
||||
if !r.depExists(dependencies, "dankcalendar") {
|
||||
return nil, fmt.Errorf("--dankcalendar: not available on this distribution")
|
||||
}
|
||||
delete(disabledItems, "dankcalendar")
|
||||
}
|
||||
|
||||
// Process --include-deps (enable items that are disabled by default)
|
||||
for _, name := range r.cfg.IncludeDeps {
|
||||
name = strings.TrimSpace(name)
|
||||
|
||||
@@ -342,17 +342,21 @@ func TestConfigReplaceConfigsStoredCorrectly(t *testing.T) {
|
||||
|
||||
func TestBuildDisabledItems(t *testing.T) {
|
||||
dependencies := []deps.Dependency{
|
||||
{Name: "niri", Status: deps.StatusInstalled},
|
||||
{Name: "ghostty", Status: deps.StatusMissing},
|
||||
{Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled},
|
||||
{Name: "niri", Status: deps.StatusInstalled, Required: true},
|
||||
{Name: "ghostty", Status: deps.StatusMissing, Required: true},
|
||||
{Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled, Required: true},
|
||||
{Name: "dms-greeter", Status: deps.StatusMissing},
|
||||
{Name: "waybar", Status: deps.StatusMissing},
|
||||
{Name: "danksearch", Status: deps.StatusMissing},
|
||||
{Name: "dankcalendar", Status: deps.StatusMissing},
|
||||
{Name: "waybar", Status: deps.StatusMissing, Required: true},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
includeDeps []string
|
||||
excludeDeps []string
|
||||
dankSearch bool
|
||||
dankCalendar bool
|
||||
deps []deps.Dependency // nil means use the shared fixture
|
||||
wantErr bool
|
||||
errContains string // substring expected in error message
|
||||
@@ -360,19 +364,20 @@ func TestBuildDisabledItems(t *testing.T) {
|
||||
wantEnabled []string // dep names that should NOT be in disabledItems (extra check)
|
||||
}{
|
||||
{
|
||||
name: "no flags set, dms-greeter disabled by default",
|
||||
wantDisabled: []string{"dms-greeter"},
|
||||
name: "no flags set, optional deps disabled by default",
|
||||
wantDisabled: []string{"dms-greeter", "danksearch", "dankcalendar"},
|
||||
wantEnabled: []string{"niri", "ghostty", "waybar"},
|
||||
},
|
||||
{
|
||||
name: "include dms-greeter enables it",
|
||||
includeDeps: []string{"dms-greeter"},
|
||||
wantEnabled: []string{"dms-greeter"},
|
||||
name: "include dms-greeter enables it",
|
||||
includeDeps: []string{"dms-greeter"},
|
||||
wantEnabled: []string{"dms-greeter"},
|
||||
wantDisabled: []string{"danksearch", "dankcalendar"},
|
||||
},
|
||||
{
|
||||
name: "exclude a regular dep",
|
||||
excludeDeps: []string{"waybar"},
|
||||
wantDisabled: []string{"dms-greeter", "waybar"},
|
||||
wantDisabled: []string{"dms-greeter", "danksearch", "dankcalendar", "waybar"},
|
||||
},
|
||||
{
|
||||
name: "include unknown dep returns error",
|
||||
@@ -399,24 +404,53 @@ func TestBuildDisabledItems(t *testing.T) {
|
||||
wantDisabled: []string{"dms-greeter"},
|
||||
},
|
||||
{
|
||||
name: "whitespace entries are skipped",
|
||||
includeDeps: []string{" ", "dms-greeter"},
|
||||
wantEnabled: []string{"dms-greeter"},
|
||||
name: "whitespace entries are skipped",
|
||||
includeDeps: []string{" ", "dms-greeter"},
|
||||
wantEnabled: []string{"dms-greeter"},
|
||||
wantDisabled: []string{"danksearch", "dankcalendar"},
|
||||
},
|
||||
{
|
||||
name: "no dms-greeter in deps, nothing disabled by default",
|
||||
name: "no optional deps present, nothing disabled by default",
|
||||
deps: []deps.Dependency{
|
||||
{Name: "niri", Status: deps.StatusInstalled},
|
||||
{Name: "niri", Status: deps.StatusInstalled, Required: true},
|
||||
},
|
||||
wantEnabled: []string{"niri"},
|
||||
},
|
||||
{
|
||||
name: "danksearch flag enables it",
|
||||
dankSearch: true,
|
||||
wantEnabled: []string{"danksearch"},
|
||||
wantDisabled: []string{"dms-greeter", "dankcalendar"},
|
||||
},
|
||||
{
|
||||
name: "dankcalendar flag enables it",
|
||||
dankCalendar: true,
|
||||
wantEnabled: []string{"dankcalendar"},
|
||||
wantDisabled: []string{"dms-greeter", "danksearch"},
|
||||
},
|
||||
{
|
||||
name: "danksearch flag when unavailable errors",
|
||||
dankSearch: true,
|
||||
deps: []deps.Dependency{{Name: "niri", Status: deps.StatusInstalled, Required: true}},
|
||||
wantErr: true,
|
||||
errContains: "--danksearch",
|
||||
},
|
||||
{
|
||||
name: "dankcalendar flag when unavailable errors",
|
||||
dankCalendar: true,
|
||||
deps: []deps.Dependency{{Name: "niri", Status: deps.StatusInstalled, Required: true}},
|
||||
wantErr: true,
|
||||
errContains: "--dankcalendar",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := NewRunner(Config{
|
||||
IncludeDeps: tt.includeDeps,
|
||||
ExcludeDeps: tt.excludeDeps,
|
||||
IncludeDeps: tt.includeDeps,
|
||||
ExcludeDeps: tt.excludeDeps,
|
||||
DankSearch: tt.dankSearch,
|
||||
DankCalendar: tt.dankCalendar,
|
||||
})
|
||||
d := tt.deps
|
||||
if d == nil {
|
||||
|
||||
@@ -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,17 +1120,18 @@ func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) {
|
||||
}
|
||||
internalKey := luaKeyComboToInternalKey(kbc)
|
||||
|
||||
action := luaExprToInternalAction(actionExpr)
|
||||
action, isRawLua := luaExprToInternalAction(actionExpr)
|
||||
flags := luaBindOptFlags(optSuffix)
|
||||
description := luaBindOptDescription(optSuffix)
|
||||
if description == "" {
|
||||
description = luaLineTrailingComment(line)
|
||||
}
|
||||
return &hyprlandOverrideBind{
|
||||
Key: internalKey,
|
||||
Action: action,
|
||||
Description: description,
|
||||
Flags: flags,
|
||||
Key: internalKey,
|
||||
Action: action,
|
||||
Description: description,
|
||||
Flags: flags,
|
||||
RawLuaAction: isRawLua,
|
||||
}, true
|
||||
}
|
||||
|
||||
|
||||
@@ -147,9 +147,10 @@ hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"), { descripti
|
||||
func TestWriteLuaBindLineLeavesCustomLuaDispatcherRaw(t *testing.T) {
|
||||
var sb strings.Builder
|
||||
writeLuaBindLine(&sb, &hyprlandOverrideBind{
|
||||
Key: "Super+u",
|
||||
Action: "hl.dsp.no_op()",
|
||||
Description: "Custom Lua",
|
||||
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) {
|
||||
|
||||
@@ -260,15 +260,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
|
||||
}
|
||||
@@ -1006,6 +1006,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)
|
||||
}
|
||||
|
||||
+438
-51
@@ -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",
|
||||
@@ -43,10 +70,11 @@ var includedPamAuthFiles = []string{
|
||||
}
|
||||
|
||||
type AuthSettings struct {
|
||||
EnableFprint bool `json:"enableFprint"`
|
||||
EnableU2f bool `json:"enableU2f"`
|
||||
GreeterEnableFprint bool `json:"greeterEnableFprint"`
|
||||
GreeterEnableU2f bool `json:"greeterEnableU2f"`
|
||||
EnableFprint bool `json:"enableFprint"`
|
||||
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,29 +512,73 @@ 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,
|
||||
}
|
||||
|
||||
resolvedLines, err := resolver.resolveService("login", "", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(resolvedLines) == 0 {
|
||||
return "", fmt.Errorf("no auth directives remained after filtering %s", filepath.Join(pamDir, "login"))
|
||||
}
|
||||
|
||||
hasAuth := false
|
||||
for _, line := range resolvedLines {
|
||||
func resolvedLinesHaveAuth(lines []string) bool {
|
||||
for _, line := range lines {
|
||||
if pamDirectiveType(strings.TrimSpace(line)) == "auth" {
|
||||
hasAuth = true
|
||||
break
|
||||
return true
|
||||
}
|
||||
}
|
||||
if !hasAuth {
|
||||
return "", fmt.Errorf("no auth directives remained after filtering %s", filepath.Join(pamDir, "login"))
|
||||
return false
|
||||
}
|
||||
|
||||
func (r lockscreenPamResolver) resolveLines() ([]string, error) {
|
||||
var lastErr error
|
||||
|
||||
// Standalone login-like services: an existing one is authoritative.
|
||||
for _, service := range lockscreenPamEntryCandidates {
|
||||
if _, err := r.locate(service); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
lines, err := r.resolveService(service, "", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !resolvedLinesHaveAuth(lines) {
|
||||
return nil, fmt.Errorf("no auth directives remained after filtering %s", service)
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// Shared building blocks for distros without a login service (openSUSE,
|
||||
// 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
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
@@ -494,6 +592,295 @@ 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
|
||||
unknownDirectives []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (r lockscreenPamResolver) analyzePath(path string) lockscreenPamAnalysis {
|
||||
var acc lockscreenPamAnalysis
|
||||
if err := r.analyzeInto(filepath.Clean(path), "", nil, &acc); err != nil {
|
||||
acc.err = err
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
func (r lockscreenPamResolver) analyzeInto(path string, filterType string, stack []string, acc *lockscreenPamAnalysis) error {
|
||||
for _, seen := range stack {
|
||||
if seen == path {
|
||||
chain := append(append([]string{}, stack...), path)
|
||||
display := make([]string, 0, len(chain))
|
||||
for _, item := range chain {
|
||||
display = append(display, filepath.Base(item))
|
||||
}
|
||||
return fmt.Errorf("cyclic PAM include detected: %s", strings.Join(display, " -> "))
|
||||
}
|
||||
}
|
||||
|
||||
data, err := r.readFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read PAM file %s: %w", path, err)
|
||||
}
|
||||
|
||||
for _, rawLine := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") {
|
||||
rawLine = strings.TrimRight(rawLine, "\r")
|
||||
trimmed := strings.TrimSpace(rawLine)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
if include, ok := parseLockscreenPamIncludeDirective(trimmed, filterType); ok {
|
||||
lineType := pamDirectiveType(trimmed)
|
||||
if filterType != "" && lineType != "" && lineType != filterType {
|
||||
continue
|
||||
}
|
||||
nestedPath := include.target
|
||||
if filepath.IsAbs(nestedPath) {
|
||||
nestedPath = filepath.Clean(nestedPath)
|
||||
} else {
|
||||
located, err := r.locate(include.target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read PAM file %s: %w", include.target, err)
|
||||
}
|
||||
nestedPath = located
|
||||
}
|
||||
if err := r.analyzeInto(nestedPath, include.filterType, append(stack, path), acc); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
lineType := pamDirectiveType(trimmed)
|
||||
if lineType == "" {
|
||||
acc.unknownDirectives = append(acc.unknownDirectives, trimmed)
|
||||
continue
|
||||
}
|
||||
if filterType != "" && lineType != filterType {
|
||||
continue
|
||||
}
|
||||
|
||||
acc.lines = append(acc.lines, rawLine)
|
||||
if lineType == "auth" {
|
||||
acc.hasAuth = true
|
||||
}
|
||||
|
||||
foundModule := false
|
||||
for _, field := range strings.Fields(trimmed) {
|
||||
if strings.HasPrefix(field, "#") {
|
||||
break
|
||||
}
|
||||
if strings.Contains(field, "pam_fprintd") {
|
||||
acc.inlineFingerprint = true
|
||||
}
|
||||
if strings.Contains(field, "pam_u2f") {
|
||||
acc.inlineU2f = true
|
||||
}
|
||||
if !foundModule && strings.HasSuffix(field, ".so") {
|
||||
acc.modules = append(acc.modules, field)
|
||||
foundModule = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Earlier base dir wins per name (libpam precedence).
|
||||
func ListLockscreenPamServices() []LockscreenPamServiceInfo {
|
||||
return listLockscreenPamServices(lockscreenPamBaseDirs, os.ReadFile)
|
||||
}
|
||||
|
||||
func listLockscreenPamServices(baseDirs []string, readFile func(string) ([]byte, error)) []LockscreenPamServiceInfo {
|
||||
resolver := lockscreenPamResolver{baseDirs: baseDirs, readFile: readFile}
|
||||
out := make([]LockscreenPamServiceInfo, 0, len(lockscreenPamCandidateServices))
|
||||
for _, name := range lockscreenPamCandidateServices {
|
||||
path, err := resolver.locate(name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
info := LockscreenPamServiceInfo{
|
||||
Name: name,
|
||||
Dir: filepath.Dir(path),
|
||||
Path: path,
|
||||
}
|
||||
if analysis := resolver.analyzePath(path); analysis.err == nil {
|
||||
info.HasAuth = analysis.hasAuth
|
||||
info.InlineFingerprint = analysis.inlineFingerprint
|
||||
info.InlineU2f = analysis.inlineU2f
|
||||
}
|
||||
out = append(out, info)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ValidateLockscreenPamService(name string) LockscreenPamValidation {
|
||||
return validateLockscreenPam(name, "", defaultValidateDeps())
|
||||
}
|
||||
|
||||
func ValidateLockscreenPamPath(path string) LockscreenPamValidation {
|
||||
return validateLockscreenPam("", path, defaultValidateDeps())
|
||||
}
|
||||
|
||||
func validateLockscreenPam(serviceName string, path string, deps lockscreenPamValidateDeps) LockscreenPamValidation {
|
||||
result := LockscreenPamValidation{
|
||||
MissingModules: []string{},
|
||||
Warnings: []string{},
|
||||
Errors: []string{},
|
||||
}
|
||||
resolver := lockscreenPamResolver{baseDirs: deps.baseDirs, readFile: deps.readFile}
|
||||
|
||||
var analysis lockscreenPamAnalysis
|
||||
if path != "" {
|
||||
result.Path = path
|
||||
analysis = resolver.analyzePath(path)
|
||||
} else {
|
||||
located, err := resolver.locate(serviceName)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("PAM service %q not found: %v", serviceName, err))
|
||||
return result
|
||||
}
|
||||
result.Path = located
|
||||
analysis = resolver.analyzePath(located)
|
||||
}
|
||||
|
||||
if analysis.err != nil {
|
||||
result.Errors = append(result.Errors, analysis.err.Error())
|
||||
return result
|
||||
}
|
||||
|
||||
result.HasAuth = analysis.hasAuth
|
||||
result.InlineFingerprint = analysis.inlineFingerprint
|
||||
result.InlineU2f = analysis.inlineU2f
|
||||
|
||||
if !analysis.hasAuth {
|
||||
result.Errors = append(result.Errors, "no auth directives found after include resolution")
|
||||
}
|
||||
|
||||
for _, directive := range analysis.unknownDirectives {
|
||||
result.Warnings = append(result.Warnings, "unsupported PAM directive (libpam may still handle it at runtime): "+directive)
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, ref := range analysis.modules {
|
||||
name := filepath.Base(ref)
|
||||
if seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
if moduleReferenceExists(ref, deps) {
|
||||
continue
|
||||
}
|
||||
result.MissingModules = append(result.MissingModules, name)
|
||||
result.Warnings = append(result.Warnings, "referenced PAM module not found: "+name)
|
||||
}
|
||||
|
||||
if analysis.inlineFingerprint {
|
||||
result.Warnings = append(result.Warnings, "pam_fprintd is present in the resolved stack; may double-prompt with DMS's separate fingerprint context")
|
||||
}
|
||||
if analysis.inlineU2f {
|
||||
result.Warnings = append(result.Warnings, "pam_u2f is present in the resolved stack; may double-prompt with DMS's separate U2F context")
|
||||
}
|
||||
|
||||
result.Valid = len(result.Errors) == 0
|
||||
return result
|
||||
}
|
||||
|
||||
func moduleReferenceExists(ref string, deps lockscreenPamValidateDeps) bool {
|
||||
if filepath.IsAbs(ref) {
|
||||
_, err := deps.stat(ref)
|
||||
return err == nil
|
||||
}
|
||||
return deps.pamModuleExists(ref)
|
||||
}
|
||||
|
||||
const UserLockscreenPamService = "dankshell"
|
||||
|
||||
func UserLockscreenPamDir() string {
|
||||
return filepath.Join(utils.XDGStateHome(), "DankMaterialShell", "pam")
|
||||
}
|
||||
|
||||
// WriteUserLockscreenPamConfig resolves the distro's real auth stack into a
|
||||
// self-contained lock-screen service under the user state dir, unprivileged
|
||||
// (reads world-readable PAM dirs, writes the user's own state dir). Rewrites
|
||||
// only on change to avoid inotify churn. Returns the written path.
|
||||
func WriteUserLockscreenPamConfig(logFunc func(string)) (string, error) {
|
||||
content, err := buildManagedLockscreenPamContent(lockscreenPamBaseDirs, os.ReadFile)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve system PAM auth stack: %w", err)
|
||||
}
|
||||
|
||||
dir := UserLockscreenPamDir()
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("failed to create %s: %w", dir, err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, UserLockscreenPamService)
|
||||
if existing, err := os.ReadFile(path); err == nil && string(existing) == content {
|
||||
return path, nil
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
return "", fmt.Errorf("failed to write %s: %w", path, err)
|
||||
}
|
||||
|
||||
if logFunc != nil {
|
||||
logFunc("✓ Wrote lock-screen PAM config " + path)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func buildManagedLockscreenU2FPamContent() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("#%PAM-1.0\n")
|
||||
@@ -522,7 +909,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 +1112,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()
|
||||
|
||||
@@ -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,223 @@ func TestRemoveManagedGreeterPamBlockWithDeps(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *pamTestEnv) validateDeps() lockscreenPamValidateDeps {
|
||||
return lockscreenPamValidateDeps{
|
||||
baseDirs: []string{e.pamDir},
|
||||
readFile: os.ReadFile,
|
||||
stat: os.Stat,
|
||||
pamModuleExists: func(module string) bool { return e.availableModules[module] },
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLockscreenPamServices(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("dedupes by name with earlier base dir winning", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
etcDir := t.TempDir()
|
||||
vendorDir := t.TempDir()
|
||||
// login exists in both dirs; system-auth only in the vendor dir.
|
||||
writeTestFile(t, filepath.Join(etcDir, "login"), "#%PAM-1.0\nauth required pam_unix.so\naccount required pam_unix.so\n")
|
||||
writeTestFile(t, filepath.Join(vendorDir, "login"), "#%PAM-1.0\nauth required pam_deny.so\n")
|
||||
writeTestFile(t, filepath.Join(vendorDir, "system-auth"), "#%PAM-1.0\nauth sufficient pam_unix.so\naccount required pam_unix.so\n")
|
||||
|
||||
services := listLockscreenPamServices([]string{etcDir, vendorDir}, os.ReadFile)
|
||||
if len(services) != 2 {
|
||||
t.Fatalf("expected 2 services (login, system-auth), got %d: %+v", len(services), services)
|
||||
}
|
||||
byName := map[string]LockscreenPamServiceInfo{}
|
||||
for _, s := range services {
|
||||
byName[s.Name] = s
|
||||
}
|
||||
login, ok := byName["login"]
|
||||
if !ok {
|
||||
t.Fatalf("expected login service, got %+v", services)
|
||||
}
|
||||
if login.Dir != etcDir || login.Path != filepath.Join(etcDir, "login") {
|
||||
t.Fatalf("expected login to resolve in earlier dir %s, got dir=%s path=%s", etcDir, login.Dir, login.Path)
|
||||
}
|
||||
if !login.HasAuth {
|
||||
t.Fatalf("expected login to report hasAuth")
|
||||
}
|
||||
if byName["system-auth"].Dir != vendorDir {
|
||||
t.Fatalf("expected system-auth to resolve in vendor dir, got %s", byName["system-auth"].Dir)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("include resolution sets hasAuth and detects inline fprintd/u2f", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
|
||||
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so\nauth sufficient pam_fprintd.so\nauth sufficient pam_u2f.so cue\naccount required pam_unix.so\n")
|
||||
|
||||
services := listLockscreenPamServices([]string{env.pamDir}, os.ReadFile)
|
||||
var login LockscreenPamServiceInfo
|
||||
for _, s := range services {
|
||||
if s.Name == "login" {
|
||||
login = s
|
||||
}
|
||||
}
|
||||
if !login.HasAuth {
|
||||
t.Fatalf("expected hasAuth via resolved include")
|
||||
}
|
||||
if !login.InlineFingerprint || !login.InlineU2f {
|
||||
t.Fatalf("expected inline fingerprint and u2f detection, got %+v", login)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateLockscreenPam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("valid service with resolved auth", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.availableModules["pam_unix.so"] = true
|
||||
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
|
||||
env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so\naccount required pam_unix.so\n")
|
||||
|
||||
result := validateLockscreenPam("login", "", env.validateDeps())
|
||||
if !result.Valid {
|
||||
t.Fatalf("expected valid result, got %+v", result)
|
||||
}
|
||||
if !result.HasAuth {
|
||||
t.Fatalf("expected hasAuth true")
|
||||
}
|
||||
if len(result.Errors) != 0 {
|
||||
t.Fatalf("expected no errors, got %v", result.Errors)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("path outside base dirs is read directly", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.availableModules["pam_unix.so"] = true
|
||||
outside := filepath.Join(t.TempDir(), "custom-pam")
|
||||
writeTestFile(t, outside, "#%PAM-1.0\nauth sufficient pam_unix.so\naccount required pam_unix.so\n")
|
||||
|
||||
result := validateLockscreenPam("", outside, env.validateDeps())
|
||||
if !result.Valid || result.Path != outside {
|
||||
t.Fatalf("expected valid result for outside path, got %+v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing module produces warning and missingModules but stays valid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.availableModules["pam_unix.so"] = true
|
||||
env.writePamFile(t, "system-auth", "#%PAM-1.0\nauth sufficient pam_unix.so\nauth required pam_absent.so\naccount required pam_unix.so\n")
|
||||
|
||||
result := validateLockscreenPam("system-auth", "", env.validateDeps())
|
||||
if !result.Valid {
|
||||
t.Fatalf("expected valid despite missing module, got %+v", result)
|
||||
}
|
||||
if len(result.MissingModules) != 1 || result.MissingModules[0] != "pam_absent.so" {
|
||||
t.Fatalf("expected missing pam_absent.so, got %v", result.MissingModules)
|
||||
}
|
||||
if !containsSubstr(result.Warnings, "pam_absent.so") {
|
||||
t.Fatalf("expected warning about missing module, got %v", result.Warnings)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown directive is a warning not an error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.availableModules["pam_unix.so"] = true
|
||||
env.availableModules["pam_foo.so"] = true
|
||||
env.writePamFile(t, "system-auth", "#%PAM-1.0\nauth sufficient pam_unix.so\nbadtype required pam_foo.so\naccount required pam_unix.so\n")
|
||||
|
||||
result := validateLockscreenPam("system-auth", "", env.validateDeps())
|
||||
if !result.Valid {
|
||||
t.Fatalf("expected valid with unknown directive, got %+v", result)
|
||||
}
|
||||
if len(result.Errors) != 0 {
|
||||
t.Fatalf("expected no errors, got %v", result.Errors)
|
||||
}
|
||||
if !containsSubstr(result.Warnings, "unsupported PAM directive") {
|
||||
t.Fatalf("expected unsupported directive warning, got %v", result.Warnings)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cyclic include is an error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\n")
|
||||
env.writePamFile(t, "system-auth", "auth include login\n")
|
||||
|
||||
result := validateLockscreenPam("login", "", env.validateDeps())
|
||||
if result.Valid {
|
||||
t.Fatalf("expected invalid on cyclic include, got %+v", result)
|
||||
}
|
||||
if !containsSubstr(result.Errors, "cyclic PAM include detected") {
|
||||
t.Fatalf("expected cyclic include error, got %v", result.Errors)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no auth directives is an error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.availableModules["pam_unix.so"] = true
|
||||
env.writePamFile(t, "system-auth", "#%PAM-1.0\naccount required pam_unix.so\n")
|
||||
|
||||
result := validateLockscreenPam("system-auth", "", env.validateDeps())
|
||||
if result.Valid {
|
||||
t.Fatalf("expected invalid when no auth directives, got %+v", result)
|
||||
}
|
||||
if !containsSubstr(result.Errors, "no auth directives") {
|
||||
t.Fatalf("expected no-auth error, got %v", result.Errors)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing file is an error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
result := validateLockscreenPam("", filepath.Join(env.pamDir, "does-not-exist"), env.validateDeps())
|
||||
if result.Valid || len(result.Errors) == 0 {
|
||||
t.Fatalf("expected invalid for missing file, got %+v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("inline fingerprint and u2f produce warnings", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.availableModules["pam_unix.so"] = true
|
||||
env.availableModules["pam_fprintd.so"] = true
|
||||
env.availableModules["pam_u2f.so"] = true
|
||||
env.writePamFile(t, "system-auth", "#%PAM-1.0\nauth sufficient pam_unix.so\nauth sufficient pam_fprintd.so\nauth sufficient pam_u2f.so cue\naccount required pam_unix.so\n")
|
||||
|
||||
result := validateLockscreenPam("system-auth", "", env.validateDeps())
|
||||
if !result.Valid {
|
||||
t.Fatalf("expected valid, got %+v", result)
|
||||
}
|
||||
if !result.InlineFingerprint || !result.InlineU2f {
|
||||
t.Fatalf("expected inline flags set, got %+v", result)
|
||||
}
|
||||
if !containsSubstr(result.Warnings, "pam_fprintd") || !containsSubstr(result.Warnings, "pam_u2f") {
|
||||
t.Fatalf("expected double-prompt warnings, got %v", result.Warnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func containsSubstr(items []string, substr string) bool {
|
||||
for _, item := range items {
|
||||
if strings.Contains(item, substr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestSyncAuthConfigWithDeps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -629,7 +1057,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 +1065,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,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 }
|
||||
@@ -178,9 +178,13 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return &CaptureResult{
|
||||
@@ -188,6 +192,7 @@ func (r *RegionSelector) Run() (*CaptureResult, bool, error) {
|
||||
Region: r.result,
|
||||
YInverted: yInverted,
|
||||
Format: format,
|
||||
Scale: scale,
|
||||
}, false, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -255,6 +270,7 @@ func (s *Screenshoter) captureMangoWindow(output *WaylandOutput, region Region,
|
||||
Region: region,
|
||||
YInverted: false,
|
||||
Format: result.Format,
|
||||
Scale: scale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -430,6 +446,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 +519,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 +622,7 @@ func (s *Screenshoter) captureAndCrop(output *WaylandOutput, region Region) (*Ca
|
||||
Region: region,
|
||||
YInverted: false,
|
||||
Format: result.Format,
|
||||
Scale: scale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -612,16 +631,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 +670,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 +684,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 +736,7 @@ func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, r
|
||||
Region: region,
|
||||
YInverted: false,
|
||||
Format: result.Format,
|
||||
Scale: scale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -747,12 +754,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 +782,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 +791,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
|
||||
}
|
||||
|
||||
|
||||
@@ -138,20 +138,22 @@ func handleCopyEntry(conn net.Conn, req models.Request, m *Manager) {
|
||||
return
|
||||
}
|
||||
|
||||
filePath := m.EntryToFile(entry)
|
||||
if filePath != "" {
|
||||
if err := m.CopyFile(filePath); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
if entry.AltMimeType == "" {
|
||||
filePath := m.EntryToFile(entry)
|
||||
if filePath != "" {
|
||||
if err := m.CopyFile(filePath); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
models.Respond(conn, req.ID, map[string]any{
|
||||
"success": true,
|
||||
"filePath": filePath,
|
||||
})
|
||||
return
|
||||
}
|
||||
models.Respond(conn, req.ID, map[string]any{
|
||||
"success": true,
|
||||
"filePath": filePath,
|
||||
})
|
||||
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
|
||||
@@ -366,11 +408,13 @@ func (m *Manager) storeClipboardEntry(data []byte, mimeType string) {
|
||||
}
|
||||
|
||||
entry := Entry{
|
||||
Data: data,
|
||||
MimeType: mimeType,
|
||||
Size: len(data),
|
||||
Timestamp: time.Now(),
|
||||
IsImage: m.isImageMimeType(mimeType),
|
||||
Data: data,
|
||||
MimeType: mimeType,
|
||||
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/")
|
||||
}
|
||||
@@ -929,13 +1028,15 @@ func (m *Manager) CreateHistoryEntryFromPinned(pinnedEntry *Entry) error {
|
||||
|
||||
// Create a new unpinned entry with the same data
|
||||
newEntry := Entry{
|
||||
Data: pinnedEntry.Data,
|
||||
MimeType: pinnedEntry.MimeType,
|
||||
Size: pinnedEntry.Size,
|
||||
Timestamp: time.Now(),
|
||||
IsImage: pinnedEntry.IsImage,
|
||||
Preview: pinnedEntry.Preview,
|
||||
Pinned: false,
|
||||
Data: pinnedEntry.Data,
|
||||
MimeType: pinnedEntry.MimeType,
|
||||
Size: pinnedEntry.Size,
|
||||
Timestamp: time.Now(),
|
||||
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
|
||||
}
|
||||
|
||||
return string(fullEntry.Data), nil
|
||||
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()
|
||||
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
|
||||
dbusConn, err := m.dbusConnForFlatpak()
|
||||
if err != nil {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -95,15 +95,17 @@ type SearchResult struct {
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
ID uint64 `json:"id"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Preview string `json:"preview"`
|
||||
Size int `json:"size"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
IsImage bool `json:"isImage"`
|
||||
Hash uint64 `json:"hash,omitempty"`
|
||||
Pinned bool `json:"pinned"`
|
||||
ID uint64 `json:"id"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Preview string `json:"preview"`
|
||||
Size int `json:"size"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
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
|
||||
|
||||
dbusConn *dbus.Conn
|
||||
// lazily created by dbusConnForFlatpak under dbusConnMutex
|
||||
dbusConn *dbus.Conn
|
||||
dbusConnMutex sync.Mutex
|
||||
}
|
||||
|
||||
func (m *Manager) GetState() State {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -22,13 +22,14 @@ type ConnectionStateChecker interface {
|
||||
}
|
||||
|
||||
type IWDAgent struct {
|
||||
conn *dbus.Conn
|
||||
objPath dbus.ObjectPath
|
||||
prompts PromptBroker
|
||||
onUserCanceled func()
|
||||
onPromptRetry func(ssid string)
|
||||
lastRequestSSID string
|
||||
stateChecker ConnectionStateChecker
|
||||
conn *dbus.Conn
|
||||
objPath dbus.ObjectPath
|
||||
prompts PromptBroker
|
||||
onUserCanceled func()
|
||||
onPromptRetry func(ssid string)
|
||||
takePendingSecret func(ssid string) (string, bool)
|
||||
lastRequestSSID string
|
||||
stateChecker ConnectionStateChecker
|
||||
}
|
||||
|
||||
const iwdAgentIntrospectXML = `
|
||||
@@ -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 {
|
||||
a.onPromptRetry(ssid)
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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,57 +603,49 @@ func (b *NetworkManagerBackend) handleDeviceRemoved(devicePath dbus.ObjectPath)
|
||||
)
|
||||
}
|
||||
|
||||
for iface, info := range b.ethernetDevices {
|
||||
if info.device.GetPath() == devicePath {
|
||||
delete(b.ethernetDevices, iface)
|
||||
|
||||
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
|
||||
break
|
||||
}
|
||||
if _, remaining, found := b.removeEthernetDeviceByPath(devicePath); found {
|
||||
if b.ethernetDevice != nil {
|
||||
dev := b.ethernetDevice.(gonetworkmanager.Device)
|
||||
if dev.GetPath() == devicePath {
|
||||
b.ethernetDevice = nil
|
||||
for _, r := range remaining {
|
||||
b.ethernetDevice = r.device
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
b.updateAllEthernetDevices()
|
||||
b.updateEthernetState()
|
||||
b.listEthernetConnections()
|
||||
b.updatePrimaryConnection()
|
||||
|
||||
if b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
b.updateAllEthernetDevices()
|
||||
b.updateEthernetState()
|
||||
b.listEthernetConnections()
|
||||
b.updatePrimaryConnection()
|
||||
|
||||
if b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for iface, info := range b.wifiDevices {
|
||||
if info.device.GetPath() == devicePath {
|
||||
delete(b.wifiDevices, iface)
|
||||
|
||||
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
|
||||
break
|
||||
}
|
||||
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 _, r := range remaining {
|
||||
b.wifiDevice = r.device
|
||||
b.wifiDev = r.wireless
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
b.updateAllWiFiDevices()
|
||||
b.updateWiFiState()
|
||||
|
||||
if b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
b.updateAllWiFiDevices()
|
||||
b.updateWiFiState()
|
||||
|
||||
if b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
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
|
||||
|
||||
@@ -973,7 +973,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 +995,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 +1047,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 +1211,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 +1224,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) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -41,7 +42,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||
)
|
||||
|
||||
const APIVersion = 27
|
||||
const APIVersion = 28
|
||||
|
||||
var CLIVersion = "dev"
|
||||
|
||||
@@ -398,6 +399,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 +421,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"}
|
||||
|
||||
@@ -581,6 +598,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 {
|
||||
@@ -1249,10 +1271,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 {
|
||||
|
||||
@@ -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 {
|
||||
return privilegedArgv(opts, "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "upgrade", "-y")
|
||||
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 {
|
||||
|
||||
@@ -50,7 +50,11 @@ func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine
|
||||
}
|
||||
|
||||
func pacmanUpgradeArgv(opts UpgradeOptions) []string {
|
||||
return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed")
|
||||
argv := []string{"pacman", "-Syu", "--noconfirm", "--needed"}
|
||||
if len(opts.Ignored) > 0 {
|
||||
argv = append(argv, "--ignore", strings.Join(opts.Ignored, ","))
|
||||
}
|
||||
return privilegedArgv(opts, argv...)
|
||||
}
|
||||
|
||||
type archHelperBackend struct {
|
||||
@@ -99,23 +103,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})
|
||||
}
|
||||
|
||||
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 +256,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 {
|
||||
return privilegedArgv(opts, "zypper", "--non-interactive", "update")
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ func handleUpgrade(conn net.Conn, req models.Request, m *Manager) {
|
||||
DryRun: params.BoolOpt(req.Params, "dry", false),
|
||||
CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
|
||||
Terminal: params.StringOpt(req.Params, "terminal", ""),
|
||||
Ignored: stringSliceOpt(req.Params, "ignored"),
|
||||
}
|
||||
if err := m.Upgrade(opts); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
@@ -53,3 +54,21 @@ func handleUpgrade(conn net.Conn, req models.Request, m *Manager) {
|
||||
}
|
||||
models.Respond(conn, req.ID, m.GetState())
|
||||
}
|
||||
|
||||
func stringSliceOpt(p map[string]any, key string) []string {
|
||||
val, ok := params.Any(p, key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
arr, ok := val.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, v := range arr {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ const (
|
||||
minIntervalSeconds = 5 * 60
|
||||
recentLogCapacity = 200
|
||||
checkTimeout = 5 * time.Minute
|
||||
retryIntervalSeconds = 5 * 60
|
||||
upgradeTimeout = 30 * time.Minute
|
||||
postUpgradeCompleteDelay = 3 * time.Second
|
||||
)
|
||||
@@ -150,7 +151,7 @@ func (m *Manager) Refresh(opts RefreshOptions) {
|
||||
m.refreshSerial.Unlock()
|
||||
return
|
||||
}
|
||||
m.runRefresh(context.Background())
|
||||
m.runRefresh(context.Background(), true)
|
||||
}
|
||||
|
||||
func (m *Manager) Upgrade(opts UpgradeOptions) error {
|
||||
@@ -237,12 +238,12 @@ func (m *Manager) scheduler() {
|
||||
case <-m.wakeSched:
|
||||
t.Stop()
|
||||
case <-t.C:
|
||||
m.runRefresh(context.Background())
|
||||
m.runRefresh(context.Background(), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) runRefresh(parent context.Context) {
|
||||
func (m *Manager) runRefresh(parent context.Context, manual bool) {
|
||||
m.refreshSerial.Lock()
|
||||
defer m.refreshSerial.Unlock()
|
||||
|
||||
@@ -284,27 +285,43 @@ func (m *Manager) runRefresh(parent context.Context) {
|
||||
now := time.Now().Unix()
|
||||
m.mu.Lock()
|
||||
m.state.LastCheckUnix = now
|
||||
m.state.Packages = m.state.Packages[:0]
|
||||
prev := m.state.Packages
|
||||
next := make([]Package, 0, len(prev))
|
||||
var firstErr error
|
||||
for i, r := range results {
|
||||
if r.err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("%s: %w", backends[i].ID(), r.err)
|
||||
}
|
||||
// Retain a failed backend's last known packages so a transient failure doesn't wipe the list.
|
||||
for _, p := range prev {
|
||||
if p.Backend == backends[i].ID() {
|
||||
next = append(next, p)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
m.state.Packages = append(m.state.Packages, r.pkgs...)
|
||||
next = append(next, r.pkgs...)
|
||||
}
|
||||
m.state.Count = len(m.state.Packages)
|
||||
m.state.Packages = next
|
||||
m.state.Count = len(next)
|
||||
m.state.NextCheckUnix = now + int64(m.state.IntervalSeconds)
|
||||
if firstErr != nil {
|
||||
m.state.Phase = PhaseError
|
||||
m.state.Error = &ErrorInfo{Code: ErrCodeBackendFailed, Message: firstErr.Error()}
|
||||
} else {
|
||||
switch {
|
||||
case firstErr == nil:
|
||||
m.state.Phase = PhaseIdle
|
||||
m.state.LastSuccessUnix = now
|
||||
case manual:
|
||||
m.state.Phase = PhaseError
|
||||
m.state.Error = &ErrorInfo{Code: ErrCodeBackendFailed, Message: firstErr.Error()}
|
||||
default:
|
||||
// Background checks fail silently and retry sooner; only manual refreshes surface errors.
|
||||
m.state.Phase = PhaseIdle
|
||||
retry := min(int64(m.state.IntervalSeconds), retryIntervalSeconds)
|
||||
m.state.NextCheckUnix = now + retry
|
||||
log.Warnf("[sysupdate] background check failed, retrying in %ds: %v", retry, firstErr)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
m.wake()
|
||||
m.markDirty()
|
||||
}
|
||||
|
||||
@@ -328,10 +345,15 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
|
||||
opts.Targets = append([]Package(nil), m.state.Packages...)
|
||||
m.mu.RUnlock()
|
||||
}
|
||||
opts.Targets = dropIgnoredTargets(opts.Targets, opts.Ignored)
|
||||
|
||||
backends := upgradeBackends(m.selection, opts)
|
||||
if len(backends) == 0 {
|
||||
m.setError(ErrCodeNoBackend, "no backend selected for upgrade")
|
||||
if len(opts.Targets) > 0 {
|
||||
m.setError(ErrCodeNoBackend, "all pending updates are excluded by current settings (AUR/Flatpak disabled)")
|
||||
} else {
|
||||
m.setError(ErrCodeNoBackend, "no backend selected for upgrade")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -429,6 +451,24 @@ func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) {
|
||||
m.markDirty()
|
||||
}
|
||||
|
||||
func dropIgnoredTargets(targets []Package, ignored []string) []Package {
|
||||
if len(ignored) == 0 {
|
||||
return targets
|
||||
}
|
||||
skip := make(map[string]bool, len(ignored))
|
||||
for _, name := range ignored {
|
||||
skip[name] = true
|
||||
}
|
||||
out := targets[:0]
|
||||
for _, p := range targets {
|
||||
if skip[p.Name] {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func upgradeBackends(sel Selection, opts UpgradeOptions) []Backend {
|
||||
var out []Backend
|
||||
if sel.System != nil {
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
package sysupdate
|
||||
|
||||
import "regexp"
|
||||
|
||||
var safePkgName = regexp.MustCompile(`^[A-Za-z0-9@._+:-]+$`)
|
||||
|
||||
// shellSafeNames drops names unsafe to interpolate into the apt/zypper sh -c scripts.
|
||||
func shellSafeNames(names []string) []string {
|
||||
out := make([]string, 0, len(names))
|
||||
for _, n := range names {
|
||||
if safePkgName.MatchString(n) {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func BackendHasTargets(b Backend, targets []Package, includeAUR, includeFlatpak bool) bool {
|
||||
if b == nil || len(targets) == 0 {
|
||||
return false
|
||||
|
||||
@@ -81,6 +81,7 @@ type UpgradeOptions struct {
|
||||
CustomCommand string
|
||||
Terminal string
|
||||
Targets []Package
|
||||
Ignored []string
|
||||
}
|
||||
|
||||
type RefreshOptions struct {
|
||||
|
||||
@@ -2,6 +2,7 @@ package sysupdate
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -35,19 +36,55 @@ func TestUpgradeCommandBuilders(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "aur helper full update with aur",
|
||||
got: archHelperUpgradeArgv("paru", true),
|
||||
got: archHelperUpgradeArgv("paru", true, nil),
|
||||
want: []string{"paru", "-Syu", "--noconfirm", "--needed"},
|
||||
},
|
||||
{
|
||||
name: "aur helper repo-only full update",
|
||||
got: archHelperUpgradeArgv("yay", false),
|
||||
got: archHelperUpgradeArgv("yay", false, nil),
|
||||
want: []string{"yay", "-Syu", "--noconfirm", "--needed", "--repo"},
|
||||
},
|
||||
{
|
||||
name: "aur helper with ignored packages",
|
||||
got: archHelperUpgradeArgv("paru", true, []string{"linux", "bad;name", "discord"}),
|
||||
want: []string{"paru", "-Syu", "--noconfirm", "--needed", "--ignore", "linux,discord"},
|
||||
},
|
||||
{
|
||||
name: "pacman with ignored packages",
|
||||
got: pacmanUpgradeArgv(UpgradeOptions{Ignored: []string{"linux"}}),
|
||||
want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed", "--ignore", "linux"},
|
||||
},
|
||||
{
|
||||
name: "dnf with ignored packages",
|
||||
got: dnfUpgradeArgv("dnf5", UpgradeOptions{Ignored: []string{"kernel", "mesa"}}),
|
||||
want: []string{"pkexec", "dnf5", "upgrade", "--refresh", "-y", "--exclude=kernel,mesa"},
|
||||
},
|
||||
{
|
||||
name: "apt without ignored uses plain upgrade",
|
||||
got: aptUpgradeArgv("apt-get", UpgradeOptions{}),
|
||||
want: []string{"pkexec", "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", "apt-get", "upgrade", "-y"},
|
||||
},
|
||||
{
|
||||
name: "zypper without ignored uses plain update",
|
||||
got: zypperUpgradeArgv(UpgradeOptions{}),
|
||||
want: []string{"pkexec", "zypper", "--non-interactive", "update"},
|
||||
},
|
||||
{
|
||||
name: "flatpak full update",
|
||||
got: flatpakUpgradeArgv(),
|
||||
got: flatpakUpgradeArgv(UpgradeOptions{}),
|
||||
want: []string{"flatpak", "update", "-y", "--noninteractive"},
|
||||
},
|
||||
{
|
||||
name: "flatpak update with ignored targets refs",
|
||||
got: flatpakUpgradeArgv(UpgradeOptions{
|
||||
Ignored: []string{"org.mozilla.firefox"},
|
||||
Targets: []Package{
|
||||
{Name: "Discord", Repo: RepoFlatpak, Ref: "com.discordapp.Discord//stable"},
|
||||
{Name: "bash", Repo: RepoSystem, Backend: "apt"},
|
||||
},
|
||||
}),
|
||||
want: []string{"flatpak", "update", "-y", "--noninteractive", "com.discordapp.Discord//stable"},
|
||||
},
|
||||
{
|
||||
name: "rpm-ostree upgrade",
|
||||
got: rpmOstreeUpgradeArgv(UpgradeOptions{}),
|
||||
@@ -69,6 +106,45 @@ func TestUpgradeCommandBuilders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAptUpgradeArgvHoldsIgnored(t *testing.T) {
|
||||
argv := aptUpgradeArgv("apt-get", UpgradeOptions{Ignored: []string{"linux-image-generic", "bad;name"}})
|
||||
if len(argv) < 2 || argv[len(argv)-2] != "-c" {
|
||||
t.Fatalf("expected sh -c script, got %#v", argv)
|
||||
}
|
||||
script := argv[len(argv)-1]
|
||||
if !strings.Contains(script, "apt-mark hold") || !strings.Contains(script, "apt-mark unhold") {
|
||||
t.Fatalf("hold script missing hold/unhold: %q", script)
|
||||
}
|
||||
if !strings.Contains(script, "linux-image-generic") {
|
||||
t.Fatalf("hold script missing ignored package: %q", script)
|
||||
}
|
||||
if strings.Contains(script, "bad;name") {
|
||||
t.Fatalf("hold script must drop unsafe name: %q", script)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZypperUpgradeArgvLocksIgnored(t *testing.T) {
|
||||
argv := zypperUpgradeArgv(UpgradeOptions{Ignored: []string{"kernel-default"}})
|
||||
if len(argv) < 2 || argv[len(argv)-2] != "-c" {
|
||||
t.Fatalf("expected sh -c script, got %#v", argv)
|
||||
}
|
||||
script := argv[len(argv)-1]
|
||||
if !strings.Contains(script, "zypper --non-interactive al") || !strings.Contains(script, "zypper --non-interactive rl") {
|
||||
t.Fatalf("lock script missing add/remove lock: %q", script)
|
||||
}
|
||||
if !strings.Contains(script, "kernel-default") {
|
||||
t.Fatalf("lock script missing ignored package: %q", script)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellSafeNames(t *testing.T) {
|
||||
got := shellSafeNames([]string{"linux", "gtk+", "bad name", "rm -rf /", "org.mozilla.firefox", "a;b"})
|
||||
want := []string{"linux", "gtk+", "org.mozilla.firefox"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("shellSafeNames() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendHasTargetsRespectsBackendAndOptions(t *testing.T) {
|
||||
targets := []Package{
|
||||
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf5"},
|
||||
|
||||
@@ -23,6 +23,9 @@ import (
|
||||
|
||||
const animKelvinStep = 25
|
||||
|
||||
// Go timers freeze during suspend; cap sleeps so wall-clock deadlines can't be missed.
|
||||
const maxScheduleWait = 5 * time.Minute
|
||||
|
||||
func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error) {
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, err
|
||||
@@ -304,8 +307,8 @@ func (m *Manager) setupControlHandlers(state *outputState, control *wlr_gamma_co
|
||||
out.rampSize = size
|
||||
out.failed = false
|
||||
out.retryCount = 0
|
||||
out.lastTemp = 0
|
||||
}
|
||||
m.lastAppliedTemp = 0
|
||||
m.applyCurrentTemp("gamma_size")
|
||||
})
|
||||
})
|
||||
@@ -507,6 +510,10 @@ func (m *Manager) recalcSchedule(now time.Time) {
|
||||
config.ManualSunrise.Hour(), config.ManualSunrise.Minute(), config.ManualSunrise.Second(), 0, now.Location())
|
||||
sunset := time.Date(now.Year(), now.Month(), now.Day(),
|
||||
config.ManualSunset.Hour(), config.ManualSunset.Minute(), config.ManualSunset.Second(), 0, now.Location())
|
||||
if !sunset.After(sunrise) {
|
||||
// night start past midnight belongs to the next day
|
||||
sunset = sunset.Add(24 * time.Hour)
|
||||
}
|
||||
times = SunTimes{
|
||||
Dawn: sunrise.Add(-dur),
|
||||
Sunrise: sunrise,
|
||||
@@ -518,6 +525,8 @@ func (m *Manager) recalcSchedule(now time.Time) {
|
||||
lat, lon := m.getLocation()
|
||||
if lat == nil || lon == nil {
|
||||
m.gammaState = StateStatic
|
||||
// stale times from a previous config must not drive applies
|
||||
m.schedule = sunSchedule{}
|
||||
return
|
||||
}
|
||||
times, cond = CalculateSunTimesWithTwilight(*lat, *lon, now, config.ElevationTwilight, config.ElevationDaylight)
|
||||
@@ -611,7 +620,26 @@ func (m *Manager) getSunPosition(now time.Time) float64 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
func shiftTimes(times SunTimes, d time.Duration) SunTimes {
|
||||
return SunTimes{
|
||||
Dawn: times.Dawn.Add(d),
|
||||
Sunrise: times.Sunrise.Add(d),
|
||||
Sunset: times.Sunset.Add(d),
|
||||
Night: times.Night.Add(d),
|
||||
}
|
||||
}
|
||||
|
||||
// activeCycle maps early-morning hours back to yesterday's cycle when the
|
||||
// schedule crosses midnight.
|
||||
func activeCycle(now time.Time, times SunTimes) SunTimes {
|
||||
if now.Before(times.Night.Add(-24 * time.Hour)) {
|
||||
return shiftTimes(times, -24*time.Hour)
|
||||
}
|
||||
return times
|
||||
}
|
||||
|
||||
func (m *Manager) getSunPositionNormal(now time.Time, times SunTimes) float64 {
|
||||
times = activeCycle(now, times)
|
||||
if now.Before(times.Dawn) {
|
||||
return 0.0
|
||||
}
|
||||
@@ -666,7 +694,7 @@ func (m *Manager) getNextDeadline(now time.Time) time.Time {
|
||||
}
|
||||
|
||||
func (m *Manager) getDeadlineNormal(now time.Time, sched sunSchedule) time.Time {
|
||||
times := sched.times
|
||||
times := activeCycle(now, sched.times)
|
||||
switch {
|
||||
case now.Before(times.Dawn):
|
||||
return times.Dawn
|
||||
@@ -737,8 +765,11 @@ func (m *Manager) schedulerLoop() {
|
||||
if enabled {
|
||||
deadline := m.getNextDeadline(now)
|
||||
waitDur = time.Until(deadline)
|
||||
if waitDur < time.Second {
|
||||
switch {
|
||||
case waitDur < time.Second:
|
||||
waitDur = time.Second
|
||||
case waitDur > maxScheduleWait:
|
||||
waitDur = maxScheduleWait
|
||||
}
|
||||
} else {
|
||||
waitDur = 24 * time.Hour
|
||||
@@ -818,8 +849,6 @@ func (m *Manager) applyGamma(temp int) {
|
||||
return
|
||||
case !m.controlsInitialized:
|
||||
return
|
||||
case m.lastAppliedTemp == temp && m.lastAppliedGamma == gamma:
|
||||
return
|
||||
}
|
||||
|
||||
var outs []*outputState
|
||||
@@ -845,6 +874,8 @@ func (m *Manager) applyGamma(temp int) {
|
||||
continue
|
||||
case out.gammaControl == nil:
|
||||
continue
|
||||
case out.lastTemp == temp && out.lastGamma == gamma:
|
||||
continue
|
||||
case !m.outputStillValid(out):
|
||||
continue
|
||||
}
|
||||
@@ -865,19 +896,19 @@ func (m *Manager) applyGamma(temp int) {
|
||||
for _, j := range jobs {
|
||||
err := m.setGammaBytes(j.out, j.data)
|
||||
if err == nil {
|
||||
j.out.lastTemp = temp
|
||||
j.out.lastGamma = gamma
|
||||
continue
|
||||
}
|
||||
log.Warnf("gamma: failed to set output %d: %v", j.out.id, err)
|
||||
j.out.failed = true
|
||||
j.out.rampSize = 0
|
||||
j.out.lastTemp = 0
|
||||
if isConnectionDeadErr(err) {
|
||||
m.markConnectionDead(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
m.lastAppliedTemp = temp
|
||||
m.lastAppliedGamma = gamma
|
||||
}
|
||||
|
||||
func (m *Manager) setGammaBytes(out *outputState, data []byte) error {
|
||||
@@ -939,7 +970,8 @@ func (m *Manager) updateStateFromSchedule() {
|
||||
pos = m.getSunPosition(now)
|
||||
temp = m.getTempFromPosition(pos)
|
||||
deadline = m.getNextDeadline(now)
|
||||
isDay = now.After(times.Sunrise) && now.Before(times.Sunset)
|
||||
cycle := activeCycle(now, times)
|
||||
isDay = now.After(cycle.Sunrise) && now.Before(cycle.Sunset)
|
||||
}
|
||||
|
||||
newState := State{
|
||||
@@ -1053,14 +1085,15 @@ func (m *Manager) handleResume() {
|
||||
return
|
||||
}
|
||||
|
||||
// Compositors (Niri, Hyprland, wlroots-based) re-apply the cached gamma
|
||||
// ramp to DRM on resume; gamma_control objects stay valid. We just need
|
||||
// to force a resend so the schedule catches up with the current time of
|
||||
// day — the original #1235 regression was caused by lastAppliedTemp
|
||||
// matching and the send being skipped.
|
||||
// Compositor gamma state is unknown after resume; force a resend (#1235)
|
||||
// and re-arm the scheduler timer, which froze during suspend.
|
||||
m.outputs.Range(func(_ uint32, out *outputState) bool {
|
||||
out.lastTemp = 0
|
||||
return true
|
||||
})
|
||||
m.recalcSchedule(time.Now())
|
||||
m.lastAppliedTemp = 0
|
||||
m.applyCurrentTemp("resume")
|
||||
m.triggerUpdate()
|
||||
}
|
||||
|
||||
func (m *Manager) triggerUpdate() {
|
||||
|
||||
@@ -102,9 +102,6 @@ type Manager struct {
|
||||
dbusSignal chan *dbus.Signal
|
||||
|
||||
geoClient geolocation.Client
|
||||
|
||||
lastAppliedTemp int
|
||||
lastAppliedGamma float64
|
||||
}
|
||||
|
||||
type outputState struct {
|
||||
@@ -117,6 +114,8 @@ type outputState struct {
|
||||
isVirtual bool
|
||||
retryCount int
|
||||
lastFailTime time.Time
|
||||
lastTemp int
|
||||
lastGamma float64
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
|
||||
@@ -139,6 +139,18 @@ func (m Model) deployConfigurations() tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) optionalDepSelected(name string) bool {
|
||||
if m.disabledItems[name] {
|
||||
return false
|
||||
}
|
||||
for _, dep := range m.dependencies {
|
||||
if dep.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m Model) useSystemdConfig() bool {
|
||||
if m.osInfo == nil {
|
||||
return true
|
||||
|
||||
@@ -28,6 +28,21 @@ func (m Model) viewDetectingDeps() string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func partitionOptionalLast(dependencies []deps.Dependency) []deps.Dependency {
|
||||
ordered := make([]deps.Dependency, 0, len(dependencies))
|
||||
for _, dep := range dependencies {
|
||||
if dep.Required {
|
||||
ordered = append(ordered, dep)
|
||||
}
|
||||
}
|
||||
for _, dep := range dependencies {
|
||||
if !dep.Required {
|
||||
ordered = append(ordered, dep)
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func (m Model) viewDependencyReview() string {
|
||||
var b strings.Builder
|
||||
|
||||
@@ -39,7 +54,15 @@ func (m Model) viewDependencyReview() string {
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if len(m.dependencies) > 0 {
|
||||
optionalHeaderShown := false
|
||||
for i, dep := range m.dependencies {
|
||||
if !dep.Required && !optionalHeaderShown {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.styles.Subtle.Render("Optional (space to enable)"))
|
||||
b.WriteString("\n")
|
||||
optionalHeaderShown = true
|
||||
}
|
||||
|
||||
var status string
|
||||
var reinstallMarker string
|
||||
var variantMarker string
|
||||
@@ -82,8 +105,13 @@ func (m Model) viewDependencyReview() string {
|
||||
}
|
||||
|
||||
note := ""
|
||||
if dep.Name == "dms-greeter" {
|
||||
switch dep.Name {
|
||||
case "dms-greeter":
|
||||
note = m.styles.Subtle.Render(" (selection replaces your current display manager)")
|
||||
case "danksearch":
|
||||
note = m.styles.Subtle.Render(" (file search; enables dsearch.service)")
|
||||
case "dankcalendar":
|
||||
note = m.styles.Subtle.Render(" (autostart managed in dankcalendar settings)")
|
||||
}
|
||||
|
||||
var line string
|
||||
@@ -120,13 +148,13 @@ func (m Model) updateDetectingDepsState(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.err = depsMsg.err
|
||||
m.state = StateError
|
||||
} else {
|
||||
m.dependencies = depsMsg.deps
|
||||
// dms-greeter is opt-in skipped by default
|
||||
for _, dep := range depsMsg.deps {
|
||||
if dep.Name == "dms-greeter" {
|
||||
m.disabledItems["dms-greeter"] = true
|
||||
break
|
||||
m.dependencies = partitionOptionalLast(depsMsg.deps)
|
||||
// Optional components are opt-in, skipped by default
|
||||
for _, dep := range m.dependencies {
|
||||
if dep.Required {
|
||||
continue
|
||||
}
|
||||
m.disabledItems[dep.Name] = true
|
||||
}
|
||||
m.state = StateDependencyReview
|
||||
}
|
||||
@@ -231,14 +259,7 @@ func (m Model) installPackages() tea.Cmd {
|
||||
for msg := range installerProgressChan {
|
||||
// Run optional greeter setup
|
||||
if msg.Phase == distros.PhaseComplete && msg.IsComplete && msg.Error == nil {
|
||||
greeterSelected := false
|
||||
for _, dep := range m.dependencies {
|
||||
if dep.Name == "dms-greeter" && !m.disabledItems["dms-greeter"] {
|
||||
greeterSelected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if greeterSelected {
|
||||
if m.optionalDepSelected("dms-greeter") {
|
||||
compositorName := "niri"
|
||||
switch m.selectedWindowManager() {
|
||||
case deps.WindowManagerHyprland:
|
||||
@@ -265,6 +286,28 @@ func (m Model) installPackages() tea.Cmd {
|
||||
logOutput: fmt.Sprintf("⚠ Greeter auto-setup warning (non-fatal): %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
if m.useSystemdConfig() && m.optionalDepSelected("danksearch") {
|
||||
m.packageProgressChan <- packageInstallProgressMsg{
|
||||
progress: 0.97,
|
||||
step: "Enabling danksearch service...",
|
||||
logOutput: "Setting up dsearch.service...",
|
||||
}
|
||||
dsearchLogFunc := func(line string) {
|
||||
m.packageProgressChan <- packageInstallProgressMsg{
|
||||
progress: 0.97,
|
||||
step: "Enabling danksearch service...",
|
||||
logOutput: line,
|
||||
}
|
||||
}
|
||||
if err := distros.SetupDsearchService(context.Background(), dsearchLogFunc); err != nil {
|
||||
m.packageProgressChan <- packageInstallProgressMsg{
|
||||
progress: 0.98,
|
||||
step: "danksearch service warning",
|
||||
logOutput: fmt.Sprintf("danksearch service setup warning (non-fatal): %v", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tuiMsg := packageInstallProgressMsg{
|
||||
|
||||
@@ -384,10 +384,6 @@ func applyHyprlandRuleAction(actions *windowrules.Actions, rule, value string) {
|
||||
if f, err := strconv.ParseFloat(value, 64); err == nil {
|
||||
actions.Opacity = &f
|
||||
}
|
||||
case "size":
|
||||
actions.Size = value
|
||||
case "move":
|
||||
actions.Move = value
|
||||
case "monitor":
|
||||
actions.Monitor = value
|
||||
case "workspace":
|
||||
@@ -564,6 +560,13 @@ func hyprLuaBoolStr(b bool) string {
|
||||
return "false"
|
||||
}
|
||||
|
||||
func hyprLuaExprWrap(v string) string {
|
||||
if _, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return v
|
||||
}
|
||||
return strconv.Quote(v)
|
||||
}
|
||||
|
||||
func luaAppendMatch(mc windowrules.MatchCriteria, dst *[]string) {
|
||||
if mc.AppID != "" {
|
||||
*dst = append(*dst, fmt.Sprintf(`class = %s`, strconv.Quote(mc.AppID)))
|
||||
@@ -634,11 +637,11 @@ func luaAppendActions(a windowrules.Actions, dst *[]string) {
|
||||
if a.Opacity != nil {
|
||||
*dst = append(*dst, fmt.Sprintf(`opacity = %s`, strconv.FormatFloat(*a.Opacity, 'g', -1, 64)))
|
||||
}
|
||||
if a.Size != "" {
|
||||
*dst = append(*dst, fmt.Sprintf(`size = %s`, strconv.Quote(a.Size)))
|
||||
if a.SizeWidth != "" && a.SizeHeight != "" {
|
||||
*dst = append(*dst, fmt.Sprintf(`size = { %s, %s }`, hyprLuaExprWrap(a.SizeWidth), hyprLuaExprWrap(a.SizeHeight)))
|
||||
}
|
||||
if a.Move != "" {
|
||||
*dst = append(*dst, fmt.Sprintf(`move = %s`, strconv.Quote(a.Move)))
|
||||
if a.MoveX != "" && a.MoveY != "" {
|
||||
*dst = append(*dst, fmt.Sprintf(`move = { %s, %s }`, hyprLuaExprWrap(a.MoveX), hyprLuaExprWrap(a.MoveY)))
|
||||
}
|
||||
if a.Monitor != "" {
|
||||
*dst = append(*dst, fmt.Sprintf(`monitor = %s`, strconv.Quote(a.Monitor)))
|
||||
@@ -1194,7 +1197,11 @@ func luaStringValue(s string) string {
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Trim(strings.TrimSpace(s), `"'`)
|
||||
v := strings.Trim(strings.TrimSpace(s), `"'`)
|
||||
if len(v) >= 2 && v[0] == '(' && v[len(v)-1] == ')' {
|
||||
v = strings.TrimSpace(v[1 : len(v)-1])
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func luaBoolLike(s string) (val bool, ok bool) {
|
||||
@@ -1349,11 +1356,29 @@ func applyLuaActionKey(a *windowrules.Actions, key, raw string) bool {
|
||||
}
|
||||
}
|
||||
case "size":
|
||||
a.Size = strings.TrimSpace(luaStringValue(raw))
|
||||
return true
|
||||
v := strings.TrimSpace(luaStringValue(raw))
|
||||
if strings.HasPrefix(v, "{") && strings.HasSuffix(v, "}") {
|
||||
inner := trimOuterBraces(v)
|
||||
parts := splitTopLevelCommaLua(inner)
|
||||
if len(parts) == 2 {
|
||||
a.SizeWidth = strings.TrimSpace(luaStringValue(parts[0]))
|
||||
a.SizeHeight = strings.TrimSpace(luaStringValue(parts[1]))
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case "move":
|
||||
a.Move = strings.TrimSpace(luaStringValue(raw))
|
||||
return true
|
||||
v := strings.TrimSpace(luaStringValue(raw))
|
||||
if strings.HasPrefix(v, "{") && strings.HasSuffix(v, "}") {
|
||||
inner := trimOuterBraces(v)
|
||||
parts := splitTopLevelCommaLua(inner)
|
||||
if len(parts) == 2 {
|
||||
a.MoveX = strings.TrimSpace(luaStringValue(parts[0]))
|
||||
a.MoveY = strings.TrimSpace(luaStringValue(parts[1]))
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case "monitor":
|
||||
a.Monitor = strings.TrimSpace(luaStringValue(raw))
|
||||
return true
|
||||
|
||||
@@ -400,3 +400,196 @@ func TestBoolToInt(t *testing.T) {
|
||||
t.Error("boolToInt(false) should be 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaAppendActionsTableSyntax(t *testing.T) {
|
||||
actions := windowrules.Actions{
|
||||
SizeWidth: "800",
|
||||
SizeHeight: "600",
|
||||
MoveX: "100",
|
||||
MoveY: "200",
|
||||
}
|
||||
|
||||
var out []string
|
||||
luaAppendActions(actions, &out)
|
||||
joined := strings.Join(out, "\n")
|
||||
for _, want := range []string{
|
||||
`size = { 800, 600 }`,
|
||||
`move = { 100, 200 }`,
|
||||
} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("expected output to contain %q, got:\n%s", want, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaAppendActionsExprWrap(t *testing.T) {
|
||||
actions := windowrules.Actions{
|
||||
SizeWidth: "window_w * 0.5",
|
||||
SizeHeight: "window_h - 50",
|
||||
MoveX: "100",
|
||||
MoveY: "(monitor_h / 2) + 17",
|
||||
}
|
||||
|
||||
var out []string
|
||||
luaAppendActions(actions, &out)
|
||||
joined := strings.Join(out, "\n")
|
||||
for _, want := range []string{
|
||||
`size = { "window_w * 0.5", "window_h - 50" }`,
|
||||
`move = { 100, "(monitor_h / 2) + 17" }`,
|
||||
} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("expected output to contain %q, got:\n%s", want, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLuaActionKeyTableSyntax(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
raw string
|
||||
wantSizeW string
|
||||
wantSizeH string
|
||||
wantMoveX string
|
||||
wantMoveY string
|
||||
}{
|
||||
{
|
||||
name: "size table syntax",
|
||||
key: "size",
|
||||
raw: `{ 800, 600 }`,
|
||||
wantSizeW: "800",
|
||||
wantSizeH: "600",
|
||||
},
|
||||
{
|
||||
name: "move table syntax",
|
||||
key: "move",
|
||||
raw: `{ 100, 200 }`,
|
||||
wantMoveX: "100",
|
||||
wantMoveY: "200",
|
||||
},
|
||||
{
|
||||
name: "size string syntax returns false",
|
||||
key: "size",
|
||||
raw: `"800x600"`,
|
||||
},
|
||||
{
|
||||
name: "move string syntax returns false",
|
||||
key: "move",
|
||||
raw: `"100 200"`,
|
||||
},
|
||||
{
|
||||
name: "size expressions",
|
||||
key: "size",
|
||||
raw: `{ "window_w * 0.5", "window_h - 50" }`,
|
||||
wantSizeW: "window_w * 0.5",
|
||||
wantSizeH: "window_h - 50",
|
||||
},
|
||||
{
|
||||
name: "move expressions",
|
||||
key: "move",
|
||||
raw: `{ 100, "(monitor_h / 2) + 17" }`,
|
||||
wantMoveX: "100",
|
||||
wantMoveY: "(monitor_h / 2) + 17",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var a windowrules.Actions
|
||||
result := applyLuaActionKey(&a, tt.key, tt.raw)
|
||||
if tt.wantSizeW == "" && tt.wantSizeH == "" && tt.wantMoveX == "" && tt.wantMoveY == "" {
|
||||
if result {
|
||||
t.Errorf("expected applyLuaActionKey to return false for string syntax, got true")
|
||||
}
|
||||
return
|
||||
}
|
||||
if !result {
|
||||
t.Fatal("applyLuaActionKey returned false")
|
||||
}
|
||||
if tt.wantSizeW != "" && a.SizeWidth != tt.wantSizeW {
|
||||
t.Errorf("SizeWidth = %q, want %q", a.SizeWidth, tt.wantSizeW)
|
||||
}
|
||||
if tt.wantSizeH != "" && a.SizeHeight != tt.wantSizeH {
|
||||
t.Errorf("SizeHeight = %q, want %q", a.SizeHeight, tt.wantSizeH)
|
||||
}
|
||||
if tt.wantMoveX != "" && a.MoveX != tt.wantMoveX {
|
||||
t.Errorf("MoveX = %q, want %q", a.MoveX, tt.wantMoveX)
|
||||
}
|
||||
if tt.wantMoveY != "" && a.MoveY != tt.wantMoveY {
|
||||
t.Errorf("MoveY = %q, want %q", a.MoveY, tt.wantMoveY)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaRoundTripTableSyntax(t *testing.T) {
|
||||
original := windowrules.Actions{
|
||||
SizeWidth: "800",
|
||||
SizeHeight: "600",
|
||||
MoveX: "100",
|
||||
MoveY: "200",
|
||||
}
|
||||
|
||||
var out []string
|
||||
luaAppendActions(original, &out)
|
||||
|
||||
var parsed windowrules.Actions
|
||||
for _, line := range out {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
applyLuaActionKey(&parsed, key, val)
|
||||
}
|
||||
|
||||
if parsed.SizeWidth != original.SizeWidth {
|
||||
t.Errorf("SizeWidth = %q, want %q", parsed.SizeWidth, original.SizeWidth)
|
||||
}
|
||||
if parsed.SizeHeight != original.SizeHeight {
|
||||
t.Errorf("SizeHeight = %q, want %q", parsed.SizeHeight, original.SizeHeight)
|
||||
}
|
||||
if parsed.MoveX != original.MoveX {
|
||||
t.Errorf("MoveX = %q, want %q", parsed.MoveX, original.MoveX)
|
||||
}
|
||||
if parsed.MoveY != original.MoveY {
|
||||
t.Errorf("MoveY = %q, want %q", parsed.MoveY, original.MoveY)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaRoundTripTableSyntaxExpressions(t *testing.T) {
|
||||
original := windowrules.Actions{
|
||||
SizeWidth: "window_w * 0.5",
|
||||
SizeHeight: "window_h - 50",
|
||||
MoveX: "100",
|
||||
MoveY: "(monitor_h / 2) + 17",
|
||||
}
|
||||
|
||||
var out []string
|
||||
luaAppendActions(original, &out)
|
||||
|
||||
var parsed windowrules.Actions
|
||||
for _, line := range out {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
applyLuaActionKey(&parsed, key, val)
|
||||
}
|
||||
|
||||
if parsed.SizeWidth != original.SizeWidth {
|
||||
t.Errorf("SizeWidth = %q, want %q", parsed.SizeWidth, original.SizeWidth)
|
||||
}
|
||||
if parsed.SizeHeight != original.SizeHeight {
|
||||
t.Errorf("SizeHeight = %q, want %q", parsed.SizeHeight, original.SizeHeight)
|
||||
}
|
||||
if parsed.MoveX != original.MoveX {
|
||||
t.Errorf("MoveX = %q, want %q", parsed.MoveX, original.MoveX)
|
||||
}
|
||||
if parsed.MoveY != original.MoveY {
|
||||
t.Errorf("MoveY = %q, want %q", parsed.MoveY, original.MoveY)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
|
||||
@@ -168,7 +167,8 @@ func ConvertMangoRulesToWindowRules(mangoRules []MangoWindowRule) []windowrules.
|
||||
}
|
||||
if w, ok := f["width"]; ok {
|
||||
if h, ok2 := f["height"]; ok2 {
|
||||
actions.Size = w + "x" + h
|
||||
actions.SizeWidth = w
|
||||
actions.SizeHeight = h
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,11 +200,9 @@ func formatMangoRule(rule windowrules.WindowRule) string {
|
||||
add("tags", rule.Actions.Workspace)
|
||||
add("monitor", rule.Actions.Monitor)
|
||||
|
||||
if rule.Actions.Size != "" {
|
||||
if w, h, ok := splitSize(rule.Actions.Size); ok {
|
||||
add("width", w)
|
||||
add("height", h)
|
||||
}
|
||||
if rule.Actions.SizeWidth != "" && rule.Actions.SizeHeight != "" {
|
||||
add("width", rule.Actions.SizeWidth)
|
||||
add("height", rule.Actions.SizeHeight)
|
||||
}
|
||||
|
||||
addBool := func(k string, b *bool) {
|
||||
@@ -223,19 +221,6 @@ func formatMangoRule(rule windowrules.WindowRule) string {
|
||||
return "windowrule=" + strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func splitSize(size string) (w, h string, ok bool) {
|
||||
for _, sep := range []string{"x", "X", " "} {
|
||||
if parts := strings.Split(size, sep); len(parts) == 2 {
|
||||
w = strings.TrimSpace(parts[0])
|
||||
h = strings.TrimSpace(parts[1])
|
||||
if _, err := strconv.ParseFloat(w, 64); err == nil {
|
||||
return w, h, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
type MangoWritableProvider struct {
|
||||
configDir string
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user