mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-02 03:28:28 -04:00
Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-stable:
|
update-stable:
|
||||||
|
# skip prerelease tags
|
||||||
|
if: ${{ !contains(github.ref_name, '-') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Create GitHub App token
|
- name: Create GitHub App token
|
||||||
id: app_token
|
id: app_token
|
||||||
uses: actions/create-github-app-token@v1
|
uses: actions/create-github-app-token@v2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.APP_ID }}
|
app-id: ${{ secrets.APP_ID }}
|
||||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
@@ -28,4 +31,54 @@ jobs:
|
|||||||
- name: Push to stable branch
|
- name: Push to stable branch
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||||
run: 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/**"
|
- "core/**"
|
||||||
- ".github/workflows/go-ci.yml"
|
- ".github/workflows/go-ci.yml"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [master, main]
|
branches: [master, main, "stable-*"]
|
||||||
paths:
|
paths:
|
||||||
- "core/**"
|
- "core/**"
|
||||||
- ".github/workflows/go-ci.yml"
|
- ".github/workflows/go-ci.yml"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Nix flake and NixOS tests
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [master, main]
|
branches: [master, main, "stable-*"]
|
||||||
paths:
|
paths:
|
||||||
- "flake.*"
|
- "flake.*"
|
||||||
- "distro/nix/**"
|
- "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:
|
on:
|
||||||
push:
|
push:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [master, main]
|
branches: [master, main, "stable-*"]
|
||||||
jobs:
|
jobs:
|
||||||
pre-commit-check:
|
pre-commit-check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -7,10 +7,16 @@ on:
|
|||||||
description: "Tag to release (e.g., v1.0.1)"
|
description: "Tag to release (e.g., v1.0.1)"
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
notify_issues:
|
||||||
|
description: "Comment on open related/fixes issues asking reporters to retest"
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
actions: write
|
actions: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-${{ inputs.tag }}
|
group: release-${{ inputs.tag }}
|
||||||
@@ -205,13 +211,23 @@ jobs:
|
|||||||
|
|
||||||
- name: Generate Changelog
|
- name: Generate Changelog
|
||||||
id: changelog
|
id: changelog
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "")
|
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "")
|
||||||
if [ -z "$PREVIOUS_TAG" ]; then
|
CHANGELOG=""
|
||||||
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /' | head -50)
|
if [ -n "$PREVIOUS_TAG" ]; then
|
||||||
else
|
# PR-based notes with author credits; falls back to raw git log below
|
||||||
CHANGELOG=$(git log --oneline --pretty=format:"%an|%s (%h)" "${PREVIOUS_TAG}..${TAG}" | grep -v "^github-actions\[bot\]|" | sed 's/^[^|]*|/- /')
|
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
|
fi
|
||||||
|
|
||||||
cat > RELEASE_BODY.md << 'EOF'
|
cat > RELEASE_BODY.md << 'EOF'
|
||||||
@@ -417,3 +433,19 @@ jobs:
|
|||||||
prerelease: ${{ contains(env.TAG, '-') }}
|
prerelease: ${{ contains(env.TAG, '-') }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# Ask reporters on still-open "related/fixes #N" issues to retest.
|
||||||
|
# Opt-in via notify_issues (Point release defaults it on). Skipped for
|
||||||
|
# prereleases. Preview: scripts/notify-issues.sh <prev> <tag> --dry-run
|
||||||
|
- name: Notify open issues to retest
|
||||||
|
if: ${{ inputs.notify_issues && !contains(env.TAG, '-') }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)
|
||||||
|
if [ -z "$PREVIOUS_TAG" ]; then
|
||||||
|
echo "no previous tag; skipping issue notify"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
bash scripts/notify-issues.sh "$PREVIOUS_TAG" "$TAG"
|
||||||
|
|||||||
+111
-39
@@ -3,10 +3,15 @@ name: Void Linux XBPS Repository
|
|||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
|
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
|
||||||
release:
|
# release trigger disabled; dispatch manually after a release
|
||||||
types: [published]
|
# release:
|
||||||
|
# types: [published]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Stable release tag/version to build (e.g. v1.5.0 or 1.5.0). Leave blank to use the selected ref/template version'
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
force_rebuild:
|
force_rebuild:
|
||||||
description: 'Force rebuilding packages even if they already exist in the repository'
|
description: 'Force rebuilding packages even if they already exist in the repository'
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -29,30 +34,63 @@ on:
|
|||||||
default: true
|
default: true
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: void-xbps-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
preflight:
|
||||||
|
name: Check for unpublished changes
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
should_build: ${{ steps.check.outputs.should_build }}
|
||||||
|
steps:
|
||||||
|
- name: Check published commit
|
||||||
|
id: check
|
||||||
|
env:
|
||||||
|
MANIFEST_URL: https://void.danklinux.com/dms/current/manifest.json
|
||||||
|
run: |
|
||||||
|
SHOULD_BUILD=true
|
||||||
|
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||||
|
PUBLISHED_COMMIT="$(curl -fsSL --retry 3 "$MANIFEST_URL" | jq -r '.source_commit // empty' 2>/dev/null || true)"
|
||||||
|
if [ "$PUBLISHED_COMMIT" = "${{ github.sha }}" ]; then
|
||||||
|
SHOULD_BUILD=false
|
||||||
|
echo "${{ github.sha }} is already published; skipping the scheduled build."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
name: Build & Deploy XBPS packages
|
name: Build & Deploy XBPS packages
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
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:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ github.event.release.tag_name || (github.event.inputs.version && (startsWith(github.event.inputs.version, 'v') && github.event.inputs.version || format('v{0}', github.event.inputs.version))) || github.ref }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 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: |
|
run: |
|
||||||
git clone --branch gh-pages https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git gh-pages-repo || {
|
aws --version
|
||||||
echo "⚠️ gh-pages branch not found or empty, initializing a new one..."
|
bash .github/scripts/publish-void-r2.sh download
|
||||||
mkdir gh-pages-repo
|
|
||||||
cd gh-pages-repo
|
|
||||||
git init
|
|
||||||
git checkout -b gh-pages
|
|
||||||
git remote add origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git
|
|
||||||
cd ..
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: Install XBPS Static Utilities
|
- name: Install XBPS Static Utilities
|
||||||
run: |
|
run: |
|
||||||
@@ -126,19 +164,19 @@ jobs:
|
|||||||
- name: Configure repositories
|
- name: Configure repositories
|
||||||
run: |
|
run: |
|
||||||
# Append the repository to repos-remote templates so xbps-src translates it automatically
|
# Append the repository to repos-remote templates so xbps-src translates it automatically
|
||||||
echo "repository=https://avengemedia.github.io/DankLinux/current" >> void-packages/etc/xbps.d/repos-remote.conf
|
echo "repository=https://void.danklinux.com/danklinux/current" >> void-packages/etc/xbps.d/repos-remote.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-x86_64-multilib.conf
|
||||||
|
|
||||||
# Add any existing compiled packages to the build cache directory to avoid rebuilds
|
# Add any existing compiled packages to the build cache directory to avoid rebuilds
|
||||||
if [ -d "gh-pages-repo/current" ]; then
|
if [ -d "r2-repo/current" ]; then
|
||||||
mkdir -p void-packages/hostdir/binpkgs
|
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
|
xbps-rindex -a void-packages/hostdir/binpkgs/*.xbps 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build packages
|
- name: Build packages
|
||||||
run: |
|
run: |
|
||||||
mkdir -p gh-pages-repo/current
|
mkdir -p r2-repo/current
|
||||||
|
|
||||||
BUILD_DMS="${{ github.event.inputs.build_dms || 'true' }}"
|
BUILD_DMS="${{ github.event.inputs.build_dms || 'true' }}"
|
||||||
BUILD_GREETER="${{ github.event.inputs.build_greeter || 'true' }}"
|
BUILD_GREETER="${{ github.event.inputs.build_greeter || 'true' }}"
|
||||||
@@ -157,6 +195,25 @@ jobs:
|
|||||||
BUILD_GIT="false"
|
BUILD_GIT="false"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
RELEASE_TAG=""
|
||||||
|
if [ "${{ github.event_name }}" = "release" ]; then
|
||||||
|
RELEASE_TAG="${{ github.event.release.tag_name }}"
|
||||||
|
elif [ -n "${{ github.event.inputs.version || '' }}" ]; then
|
||||||
|
RELEASE_TAG="${{ github.event.inputs.version }}"
|
||||||
|
elif [ "${{ github.ref_type }}" = "tag" ]; then
|
||||||
|
RELEASE_TAG="${{ github.ref_name }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$RELEASE_TAG" ]; then
|
||||||
|
RELEASE_VER="${RELEASE_TAG#v}"
|
||||||
|
ARCHIVE_TAG="v${RELEASE_VER}"
|
||||||
|
echo "Stable release tag: $ARCHIVE_TAG"
|
||||||
|
else
|
||||||
|
RELEASE_VER=""
|
||||||
|
ARCHIVE_TAG=""
|
||||||
|
echo "Stable release tag: (template version)"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "=== Starting Builds ==="
|
echo "=== Starting Builds ==="
|
||||||
echo "DMS stable build enabled: $BUILD_DMS"
|
echo "DMS stable build enabled: $BUILD_DMS"
|
||||||
echo "Greeter stable build enabled: $BUILD_GREETER"
|
echo "Greeter stable build enabled: $BUILD_GREETER"
|
||||||
@@ -165,6 +222,19 @@ jobs:
|
|||||||
|
|
||||||
cd void-packages
|
cd void-packages
|
||||||
|
|
||||||
|
if [ -n "$RELEASE_VER" ] && { [ "$BUILD_DMS" = "true" ] || [ "$BUILD_GREETER" = "true" ]; }; then
|
||||||
|
echo "🔧 Updating stable templates for $ARCHIVE_TAG"
|
||||||
|
TARBALL="$(mktemp)"
|
||||||
|
curl -fsSL -o "$TARBALL" "https://github.com/${{ github.repository }}/archive/refs/tags/${ARCHIVE_TAG}.tar.gz"
|
||||||
|
RELEASE_CHECKSUM="$(sha256sum "$TARBALL" | cut -d' ' -f1)"
|
||||||
|
rm -f "$TARBALL"
|
||||||
|
|
||||||
|
for pkg in dms dms-greeter; do
|
||||||
|
sed -i "s/^version=.*/version=${RELEASE_VER}/" "srcpkgs/${pkg}/template"
|
||||||
|
sed -i "s/^checksum=.*/checksum=${RELEASE_CHECKSUM}/" "srcpkgs/${pkg}/template"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
# 1. Build dms-git (development package)
|
# 1. Build dms-git (development package)
|
||||||
if [ "$BUILD_GIT" = "true" ]; then
|
if [ "$BUILD_GIT" = "true" ]; then
|
||||||
# Calculate dynamic git version (tag.commits.hash)
|
# Calculate dynamic git version (tag.commits.hash)
|
||||||
@@ -178,7 +248,7 @@ jobs:
|
|||||||
mkdir -p "$SRC_CACHE"
|
mkdir -p "$SRC_CACHE"
|
||||||
tar -czf "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" \
|
tar -czf "${SRC_CACHE}/dms-git-${GIT_VER}.tar.gz" \
|
||||||
--exclude=void-packages \
|
--exclude=void-packages \
|
||||||
--exclude=gh-pages-repo \
|
--exclude=r2-repo \
|
||||||
--exclude=.git \
|
--exclude=.git \
|
||||||
--exclude=danklinux \
|
--exclude=danklinux \
|
||||||
-C .. .
|
-C .. .
|
||||||
@@ -192,13 +262,13 @@ jobs:
|
|||||||
|
|
||||||
EXPECTED_GIT_FILE="dms-git-${GIT_VER}_1.x86_64.xbps"
|
EXPECTED_GIT_FILE="dms-git-${GIT_VER}_1.x86_64.xbps"
|
||||||
|
|
||||||
if [ -f "../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."
|
echo "✅ $EXPECTED_GIT_FILE already exists, skipping build."
|
||||||
else
|
else
|
||||||
echo "🔨 Compiling dms-git..."
|
echo "🔨 Compiling dms-git..."
|
||||||
./xbps-src pkg dms-git
|
./xbps-src pkg dms-git
|
||||||
rm -f "../gh-pages-repo/current/${EXPECTED_GIT_FILE}"
|
rm -f "../r2-repo/current/${EXPECTED_GIT_FILE}"
|
||||||
cp -L hostdir/binpkgs/dms-git-*.xbps ../gh-pages-repo/current/
|
cp -L "hostdir/binpkgs/${EXPECTED_GIT_FILE}" ../r2-repo/current/
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -208,13 +278,13 @@ jobs:
|
|||||||
STABLE_REV=$(grep -E '^revision=' srcpkgs/dms/template | cut -d= -f2 | tr -d '"')
|
STABLE_REV=$(grep -E '^revision=' srcpkgs/dms/template | cut -d= -f2 | tr -d '"')
|
||||||
|
|
||||||
EXPECTED_DMS_FILE="dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps"
|
EXPECTED_DMS_FILE="dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps"
|
||||||
if [ -f "../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."
|
echo "✅ $EXPECTED_DMS_FILE already exists, skipping build."
|
||||||
else
|
else
|
||||||
echo "🔨 Compiling dms ($STABLE_VER)..."
|
echo "🔨 Compiling dms ($STABLE_VER)..."
|
||||||
./xbps-src pkg dms
|
./xbps-src pkg dms
|
||||||
rm -f "../gh-pages-repo/current/${EXPECTED_DMS_FILE}"
|
rm -f "../r2-repo/current/${EXPECTED_DMS_FILE}"
|
||||||
cp -L hostdir/binpkgs/dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps ../gh-pages-repo/current/
|
cp -L hostdir/binpkgs/dms-${STABLE_VER}_${STABLE_REV}.x86_64.xbps ../r2-repo/current/
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -224,22 +294,21 @@ jobs:
|
|||||||
GREETER_REV=$(grep -E '^revision=' srcpkgs/dms-greeter/template | cut -d= -f2 | tr -d '"')
|
GREETER_REV=$(grep -E '^revision=' srcpkgs/dms-greeter/template | cut -d= -f2 | tr -d '"')
|
||||||
|
|
||||||
EXPECTED_GREETER_FILE="dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps"
|
EXPECTED_GREETER_FILE="dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps"
|
||||||
if [ -f "../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."
|
echo "✅ $EXPECTED_GREETER_FILE already exists, skipping build."
|
||||||
else
|
else
|
||||||
echo "🔨 Compiling dms-greeter ($GREETER_VER)..."
|
echo "🔨 Compiling dms-greeter ($GREETER_VER)..."
|
||||||
./xbps-src pkg dms-greeter
|
./xbps-src pkg dms-greeter
|
||||||
rm -f "../gh-pages-repo/current/${EXPECTED_GREETER_FILE}"
|
rm -f "../r2-repo/current/${EXPECTED_GREETER_FILE}"
|
||||||
cp -L hostdir/binpkgs/dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps ../gh-pages-repo/current/
|
cp -L hostdir/binpkgs/dms-greeter-${GREETER_VER}_${GREETER_REV}.x86_64.xbps ../r2-repo/current/
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Index and sign repository
|
- name: Index and sign repository
|
||||||
run: |
|
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 *.sig
|
||||||
rm -f *.sig2 *.sig
|
|
||||||
|
|
||||||
# Guard: nothing to index if no .xbps files exist
|
# Guard: nothing to index if no .xbps files exist
|
||||||
if ! ls *.xbps 1>/dev/null 2>&1; then
|
if ! ls *.xbps 1>/dev/null 2>&1; then
|
||||||
@@ -249,6 +318,14 @@ jobs:
|
|||||||
|
|
||||||
# Regenerate repo index
|
# Regenerate repo index
|
||||||
xbps-rindex -a $(pwd)/*.xbps
|
xbps-rindex -a $(pwd)/*.xbps
|
||||||
|
xbps-rindex --remove-obsoletes $(pwd)
|
||||||
|
|
||||||
|
# Remove signatures left behind by obsolete packages. Signatures for
|
||||||
|
# unchanged immutable packages are retained byte-for-byte.
|
||||||
|
for sig in *.sig2; do
|
||||||
|
[ -e "$sig" ] || break
|
||||||
|
[ -f "${sig%.sig2}" ] || rm -f "$sig"
|
||||||
|
done
|
||||||
|
|
||||||
# Sign repository
|
# Sign repository
|
||||||
echo "${{ secrets.XBPS_PRIVATE_KEY }}" > /tmp/xbps_privkey.pem
|
echo "${{ secrets.XBPS_PRIVATE_KEY }}" > /tmp/xbps_privkey.pem
|
||||||
@@ -259,10 +336,5 @@ jobs:
|
|||||||
|
|
||||||
rm -f /tmp/xbps_privkey.pem
|
rm -f /tmp/xbps_privkey.pem
|
||||||
|
|
||||||
- name: Deploy to gh-pages branch
|
- name: Publish repository to R2
|
||||||
run: |
|
run: bash .github/scripts/publish-void-r2.sh publish
|
||||||
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)
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Create GitHub App token
|
- name: Create GitHub App token
|
||||||
id: app_token
|
id: app_token
|
||||||
uses: actions/create-github-app-token@v1
|
uses: actions/create-github-app-token@v2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.APP_ID }}
|
app-id: ${{ secrets.APP_ID }}
|
||||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|||||||
@@ -126,3 +126,6 @@ __pycache__
|
|||||||
distro/void/temp/
|
distro/void/temp/
|
||||||
distro/void/hostdir/
|
distro/void/hostdir/
|
||||||
distro/void/masterdir*/
|
distro/void/masterdir*/
|
||||||
|
|
||||||
|
# Often gets built
|
||||||
|
core/dms
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ BINARY_NAME=dms
|
|||||||
CORE_DIR=core
|
CORE_DIR=core
|
||||||
BUILD_DIR=$(CORE_DIR)/bin
|
BUILD_DIR=$(CORE_DIR)/bin
|
||||||
PREFIX ?= /usr/local
|
PREFIX ?= /usr/local
|
||||||
INSTALL_DIR=$(PREFIX)/bin
|
INSTALL_DIR=$(DESTDIR)$(PREFIX)/bin
|
||||||
DATA_DIR=$(PREFIX)/share
|
DATA_DIR=$(DESTDIR)$(PREFIX)/share
|
||||||
ICON_DIR=$(DATA_DIR)/icons/hicolor/scalable/apps
|
ICON_DIR=$(DATA_DIR)/icons/hicolor/scalable/apps
|
||||||
|
|
||||||
USER_HOME := $(if $(SUDO_USER),$(shell getent passwd $(SUDO_USER) | cut -d: -f6),$(HOME))
|
USER_HOME := $(if $(SUDO_USER),$(shell getent passwd $(SUDO_USER) | cut -d: -f6),$(HOME))
|
||||||
@@ -62,7 +62,7 @@ install-systemd:
|
|||||||
@echo "Installing systemd user service..."
|
@echo "Installing systemd user service..."
|
||||||
@mkdir -p $(SYSTEMD_USER_DIR)
|
@mkdir -p $(SYSTEMD_USER_DIR)
|
||||||
@if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR); fi
|
@if [ -n "$(SUDO_USER)" ]; then chown -R $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR); fi
|
||||||
@sed 's|/usr/bin/dms|$(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
|
@chmod 644 $(SYSTEMD_USER_DIR)/dms.service
|
||||||
@if [ -n "$(SUDO_USER)" ]; then chown $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR)/dms.service; fi
|
@if [ -n "$(SUDO_USER)" ]; then chown $(SUDO_USER):"$(id -gn $SUDO_USER)" $(SYSTEMD_USER_DIR)/dms.service; fi
|
||||||
@echo "Systemd service installed to $(SYSTEMD_USER_DIR)/dms.service"
|
@echo "Systemd service installed to $(SYSTEMD_USER_DIR)/dms.service"
|
||||||
|
|||||||
+1
-1
@@ -206,7 +206,7 @@ Uses COPR repositories (`avengemedia/danklinux`, `avengemedia/dms`).
|
|||||||
Requires PPA support. Most packages built from source (slow first install).
|
Requires PPA support. Most packages built from source (slow first install).
|
||||||
|
|
||||||
**Debian**
|
**Debian**
|
||||||
Debian 13+ (Trixie). 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**
|
**openSUSE**
|
||||||
Most packages available in standard repos. Minimal building required.
|
Most packages available in standard repos. Minimal building required.
|
||||||
|
|||||||
@@ -35,9 +35,31 @@ 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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts")
|
authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts")
|
||||||
authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)")
|
authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)")
|
||||||
|
authResolveLockCmd.Flags().BoolP("quiet", "q", false, "Only print the resulting file path")
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncAuth(nonInteractive bool) error {
|
func syncAuth(nonInteractive bool) error {
|
||||||
|
|||||||
@@ -759,6 +759,7 @@ func getCommonCommands() []*cobra.Command {
|
|||||||
greeterCmd,
|
greeterCmd,
|
||||||
setupCmd,
|
setupCmd,
|
||||||
colorCmd,
|
colorCmd,
|
||||||
|
qrCmd,
|
||||||
screenshotCmd,
|
screenshotCmd,
|
||||||
notifyActionCmd,
|
notifyActionCmd,
|
||||||
notifyCmd,
|
notifyCmd,
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ func checkEnvironmentVars() []checkResult {
|
|||||||
var results []checkResult
|
var results []checkResult
|
||||||
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME")...)
|
results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME")...)
|
||||||
results = append(results, checkEnvVar("QS_ICON_THEME")...)
|
results = append(results, checkEnvVar("QS_ICON_THEME")...)
|
||||||
|
results = append(results, checkXDGMenuPrefix()...)
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +335,29 @@ func checkEnvVar(name string) []checkResult {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkXDGMenuPrefix() []checkResult {
|
||||||
|
menuPrefix := os.Getenv("XDG_MENU_PREFIX")
|
||||||
|
if menuPrefix != "" {
|
||||||
|
if checkXDGMenuFile(menuPrefix) {
|
||||||
|
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusInfo, menuPrefix, "", doctorDocsURL + "#xdg-menu-prefix"}}
|
||||||
|
}
|
||||||
|
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusWarn, fmt.Sprintf("%s (menu file not found)", menuPrefix), fmt.Sprintf("Dolphin 'Open with…' dialog may be empty. Ensure /etc/xdg/menus/%sapplications.menu exists.", menuPrefix), doctorDocsURL + "#xdg-menu-prefix"}}
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("keditfiletype"); err == nil {
|
||||||
|
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusWarn, "Not set", "Dolphin file associations and 'Open with…' dialog may be empty. Set XDG_MENU_PREFIX=plasma- in your compositor's environment block.", doctorDocsURL + "#xdg-menu-prefix"}}
|
||||||
|
}
|
||||||
|
if doctorVerbose {
|
||||||
|
return []checkResult{{catEnvironment, "XDG_MENU_PREFIX", statusInfo, "Not set", "", doctorDocsURL + "#xdg-menu-prefix"}}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkXDGMenuFile(prefix string) bool {
|
||||||
|
menuPath := fmt.Sprintf("/etc/xdg/menus/%sapplications.menu", prefix)
|
||||||
|
_, err := os.Stat(menuPath)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
func readOSRelease() map[string]string {
|
func readOSRelease() map[string]string {
|
||||||
result := make(map[string]string)
|
result := make(map[string]string)
|
||||||
data, err := os.ReadFile("/etc/os-release")
|
data, err := os.ReadFile("/etc/os-release")
|
||||||
|
|||||||
@@ -1081,8 +1081,11 @@ func ensureGreetdEnabled() error {
|
|||||||
if !runitServiceInstalled("greetd") {
|
if !runitServiceInstalled("greetd") {
|
||||||
return fmt.Errorf("greetd service not found in %s. Please install greetd first", runitSvDir)
|
return fmt.Errorf("greetd service not found in %s. Please install greetd first", runitSvDir)
|
||||||
}
|
}
|
||||||
// Seat + runtime-dir setup that logind handles automatically on systemd.
|
if greeter.IsVoidLinux() {
|
||||||
ensureRunitSeat("_greeter")
|
ensureVoidLogindGreeter("_greeter")
|
||||||
|
} else {
|
||||||
|
ensureRunitSeat("_greeter")
|
||||||
|
}
|
||||||
ensureGreetdPamRundir()
|
ensureGreetdPamRundir()
|
||||||
if err := enableRunitService("greetd"); err != nil {
|
if err := enableRunitService("greetd"); err != nil {
|
||||||
return fmt.Errorf("failed to enable greetd: %w", err)
|
return fmt.Errorf("failed to enable greetd: %w", err)
|
||||||
@@ -1261,6 +1264,9 @@ func enableGreeter(nonInteractive bool) error {
|
|||||||
if err := greeter.EnsureGreeterCacheDir(logFunc, ""); err != nil {
|
if err := greeter.EnsureGreeterCacheDir(logFunc, ""); err != nil {
|
||||||
fmt.Printf("⚠ Could not ensure cache directory: %v\n Run: sudo mkdir -p %s && sudo chown root:%s %s && sudo chmod 2770 %s\n", err, greeter.GreeterCacheDir, greeterGroup, greeter.GreeterCacheDir, greeter.GreeterCacheDir)
|
fmt.Printf("⚠ Could not ensure cache directory: %v\n Run: sudo mkdir -p %s && sudo chown root:%s %s && sudo chmod 2770 %s\n", err, greeter.GreeterCacheDir, greeterGroup, greeter.GreeterCacheDir, greeter.GreeterCacheDir)
|
||||||
}
|
}
|
||||||
|
if err := greeter.EnsureVoidLogindGreetdCommand(logFunc, ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := ensureGraphicalTarget(); err != nil {
|
if err := ensureGraphicalTarget(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1475,12 +1481,23 @@ func extractGreeterWrapperFromCommand(command string) string {
|
|||||||
if len(tokens) == 0 {
|
if len(tokens) == 0 {
|
||||||
return ""
|
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 == "" {
|
if wrapper == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if len(tokens) > 1 {
|
if wrapperIndex+1 < len(tokens) {
|
||||||
next := strings.Trim(tokens[1], "\"")
|
next := strings.Trim(tokens[wrapperIndex+1], "\"")
|
||||||
if next != "" && (filepath.Base(wrapper) == "bash" || filepath.Base(wrapper) == "sh") && strings.Contains(filepath.Base(next), "dms-greeter") {
|
if next != "" && (filepath.Base(wrapper) == "bash" || filepath.Base(wrapper) == "sh") && strings.Contains(filepath.Base(next), "dms-greeter") {
|
||||||
return fmt.Sprintf("%s (script: %s)", wrapper, next)
|
return fmt.Sprintf("%s (script: %s)", wrapper, next)
|
||||||
}
|
}
|
||||||
@@ -1535,7 +1552,7 @@ func packageInstallHint() string {
|
|||||||
case distros.FamilyArch:
|
case distros.FamilyArch:
|
||||||
return "Install from AUR with 'paru -S greetd-dms-greeter-git' or 'yay -S greetd-dms-greeter-git'"
|
return "Install from AUR with 'paru -S greetd-dms-greeter-git' or 'yay -S greetd-dms-greeter-git'"
|
||||||
case distros.FamilyVoid:
|
case distros.FamilyVoid:
|
||||||
return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=https://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:
|
default:
|
||||||
return "Run 'dms greeter install' to install greeter"
|
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) {
|
func TestRejectNixOSGreeterMutationBlocksImperativeCommands(t *testing.T) {
|
||||||
origGreeterIsNixOSFn := greeterIsNixOSFn
|
origGreeterIsNixOSFn := greeterIsNixOSFn
|
||||||
greeterIsNixOSFn = func() bool { return true }
|
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
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ var (
|
|||||||
sysUpdateJSON bool
|
sysUpdateJSON bool
|
||||||
sysUpdateNoFlatpak bool
|
sysUpdateNoFlatpak bool
|
||||||
sysUpdateNoAUR bool
|
sysUpdateNoAUR bool
|
||||||
|
sysUpdateIgnore []string
|
||||||
sysUpdateIntervalS int
|
sysUpdateIntervalS int
|
||||||
sysUpdateListPmTime = 5 * time.Minute
|
sysUpdateListPmTime = 5 * time.Minute
|
||||||
)
|
)
|
||||||
@@ -58,6 +59,7 @@ func init() {
|
|||||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
|
||||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
|
||||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
|
||||||
|
systemUpdateCmd.Flags().StringSliceVar(&sysUpdateIgnore, "ignore", nil, "Skip specific packages (repeatable or comma-separated)")
|
||||||
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
|
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
|
||||||
|
|
||||||
systemCmd.AddCommand(systemUpdateCmd)
|
systemCmd.AddCommand(systemUpdateCmd)
|
||||||
@@ -192,6 +194,7 @@ func runSystemUpdateApply() {
|
|||||||
Targets: pkgs,
|
Targets: pkgs,
|
||||||
IncludeFlatpak: !sysUpdateNoFlatpak,
|
IncludeFlatpak: !sysUpdateNoFlatpak,
|
||||||
IncludeAUR: !sysUpdateNoAUR,
|
IncludeAUR: !sysUpdateNoAUR,
|
||||||
|
Ignored: sysUpdateIgnore,
|
||||||
DryRun: sysUpdateDry,
|
DryRun: sysUpdateDry,
|
||||||
UseSudo: true,
|
UseSudo: true,
|
||||||
}
|
}
|
||||||
@@ -234,12 +237,19 @@ func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupd
|
|||||||
}
|
}
|
||||||
|
|
||||||
func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
|
func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
|
||||||
if !sysUpdateNoAUR {
|
if !sysUpdateNoAUR && len(sysUpdateIgnore) == 0 {
|
||||||
return pkgs
|
return pkgs
|
||||||
}
|
}
|
||||||
|
ignored := make(map[string]bool, len(sysUpdateIgnore))
|
||||||
|
for _, name := range sysUpdateIgnore {
|
||||||
|
ignored[name] = true
|
||||||
|
}
|
||||||
out := pkgs[:0]
|
out := pkgs[:0]
|
||||||
for _, p := range pkgs {
|
for _, p := range pkgs {
|
||||||
if p.Repo == sysupdate.RepoAUR {
|
if sysUpdateNoAUR && p.Repo == sysupdate.RepoAUR {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ignored[p.Name] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, p)
|
out = append(out, p)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -83,9 +84,31 @@ func ensureRunitSeat(greeterUser string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureGreetdPamRundir adds pam_rundir to the greetd PAM stack so the post-login
|
// ensureVoidLogindGreeter configures the elogind-backed greeter on Void.
|
||||||
// session gets an XDG_RUNTIME_DIR on systems without logind (Void with seatd).
|
func ensureVoidLogindGreeter(greeterUser string) {
|
||||||
// Appended outside DMS's managed auth block so it survives `dms greeter sync`.
|
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() {
|
func ensureGreetdPamRundir() {
|
||||||
const pamPath = "/etc/pam.d/greetd"
|
const pamPath = "/etc/pam.d/greetd"
|
||||||
data, err := os.ReadFile(pamPath)
|
data, err := os.ReadFile(pamPath)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func init() {
|
|||||||
runCmd.Flags().MarkHidden("daemon-child")
|
runCmd.Flags().MarkHidden("daemon-child")
|
||||||
|
|
||||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd)
|
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd)
|
||||||
authCmd.AddCommand(authSyncCmd)
|
authCmd.AddCommand(authSyncCmd, authResolveLockCmd)
|
||||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||||
updateCmd.AddCommand(updateCheckCmd)
|
updateCmd.AddCommand(updateCheckCmd)
|
||||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func init() {
|
|||||||
runCmd.Flags().MarkHidden("daemon-child")
|
runCmd.Flags().MarkHidden("daemon-child")
|
||||||
|
|
||||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd)
|
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd)
|
||||||
authCmd.AddCommand(authSyncCmd)
|
authCmd.AddCommand(authSyncCmd, authResolveLockCmd)
|
||||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||||
rootCmd.AddCommand(getCommonCommands()...)
|
rootCmd.AddCommand(getCommonCommands()...)
|
||||||
|
|||||||
+10
-2
@@ -92,6 +92,14 @@ func appendLogEnv(env []string) []string {
|
|||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withDMSExecutable(env []string) []string {
|
||||||
|
selfPath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
return append(env, "DMS_EXECUTABLE="+selfPath)
|
||||||
|
}
|
||||||
|
|
||||||
func hasSystemdRun() bool {
|
func hasSystemdRun() bool {
|
||||||
_, err := exec.LookPath("systemd-run")
|
_, err := exec.LookPath("systemd-run")
|
||||||
return err == nil
|
return err == nil
|
||||||
@@ -207,7 +215,7 @@ func runShellInteractive(session bool) {
|
|||||||
log.Infof("Spawning quickshell with -p %s", configPath)
|
log.Infof("Spawning quickshell with -p %s", configPath)
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
|
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
|
||||||
cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
|
cmd.Env = withDMSExecutable(append(os.Environ(), "DMS_SOCKET="+socketPath))
|
||||||
if os.Getenv("QT_LOGGING_RULES") == "" {
|
if os.Getenv("QT_LOGGING_RULES") == "" {
|
||||||
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
|
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
|
||||||
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
|
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
|
||||||
@@ -461,7 +469,7 @@ func runShellDaemon(session bool) {
|
|||||||
log.Infof("Spawning quickshell with -p %s", configPath)
|
log.Infof("Spawning quickshell with -p %s", configPath)
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
|
cmd := exec.CommandContext(ctx, "qs", "-p", configPath)
|
||||||
cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath)
|
cmd.Env = withDMSExecutable(append(os.Environ(), "DMS_SOCKET="+socketPath))
|
||||||
if os.Getenv("QT_LOGGING_RULES") == "" {
|
if os.Getenv("QT_LOGGING_RULES") == "" {
|
||||||
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
|
if qtRules := log.GetQtLoggingRules(); qtRules != "" {
|
||||||
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
|
cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules)
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ end)
|
|||||||
|
|
||||||
hl.config({
|
hl.config({
|
||||||
input = {
|
input = {
|
||||||
kb_layout = "us",
|
-- empty inherits XKB_DEFAULT_LAYOUT (libxkbcommon), falls back to "us"
|
||||||
|
kb_layout = "",
|
||||||
numlock_by_default = true,
|
numlock_by_default = true,
|
||||||
follow_mouse = 0,
|
follow_mouse = 0,
|
||||||
touchpad = {
|
touchpad = {
|
||||||
|
|||||||
@@ -51,24 +51,24 @@ binds {
|
|||||||
XF86AudioMicMute allow-when-locked=true {
|
XF86AudioMicMute allow-when-locked=true {
|
||||||
spawn "dms" "ipc" "call" "audio" "micmute";
|
spawn "dms" "ipc" "call" "audio" "micmute";
|
||||||
}
|
}
|
||||||
XF86AudioPause allow-when-locked=true {
|
XF86AudioPause allow-when-locked=true {
|
||||||
spawn "dms" "ipc" "call" "mpris" "playPause";
|
spawn "dms" "ipc" "call" "mpris" "playPause";
|
||||||
}
|
}
|
||||||
XF86AudioPlay allow-when-locked=true {
|
XF86AudioPlay allow-when-locked=true {
|
||||||
spawn "dms" "ipc" "call" "mpris" "playPause";
|
spawn "dms" "ipc" "call" "mpris" "playPause";
|
||||||
}
|
}
|
||||||
XF86AudioPrev allow-when-locked=true {
|
XF86AudioPrev allow-when-locked=true {
|
||||||
spawn "dms" "ipc" "call" "mpris" "previous";
|
spawn "dms" "ipc" "call" "mpris" "previous";
|
||||||
}
|
}
|
||||||
XF86AudioNext allow-when-locked=true {
|
XF86AudioNext allow-when-locked=true {
|
||||||
spawn "dms" "ipc" "call" "mpris" "next";
|
spawn "dms" "ipc" "call" "mpris" "next";
|
||||||
}
|
}
|
||||||
Ctrl+XF86AudioRaiseVolume allow-when-locked=true {
|
Ctrl+XF86AudioRaiseVolume allow-when-locked=true {
|
||||||
spawn "dms" "ipc" "call" "mpris" "increment" "3";
|
spawn "dms" "ipc" "call" "mpris" "increment" "3";
|
||||||
}
|
}
|
||||||
Ctrl+XF86AudioLowerVolume allow-when-locked=true {
|
Ctrl+XF86AudioLowerVolume allow-when-locked=true {
|
||||||
spawn "dms" "ipc" "call" "mpris" "decrement" "3";
|
spawn "dms" "ipc" "call" "mpris" "decrement" "3";
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Brightness Controls ===
|
// === Brightness Controls ===
|
||||||
XF86MonBrightnessUp allow-when-locked=true {
|
XF86MonBrightnessUp allow-when-locked=true {
|
||||||
|
|||||||
@@ -133,6 +133,11 @@ func mergedAssociations() *MimeAssociations {
|
|||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isSafeIniField rejects values that would corrupt a key=value line in mimeapps.list
|
||||||
|
func isSafeIniField(s string) bool {
|
||||||
|
return !strings.ContainsAny(s, "\n\r[]")
|
||||||
|
}
|
||||||
|
|
||||||
func writeUserMimeapps(update func(*MimeAssociations)) error {
|
func writeUserMimeapps(update func(*MimeAssociations)) error {
|
||||||
mimeappsWriteMu.Lock()
|
mimeappsWriteMu.Lock()
|
||||||
defer mimeappsWriteMu.Unlock()
|
defer mimeappsWriteMu.Unlock()
|
||||||
@@ -152,6 +157,7 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
|
|||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
w := bufio.NewWriter(&buf)
|
w := bufio.NewWriter(&buf)
|
||||||
|
|
||||||
|
var writeErr error
|
||||||
writeSection := func(name string, entries map[string]string) {
|
writeSection := func(name string, entries map[string]string) {
|
||||||
fmt.Fprintf(w, "[%s]\n", name)
|
fmt.Fprintf(w, "[%s]\n", name)
|
||||||
keys := make([]string, 0, len(entries))
|
keys := make([]string, 0, len(entries))
|
||||||
@@ -160,7 +166,14 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
|
|||||||
}
|
}
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
for _, k := range 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)
|
fmt.Fprintln(w)
|
||||||
}
|
}
|
||||||
@@ -177,6 +190,10 @@ func writeUserMimeapps(update func(*MimeAssociations)) error {
|
|||||||
writeSection(groupAdded, flatten(assoc.Added))
|
writeSection(groupAdded, flatten(assoc.Added))
|
||||||
writeSection(groupRemoved, flatten(assoc.Removed))
|
writeSection(groupRemoved, flatten(assoc.Removed))
|
||||||
|
|
||||||
|
if writeErr != nil {
|
||||||
|
return writeErr
|
||||||
|
}
|
||||||
|
|
||||||
if err := w.Flush(); err != nil {
|
if err := w.Flush(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,7 +161,60 @@ func (d *DebianDistribution) getQuickshellMapping(variant deps.PackageVariant) P
|
|||||||
if forceQuickshellGit || variant == deps.VariantGit {
|
if forceQuickshellGit || variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}
|
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}
|
||||||
}
|
}
|
||||||
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 {
|
func (d *DebianDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
@@ -266,6 +319,10 @@ func (d *DebianDistribution) InstallPackages(ctx context.Context, dependencies [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := d.ensureQuickshellBackports(ctx, systemPkgs, sudoPassword, progressChan); err != nil {
|
||||||
|
return fmt.Errorf("failed to enable trixie-backports for quickshell: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// System Packages
|
// System Packages
|
||||||
if len(systemPkgs) > 0 {
|
if len(systemPkgs) > 0 {
|
||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
VoidDMSRepo = "https://avengemedia.github.io/DankMaterialShell/current"
|
VoidDMSRepo = "https://void.danklinux.com/dms/current"
|
||||||
VoidDankLinuxRepo = "https://avengemedia.github.io/DankLinux/current"
|
VoidDankLinuxRepo = "https://void.danklinux.com/danklinux/current"
|
||||||
VoidHyprlandRepo = "https://mirror.black-hole.dev/x86_64"
|
VoidHyprlandRepo = "https://mirror.black-hole.dev/x86_64"
|
||||||
|
|
||||||
voidRunitSvDir = "/etc/sv"
|
voidRunitSvDir = "/etc/sv"
|
||||||
@@ -72,6 +72,7 @@ func (v *VoidDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
|
|||||||
dependencies = append(dependencies, v.detectAccountsService())
|
dependencies = append(dependencies, v.detectAccountsService())
|
||||||
dependencies = append(dependencies, v.detectDBus())
|
dependencies = append(dependencies, v.detectDBus())
|
||||||
dependencies = append(dependencies, v.detectElogind())
|
dependencies = append(dependencies, v.detectElogind())
|
||||||
|
dependencies = append(dependencies, v.detectMesaDri())
|
||||||
|
|
||||||
if wm == deps.WindowManagerHyprland {
|
if wm == deps.WindowManagerHyprland {
|
||||||
dependencies = append(dependencies, v.detectHyprlandTools()...)
|
dependencies = append(dependencies, v.detectHyprlandTools()...)
|
||||||
@@ -142,6 +143,10 @@ func (v *VoidDistribution) detectElogind() deps.Dependency {
|
|||||||
return v.detectPackage("elogind", "loginctl/logind provider for power management and session tracking", v.packageInstalled("elogind") || v.commandExists("loginctl"))
|
return v.detectPackage("elogind", "loginctl/logind provider for power management and session tracking", v.packageInstalled("elogind") || v.commandExists("loginctl"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) detectMesaDri() deps.Dependency {
|
||||||
|
return v.detectPackage("mesa-dri", "Mesa DRI/EGL drivers (GPU rendering; compositors find no outputs without it)", v.packageInstalled("mesa-dri"))
|
||||||
|
}
|
||||||
|
|
||||||
func (v *VoidDistribution) detectXwaylandSatellite() deps.Dependency {
|
func (v *VoidDistribution) detectXwaylandSatellite() deps.Dependency {
|
||||||
return v.detectPackage("xwayland-satellite", "Xwayland support", v.packageInstalled("xwayland-satellite"))
|
return v.detectPackage("xwayland-satellite", "Xwayland support", v.packageInstalled("xwayland-satellite"))
|
||||||
}
|
}
|
||||||
@@ -172,6 +177,7 @@ func (v *VoidDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
|
|||||||
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
|
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
|
||||||
"dbus": {Name: "dbus", Repository: RepoTypeSystem},
|
"dbus": {Name: "dbus", Repository: RepoTypeSystem},
|
||||||
"elogind": {Name: "elogind", Repository: RepoTypeSystem},
|
"elogind": {Name: "elogind", Repository: RepoTypeSystem},
|
||||||
|
"mesa-dri": {Name: "mesa-dri", Repository: RepoTypeSystem},
|
||||||
|
|
||||||
"quickshell": {Name: "quickshell", Repository: RepoTypeSystem},
|
"quickshell": {Name: "quickshell", Repository: RepoTypeSystem},
|
||||||
"matugen": {Name: "matugen", Repository: RepoTypeSystem},
|
"matugen": {Name: "matugen", Repository: RepoTypeSystem},
|
||||||
@@ -305,6 +311,7 @@ func (v *VoidDistribution) ensureSessionServices(ctx context.Context, sudoPasswo
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// D-Bus activation alone starts elogind without its wrapper mounts; the runit service is required.
|
||||||
for _, service := range []string{"dbus", "elogind"} {
|
for _, service := range []string{"dbus", "elogind"} {
|
||||||
if !v.runitServiceInstalled(service) {
|
if !v.runitServiceInstalled(service) {
|
||||||
v.log(fmt.Sprintf("Warning: %s runit service not found in %s; power/session actions may not work until %s is installed", service, voidRunitSvDir, service))
|
v.log(fmt.Sprintf("Warning: %s runit service not found in %s; power/session actions may not work until %s is installed", service, voidRunitSvDir, service))
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ func (c *GeoClueClient) startSignalPump() error {
|
|||||||
if err := c.dbusConn.AddMatchSignal(
|
if err := c.dbusConn.AddMatchSignal(
|
||||||
dbus.WithMatchObjectPath(c.clientPath),
|
dbus.WithMatchObjectPath(c.clientPath),
|
||||||
dbus.WithMatchInterface(dbusGeoClueClientInterface),
|
dbus.WithMatchInterface(dbusGeoClueClientInterface),
|
||||||
dbus.WithMatchSender(dbusGeoClueClientLocationUpdated),
|
dbus.WithMatchMember("LocationUpdated"),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
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)) {
|
func ensureGreetdPamRundir(sudoPassword string, logFunc func(string)) {
|
||||||
const pamPath = "/etc/pam.d/greetd"
|
const pamPath = "/etc/pam.d/greetd"
|
||||||
data, err := os.ReadFile(pamPath)
|
data, err := os.ReadFile(pamPath)
|
||||||
@@ -483,11 +529,23 @@ func execFromDesktopFile(path string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
inDesktopEntry := false
|
||||||
for line := range strings.SplitSeq(string(data), "\n") {
|
for line := range strings.SplitSeq(string(data), "\n") {
|
||||||
trimmed := strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
if strings.HasPrefix(trimmed, "Exec=") {
|
switch {
|
||||||
return strings.TrimSpace(trimmed[len("Exec="):]), nil
|
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)
|
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)
|
failHint = fmt.Sprintf("⚠ dms-greeter install failed. Install from AUR: %s -S greetd-dms-greeter-git", aurHelper)
|
||||||
installCmd = exec.CommandContext(ctx, aurHelper, "-S", "--noconfirm", "greetd-dms-greeter-git")
|
installCmd = exec.CommandContext(ctx, aurHelper, "-S", "--noconfirm", "greetd-dms-greeter-git")
|
||||||
case distros.FamilyVoid:
|
case distros.FamilyVoid:
|
||||||
failHint = "⚠ dms-greeter install failed. Add the DMS XBPS repo manually:\necho 'repository=https://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...")
|
logFunc("Adding DMS XBPS repository...")
|
||||||
if err := ensureVoidXBPSRepo(ctx, sudoPassword, "dms", distros.VoidDMSRepo); err != nil {
|
if err := ensureVoidXBPSRepo(ctx, sudoPassword, "dms", distros.VoidDMSRepo); err != nil {
|
||||||
logFunc(fmt.Sprintf("⚠ Failed to add DMS XBPS repository: %v", err))
|
logFunc(fmt.Sprintf("⚠ Failed to add DMS XBPS repository: %v", err))
|
||||||
@@ -1740,6 +1798,10 @@ func syncGreeterColorSource(homeDir, cacheDir string, state greeterThemeSyncStat
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
|
func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
|
||||||
|
if err := EnsureVoidLogindGreetdCommand(logFunc, sudoPassword); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get user home directory: %w", err)
|
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||||
@@ -2271,13 +2333,7 @@ vt = 1
|
|||||||
return fmt.Errorf("failed to read greetd config: %w", err)
|
return fmt.Errorf("failed to read greetd config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
wrapperCmd := resolveGreeterWrapperPath()
|
commandValue := buildGreetdCommand(resolveGreeterWrapperPath(), compositor, dmsPath, IsVoidLinux())
|
||||||
|
|
||||||
compositorLower := strings.ToLower(compositor)
|
|
||||||
commandValue := fmt.Sprintf("%s --command %s --cache-dir %s", wrapperCmd, compositorLower, GreeterCacheDir)
|
|
||||||
if dmsPath != "" {
|
|
||||||
commandValue = fmt.Sprintf("%s -p %s", commandValue, dmsPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
commandLine := fmt.Sprintf(`command = "%s"`, commandValue)
|
commandLine := fmt.Sprintf(`command = "%s"`, commandValue)
|
||||||
newConfig := upsertDefaultSession(configContent, greeterUser, commandLine)
|
newConfig := upsertDefaultSession(configContent, greeterUser, commandLine)
|
||||||
@@ -2289,6 +2345,84 @@ vt = 1
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildGreetdCommand(wrapperCmd, compositor, dmsPath string, useVoidLogind bool) string {
|
||||||
|
commandValue := fmt.Sprintf("%s --command %s --cache-dir %s", wrapperCmd, strings.ToLower(compositor), GreeterCacheDir)
|
||||||
|
if dmsPath != "" {
|
||||||
|
commandValue = fmt.Sprintf("%s -p %s", commandValue, dmsPath)
|
||||||
|
}
|
||||||
|
if useVoidLogind {
|
||||||
|
commandValue = "env LIBSEAT_BACKEND=logind DMS_VOID=1 " + commandValue
|
||||||
|
}
|
||||||
|
return commandValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureVoidLogindGreetdCommand migrates DMS greeter commands on Void.
|
||||||
|
func EnsureVoidLogindGreetdCommand(logFunc func(string), sudoPassword string) error {
|
||||||
|
if !IsVoidLinux() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = "/etc/greetd/config.toml"
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read greetd config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configContent := string(data)
|
||||||
|
command := extractDefaultSessionCommand(configContent)
|
||||||
|
if command == "" || !strings.Contains(command, "dms-greeter") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
migratedCommand := voidLogindGreeterCommand(command)
|
||||||
|
if migratedCommand == command {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
greeterUser := extractDefaultSessionUser(configContent)
|
||||||
|
if greeterUser == "" {
|
||||||
|
greeterUser = DetectGreeterUser()
|
||||||
|
}
|
||||||
|
newConfig := upsertDefaultSession(configContent, greeterUser, fmt.Sprintf(`command = "%s"`, migratedCommand))
|
||||||
|
return writeGreetdConfig(configPath, newConfig, logFunc, sudoPassword, "✓ Updated existing Void greeter to use elogind")
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractDefaultSessionCommand(configContent string) string {
|
||||||
|
inDefaultSession := false
|
||||||
|
for line := range strings.SplitSeq(configContent, "\n") {
|
||||||
|
if section, ok := parseTomlSection(line); ok {
|
||||||
|
inDefaultSession = section == "default_session"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !inDefaultSession {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed := stripTomlComment(line)
|
||||||
|
if !strings.HasPrefix(trimmed, "command =") && !strings.HasPrefix(trimmed, "command=") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(trimmed, "=", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if command := strings.Trim(strings.TrimSpace(parts[1]), `"`); command != "" {
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func voidLogindGreeterCommand(command string) string {
|
||||||
|
if strings.Contains(command, "LIBSEAT_BACKEND=logind") && strings.Contains(command, "DMS_VOID=1") {
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
return "env LIBSEAT_BACKEND=logind DMS_VOID=1 " + command
|
||||||
|
}
|
||||||
|
|
||||||
func stripConfigFlag(command string) string {
|
func stripConfigFlag(command string) string {
|
||||||
for _, flag := range []string{" -C ", " --config "} {
|
for _, flag := range []string{" -C ", " --config "} {
|
||||||
idx := strings.Index(command, flag)
|
idx := strings.Index(command, flag)
|
||||||
@@ -2430,7 +2564,11 @@ func EnableGreetd(sudoPassword string, logFunc func(string)) error {
|
|||||||
if !runitServiceInstalled("greetd") {
|
if !runitServiceInstalled("greetd") {
|
||||||
return fmt.Errorf("greetd service not found in %s; ensure greetd is installed", runitSvDir)
|
return fmt.Errorf("greetd service not found in %s; ensure greetd is installed", runitSvDir)
|
||||||
}
|
}
|
||||||
ensureRunitSeat(DetectGreeterUser(), sudoPassword, logFunc)
|
if IsVoidLinux() {
|
||||||
|
ensureVoidLogindGreeter(DetectGreeterUser(), sudoPassword, logFunc)
|
||||||
|
} else {
|
||||||
|
ensureRunitSeat(DetectGreeterUser(), sudoPassword, logFunc)
|
||||||
|
}
|
||||||
ensureGreetdPamRundir(sudoPassword, logFunc)
|
ensureGreetdPamRundir(sudoPassword, logFunc)
|
||||||
if err := enableRunitService("greetd", sudoPassword); err != nil {
|
if err := enableRunitService("greetd", sudoPassword); err != nil {
|
||||||
return fmt.Errorf("failed to enable greetd: %w", err)
|
return fmt.Errorf("failed to enable greetd: %w", err)
|
||||||
|
|||||||
@@ -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) {
|
func TestResolveGreeterAutoLoginState(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package greeter
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -93,18 +94,95 @@ func resolveSessionExecInDirs(sessionID string, dirs []string) (string, error) {
|
|||||||
return "", fmt.Errorf("session desktop file %q was not found", id)
|
return "", fmt.Errorf("session desktop file %q was not found", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseExecString splits a Desktop Entry Exec= value into argv without
|
||||||
|
// involving a shell, mirroring quickshell's DesktopEntry::parseExecString
|
||||||
|
// (string quoting, value escapes, field code stripping).
|
||||||
|
func parseExecString(execLine string) []string {
|
||||||
|
var args []string
|
||||||
|
var cur strings.Builder
|
||||||
|
inString := false
|
||||||
|
escape := 0
|
||||||
|
percent := false
|
||||||
|
|
||||||
|
for _, c := range execLine {
|
||||||
|
switch {
|
||||||
|
case escape == 0 && c == '\\':
|
||||||
|
escape = 1
|
||||||
|
case inString:
|
||||||
|
switch {
|
||||||
|
case c == '\\':
|
||||||
|
escape++
|
||||||
|
if escape == 4 {
|
||||||
|
cur.WriteByte('\\')
|
||||||
|
escape = 0
|
||||||
|
}
|
||||||
|
case escape == 2:
|
||||||
|
cur.WriteRune(c)
|
||||||
|
escape = 0
|
||||||
|
case escape != 0:
|
||||||
|
switch c {
|
||||||
|
case 's':
|
||||||
|
cur.WriteByte(' ')
|
||||||
|
case 'n':
|
||||||
|
cur.WriteByte('\n')
|
||||||
|
case 't':
|
||||||
|
cur.WriteByte('\t')
|
||||||
|
case 'r':
|
||||||
|
cur.WriteByte('\r')
|
||||||
|
default:
|
||||||
|
cur.WriteRune(c)
|
||||||
|
}
|
||||||
|
escape = 0
|
||||||
|
case c == '"' || c == '\'':
|
||||||
|
inString = false
|
||||||
|
default:
|
||||||
|
cur.WriteRune(c)
|
||||||
|
}
|
||||||
|
case escape != 0:
|
||||||
|
cur.WriteRune(c)
|
||||||
|
escape = 0
|
||||||
|
case percent:
|
||||||
|
if c == '%' {
|
||||||
|
cur.WriteByte('%')
|
||||||
|
}
|
||||||
|
percent = false
|
||||||
|
case c == '%':
|
||||||
|
percent = true
|
||||||
|
case c == '"' || c == '\'':
|
||||||
|
inString = true
|
||||||
|
case c == ' ':
|
||||||
|
if cur.Len() > 0 {
|
||||||
|
args = append(args, cur.String())
|
||||||
|
cur.Reset()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
cur.WriteRune(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cur.Len() > 0 {
|
||||||
|
args = append(args, cur.String())
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
func LaunchSessionByID(sessionID string) error {
|
func LaunchSessionByID(sessionID string) error {
|
||||||
execLine, err := ResolveSessionExec(sessionID)
|
execLine, err := ResolveSessionExec(sessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
execLine = strings.TrimSpace(stripDesktopExecCodes(execLine))
|
|
||||||
if execLine == "" {
|
argv := parseExecString(strings.TrimSpace(execLine))
|
||||||
|
if len(argv) == 0 {
|
||||||
return fmt.Errorf("session %q has an empty Exec command", sessionID)
|
return fmt.Errorf("session %q has an empty Exec command", sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolved, err := exec.LookPath(argv[0])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("session %q command %q not found: %w", sessionID, argv[0], err)
|
||||||
|
}
|
||||||
|
|
||||||
env := append(os.Environ(), "XDG_SESSION_TYPE=wayland")
|
env := append(os.Environ(), "XDG_SESSION_TYPE=wayland")
|
||||||
return syscall.Exec("/bin/sh", []string{"sh", "-c", "exec " + execLine}, env)
|
return syscall.Exec(resolved, argv, env)
|
||||||
}
|
}
|
||||||
|
|
||||||
func LaunchSessionFromMemory(cacheDir, homeDir string) error {
|
func LaunchSessionFromMemory(cacheDir, homeDir string) error {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package greeter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseExecString(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
exec string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"plain", "niri --session", []string{"niri", "--session"}},
|
||||||
|
{"extra spaces", "niri --session", []string{"niri", "--session"}},
|
||||||
|
{"double quoted arg", `env "with space" run`, []string{"env", "with space", "run"}},
|
||||||
|
{"single quoted arg", `env 'with space' run`, []string{"env", "with space", "run"}},
|
||||||
|
{"escaped quote in quotes", `sh "say \\"hi\\""`, []string{"sh", `say "hi"`}},
|
||||||
|
{"field code dropped", "gnome-session %U", []string{"gnome-session"}},
|
||||||
|
{"field code mid-arg", "app --url=%u --run", []string{"app", "--url=", "--run"}},
|
||||||
|
{"literal percent", "app 100%% done", []string{"app", "100%", "done"}},
|
||||||
|
{"shell metachars stay literal", "sh -c $(reboot); echo", []string{"sh", "-c", "$(reboot);", "echo"}},
|
||||||
|
{"empty", "", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := parseExecString(tt.exec); !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Fatalf("parseExecString(%q) = %#v, want %#v", tt.exec, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecFromDesktopFileOnlyReadsDesktopEntryGroup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "example.desktop")
|
||||||
|
writeTestFile(t, path, `[Desktop Action other]
|
||||||
|
Exec=/wrong/binary
|
||||||
|
|
||||||
|
[Desktop Entry]
|
||||||
|
Name=Example
|
||||||
|
Exec = /right/binary --flag
|
||||||
|
`)
|
||||||
|
|
||||||
|
got, err := execFromDesktopFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("execFromDesktopFile returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got != "/right/binary --flag" {
|
||||||
|
t.Fatalf("execFromDesktopFile = %q, want %q", got, "/right/binary --flag")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -299,6 +299,9 @@ type hyprlandOverrideBind struct {
|
|||||||
Options map[string]any
|
Options map[string]any
|
||||||
// Unbind: negative override (hl.unbind only, no rebind).
|
// Unbind: negative override (hl.unbind only, no rebind).
|
||||||
Unbind bool
|
Unbind bool
|
||||||
|
// RawLuaAction: Action is a custom hl.* Lua expression round-tripped from an
|
||||||
|
// existing Lua override; re-emit it verbatim instead of quoting it.
|
||||||
|
RawLuaAction bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HyprlandProvider) ensureWritableConfig() error {
|
func (h *HyprlandProvider) ensureWritableConfig() error {
|
||||||
@@ -1046,18 +1049,26 @@ func luaActionStringFromHyprlangAction(action string) string {
|
|||||||
if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok {
|
if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok {
|
||||||
return expr
|
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)
|
d, p := luaExprToDispatcherParams(expr)
|
||||||
|
if d == expr && p == "" {
|
||||||
|
return expr, true
|
||||||
|
}
|
||||||
if d == "exec" && p != "" && !strings.HasPrefix(p, "hyprctl dispatch lua:") {
|
if d == "exec" && p != "" && !strings.HasPrefix(p, "hyprctl dispatch lua:") {
|
||||||
return "exec " + p
|
return "exec " + p, false
|
||||||
}
|
}
|
||||||
if p != "" {
|
if p != "" {
|
||||||
return d + " " + p
|
return d + " " + p, false
|
||||||
}
|
}
|
||||||
return d
|
return d, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func luaBindOptions(bind *hyprlandOverrideBind) []string {
|
func luaBindOptions(bind *hyprlandOverrideBind) []string {
|
||||||
@@ -1075,20 +1086,25 @@ func luaBindOptions(bind *hyprlandOverrideBind) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
|
func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
|
||||||
key := formatLuaBindKey(bind.Key)
|
key := strconv.Quote(formatLuaBindKey(bind.Key))
|
||||||
if bind.Unbind {
|
if bind.Unbind {
|
||||||
fmt.Fprintf(sb, `hl.unbind("%s")`, key)
|
fmt.Fprintf(sb, `hl.unbind(%s)`, key)
|
||||||
sb.WriteByte('\n')
|
sb.WriteByte('\n')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
expr := luaActionStringFromHyprlangAction(bind.Action)
|
var expr string
|
||||||
|
if bind.RawLuaAction {
|
||||||
|
expr = bind.Action
|
||||||
|
} else {
|
||||||
|
expr = luaActionStringFromHyprlangAction(bind.Action)
|
||||||
|
}
|
||||||
opts := luaBindOptions(bind)
|
opts := luaBindOptions(bind)
|
||||||
fmt.Fprintf(sb, `hl.unbind("%s")`, key)
|
fmt.Fprintf(sb, `hl.unbind(%s)`, key)
|
||||||
sb.WriteByte('\n')
|
sb.WriteByte('\n')
|
||||||
if len(opts) > 0 {
|
if len(opts) > 0 {
|
||||||
fmt.Fprintf(sb, `hl.bind("%s", %s, { %s })`, key, expr, strings.Join(opts, ", "))
|
fmt.Fprintf(sb, `hl.bind(%s, %s, { %s })`, key, expr, strings.Join(opts, ", "))
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(sb, `hl.bind("%s", %s)`, key, expr)
|
fmt.Fprintf(sb, `hl.bind(%s, %s)`, key, expr)
|
||||||
}
|
}
|
||||||
sb.WriteByte('\n')
|
sb.WriteByte('\n')
|
||||||
}
|
}
|
||||||
@@ -1104,17 +1120,18 @@ func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) {
|
|||||||
}
|
}
|
||||||
internalKey := luaKeyComboToInternalKey(kbc)
|
internalKey := luaKeyComboToInternalKey(kbc)
|
||||||
|
|
||||||
action := luaExprToInternalAction(actionExpr)
|
action, isRawLua := luaExprToInternalAction(actionExpr)
|
||||||
flags := luaBindOptFlags(optSuffix)
|
flags := luaBindOptFlags(optSuffix)
|
||||||
description := luaBindOptDescription(optSuffix)
|
description := luaBindOptDescription(optSuffix)
|
||||||
if description == "" {
|
if description == "" {
|
||||||
description = luaLineTrailingComment(line)
|
description = luaLineTrailingComment(line)
|
||||||
}
|
}
|
||||||
return &hyprlandOverrideBind{
|
return &hyprlandOverrideBind{
|
||||||
Key: internalKey,
|
Key: internalKey,
|
||||||
Action: action,
|
Action: action,
|
||||||
Description: description,
|
Description: description,
|
||||||
Flags: flags,
|
Flags: flags,
|
||||||
|
RawLuaAction: isRawLua,
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,9 +147,10 @@ hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"), { descripti
|
|||||||
func TestWriteLuaBindLineLeavesCustomLuaDispatcherRaw(t *testing.T) {
|
func TestWriteLuaBindLineLeavesCustomLuaDispatcherRaw(t *testing.T) {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
writeLuaBindLine(&sb, &hyprlandOverrideBind{
|
writeLuaBindLine(&sb, &hyprlandOverrideBind{
|
||||||
Key: "Super+u",
|
Key: "Super+u",
|
||||||
Action: "hl.dsp.no_op()",
|
Action: "hl.dsp.no_op()",
|
||||||
Description: "Custom Lua",
|
Description: "Custom Lua",
|
||||||
|
RawLuaAction: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
want := `hl.unbind("SUPER + U")
|
want := `hl.unbind("SUPER + U")
|
||||||
@@ -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) {
|
func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
action string
|
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()")
|
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 {
|
if got != want {
|
||||||
t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want)
|
t.Fatalf("luaActionStringFromHyprlangAction() = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
if strings.Contains(got, "hl.dispatch") || strings.Contains(got, "hyprctl dispatch") {
|
|
||||||
t.Fatalf("expected custom Lua dispatcher expression to stay raw, got %q", got)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReadLuaOverrideMigratesTrailingCommentToDescription(t *testing.T) {
|
func TestReadLuaOverrideMigratesTrailingCommentToDescription(t *testing.T) {
|
||||||
|
|||||||
@@ -260,15 +260,15 @@ func Run(opts Options) error {
|
|||||||
return buildErr
|
return buildErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.SyncModeWithPortal {
|
||||||
|
syncColorScheme(opts.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
if !changed {
|
if !changed {
|
||||||
log.Info("No color changes detected, skipping refresh")
|
log.Info("No color changes detected, skipping refresh")
|
||||||
return ErrNoChanges
|
return ErrNoChanges
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.SyncModeWithPortal {
|
|
||||||
syncColorScheme(opts.Mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("Done")
|
log.Info("Done")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1006,6 +1006,13 @@ func syncColorScheme(mode ColorMode) {
|
|||||||
scheme = "default"
|
scheme = "default"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cur, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme"); err == nil {
|
||||||
|
cur = strings.Trim(cur, "'")
|
||||||
|
if cur == scheme || (mode == ColorModeLight && cur == "prefer-light") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", scheme); err != nil {
|
if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", scheme); err != nil {
|
||||||
log.Warnf("Failed to sync color-scheme: %v", err)
|
log.Warnf("Failed to sync color-scheme: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+171
-47
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -32,6 +33,32 @@ const (
|
|||||||
DankshellU2FPamPath = "/etc/pam.d/dankshell-u2f"
|
DankshellU2FPamPath = "/etc/pam.d/dankshell-u2f"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// lockscreenPamEntryCandidates are the /etc/pam.d entry-point services tried in
|
||||||
|
// order. "login" is first so systems that ship it behave exactly as before; the
|
||||||
|
// rest cover distros (or minimal installs) with no /etc/pam.d/login.
|
||||||
|
// lockscreenPamBaseDirs mirrors libpam's search order: /etc overrides, then the
|
||||||
|
// vendor dir (/usr/lib) and the stateless-distro default (/usr/share).
|
||||||
|
var lockscreenPamBaseDirs = []string{"/etc/pam.d", "/usr/lib/pam.d", "/usr/share/pam.d"}
|
||||||
|
|
||||||
|
// Standalone auth+account services, most universal first. login exists almost
|
||||||
|
// everywhere (util-linux); system-* cover Fedora/Arch/Gentoo/SUSE-Leap.
|
||||||
|
var lockscreenPamEntryCandidates = []string{
|
||||||
|
"login",
|
||||||
|
"system-auth",
|
||||||
|
"system-login",
|
||||||
|
"system-local-login",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for distros with no standalone login service, only shared building
|
||||||
|
// blocks: openSUSE/Debian (common-*), Alpine/postmarketOS (base-*).
|
||||||
|
var lockscreenPamSharedIncludePairs = []struct {
|
||||||
|
auth string
|
||||||
|
account string
|
||||||
|
}{
|
||||||
|
{auth: "common-auth", account: "common-account"},
|
||||||
|
{auth: "base-auth", account: "base-account"},
|
||||||
|
}
|
||||||
|
|
||||||
var includedPamAuthFiles = []string{
|
var includedPamAuthFiles = []string{
|
||||||
"system-auth",
|
"system-auth",
|
||||||
"common-auth",
|
"common-auth",
|
||||||
@@ -75,10 +102,50 @@ type lockscreenPamIncludeDirective struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type lockscreenPamResolver struct {
|
type lockscreenPamResolver struct {
|
||||||
pamDir string
|
baseDirs []string
|
||||||
readFile func(string) ([]byte, error)
|
readFile func(string) ([]byte, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// locate resolves a service/include name across baseDirs (libpam vendor-dir
|
||||||
|
// fallback). Targets may not escape the base dirs.
|
||||||
|
func (r lockscreenPamResolver) locate(target string) (string, error) {
|
||||||
|
target = strings.TrimSpace(target)
|
||||||
|
if target == "" {
|
||||||
|
return "", fmt.Errorf("empty PAM include target")
|
||||||
|
}
|
||||||
|
|
||||||
|
if filepath.IsAbs(target) {
|
||||||
|
clean := filepath.Clean(target)
|
||||||
|
for _, dir := range r.baseDirs {
|
||||||
|
if filepath.Dir(clean) == filepath.Clean(dir) {
|
||||||
|
return clean, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("unsupported PAM include outside PAM dirs: %s", target)
|
||||||
|
}
|
||||||
|
|
||||||
|
clean := filepath.Clean(target)
|
||||||
|
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) {
|
||||||
|
return "", fmt.Errorf("invalid PAM include target: %s", target)
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstErr error
|
||||||
|
for _, dir := range r.baseDirs {
|
||||||
|
path := filepath.Join(filepath.Clean(dir), clean)
|
||||||
|
if _, err := r.readFile(path); err != nil {
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = os.ErrNotExist
|
||||||
|
}
|
||||||
|
return "", firstErr
|
||||||
|
}
|
||||||
|
|
||||||
func defaultSyncDeps() syncDeps {
|
func defaultSyncDeps() syncDeps {
|
||||||
return syncDeps{
|
return syncDeps{
|
||||||
pamDir: "/etc/pam.d",
|
pamDir: "/etc/pam.d",
|
||||||
@@ -375,32 +442,10 @@ func parseLockscreenPamIncludeDirective(trimmed string, inheritedFilter string)
|
|||||||
return lockscreenPamIncludeDirective{}, false
|
return lockscreenPamIncludeDirective{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveLockscreenPamIncludePath(pamDir, target string) (string, error) {
|
|
||||||
if strings.TrimSpace(target) == "" {
|
|
||||||
return "", fmt.Errorf("empty PAM include target")
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanPamDir := filepath.Clean(pamDir)
|
|
||||||
if filepath.IsAbs(target) {
|
|
||||||
cleanTarget := filepath.Clean(target)
|
|
||||||
if filepath.Dir(cleanTarget) != cleanPamDir {
|
|
||||||
return "", fmt.Errorf("unsupported PAM include outside %s: %s", cleanPamDir, target)
|
|
||||||
}
|
|
||||||
return cleanTarget, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanTarget := filepath.Clean(target)
|
|
||||||
if cleanTarget == "." || cleanTarget == ".." || strings.HasPrefix(cleanTarget, ".."+string(os.PathSeparator)) {
|
|
||||||
return "", fmt.Errorf("invalid PAM include target: %s", target)
|
|
||||||
}
|
|
||||||
|
|
||||||
return filepath.Join(cleanPamDir, cleanTarget), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r lockscreenPamResolver) resolveService(serviceName string, filterType string, stack []string) ([]string, error) {
|
func (r lockscreenPamResolver) resolveService(serviceName string, filterType string, stack []string) ([]string, error) {
|
||||||
path, err := resolveLockscreenPamIncludePath(r.pamDir, serviceName)
|
path, err := r.locate(serviceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("failed to read PAM file %s: %w", serviceName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, seen := range stack {
|
for _, seen := range stack {
|
||||||
@@ -458,29 +503,73 @@ func (r lockscreenPamResolver) resolveService(serviceName string, filterType str
|
|||||||
return resolved, nil
|
return resolved, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildManagedLockscreenPamContent(pamDir string, readFile func(string) ([]byte, error)) (string, error) {
|
func resolvedLinesHaveAuth(lines []string) bool {
|
||||||
resolver := lockscreenPamResolver{
|
for _, line := range lines {
|
||||||
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 {
|
|
||||||
if pamDirectiveType(strings.TrimSpace(line)) == "auth" {
|
if pamDirectiveType(strings.TrimSpace(line)) == "auth" {
|
||||||
hasAuth = true
|
return true
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !hasAuth {
|
return false
|
||||||
return "", fmt.Errorf("no auth directives remained after filtering %s", filepath.Join(pamDir, "login"))
|
}
|
||||||
|
|
||||||
|
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
|
var b strings.Builder
|
||||||
@@ -494,6 +583,41 @@ func buildManagedLockscreenPamContent(pamDir string, readFile func(string) ([]by
|
|||||||
return b.String(), nil
|
return b.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UserLockscreenPamService = "dankshell"
|
||||||
|
|
||||||
|
func UserLockscreenPamDir() string {
|
||||||
|
return filepath.Join(utils.XDGStateHome(), "DankMaterialShell", "pam")
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteUserLockscreenPamConfig resolves the distro's real auth stack into a
|
||||||
|
// self-contained lock-screen service under the user state dir, unprivileged
|
||||||
|
// (reads world-readable PAM dirs, writes the user's own state dir). Rewrites
|
||||||
|
// only on change to avoid inotify churn. Returns the written path.
|
||||||
|
func WriteUserLockscreenPamConfig(logFunc func(string)) (string, error) {
|
||||||
|
content, err := buildManagedLockscreenPamContent(lockscreenPamBaseDirs, os.ReadFile)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to resolve system PAM auth stack: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := UserLockscreenPamDir()
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(dir, UserLockscreenPamService)
|
||||||
|
if existing, err := os.ReadFile(path); err == nil && string(existing) == content {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if logFunc != nil {
|
||||||
|
logFunc("✓ Wrote lock-screen PAM config " + path)
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
func buildManagedLockscreenU2FPamContent() string {
|
func buildManagedLockscreenU2FPamContent() string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("#%PAM-1.0\n")
|
b.WriteString("#%PAM-1.0\n")
|
||||||
@@ -522,7 +646,7 @@ func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string,
|
|||||||
return fmt.Errorf("failed to read %s: %w", deps.dankshellPath, err)
|
return fmt.Errorf("failed to read %s: %w", deps.dankshellPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := buildManagedLockscreenPamContent(deps.pamDir, deps.readFile)
|
content, err := buildManagedLockscreenPamContent([]string{deps.pamDir}, deps.readFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to build %s from %s: %w", deps.dankshellPath, filepath.Join(deps.pamDir, "login"), err)
|
return fmt.Errorf("failed to build %s from %s: %w", deps.dankshellPath, filepath.Join(deps.pamDir, "login"), err)
|
||||||
}
|
}
|
||||||
@@ -725,7 +849,7 @@ func syncGreeterPamConfigWithDeps(logFunc func(string), sudoPassword string, set
|
|||||||
if wantFprint || wantU2f {
|
if wantFprint || wantU2f {
|
||||||
blockLines := []string{GreeterPamManagedBlockStart}
|
blockLines := []string{GreeterPamManagedBlockStart}
|
||||||
if wantFprint {
|
if wantFprint {
|
||||||
blockLines = append(blockLines, "auth sufficient pam_fprintd.so max-tries=1 timeout=5")
|
blockLines = append(blockLines, "auth sufficient pam_fprintd.so max-tries=2 timeout=10")
|
||||||
}
|
}
|
||||||
if wantU2f {
|
if wantU2f {
|
||||||
blockLines = append(blockLines, "auth sufficient pam_u2f.so cue nouserok timeout=10")
|
blockLines = append(blockLines, "auth sufficient pam_u2f.so cue nouserok timeout=10")
|
||||||
|
|||||||
@@ -246,6 +246,31 @@ func TestBuildManagedLockscreenPamContent(t *testing.T) {
|
|||||||
"session optional pam_lastlog.so silent": 1,
|
"session optional pam_lastlog.so silent": 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "falls back to system-auth when login is absent",
|
||||||
|
files: map[string]string{
|
||||||
|
"system-auth": "#%PAM-1.0\nauth sufficient pam_unix.so try_first_pass nullok\naccount required pam_unix.so\n",
|
||||||
|
},
|
||||||
|
wantContains: []string{
|
||||||
|
"auth sufficient pam_unix.so try_first_pass nullok",
|
||||||
|
"account required pam_unix.so",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no usable service when none of the candidates exist",
|
||||||
|
files: map[string]string{
|
||||||
|
"other": "#%PAM-1.0\nauth required pam_deny.so\n",
|
||||||
|
},
|
||||||
|
wantErr: "no usable PAM auth service found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "existing login with bad include is authoritative and does not fall back",
|
||||||
|
files: map[string]string{
|
||||||
|
"login": "#%PAM-1.0\nauth include missing-auth\n",
|
||||||
|
"system-auth": "#%PAM-1.0\nauth sufficient pam_unix.so\naccount required pam_unix.so\n",
|
||||||
|
},
|
||||||
|
wantErr: "failed to read PAM file",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "missing include fails",
|
name: "missing include fails",
|
||||||
files: map[string]string{
|
files: map[string]string{
|
||||||
@@ -281,7 +306,7 @@ func TestBuildManagedLockscreenPamContent(t *testing.T) {
|
|||||||
env.writePamFile(t, name, content)
|
env.writePamFile(t, name, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := buildManagedLockscreenPamContent(env.pamDir, os.ReadFile)
|
content, err := buildManagedLockscreenPamContent([]string{env.pamDir}, os.ReadFile)
|
||||||
if tt.wantErr != "" {
|
if tt.wantErr != "" {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
||||||
@@ -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) {
|
func TestSyncLockscreenPamConfigWithDeps(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -512,7 +723,7 @@ func TestSyncGreeterPamConfigWithDeps(t *testing.T) {
|
|||||||
got := readFileString(t, env.greetdPath)
|
got := readFileString(t, env.greetdPath)
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
GreeterPamManagedBlockStart,
|
GreeterPamManagedBlockStart,
|
||||||
"auth sufficient pam_fprintd.so max-tries=1 timeout=5",
|
"auth sufficient pam_fprintd.so max-tries=2 timeout=10",
|
||||||
"auth sufficient pam_u2f.so cue nouserok timeout=10",
|
"auth sufficient pam_u2f.so cue nouserok timeout=10",
|
||||||
GreeterPamManagedBlockEnd,
|
GreeterPamManagedBlockEnd,
|
||||||
} {
|
} {
|
||||||
@@ -629,7 +840,7 @@ func TestSyncAuthConfigWithDeps(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
greetd := readFileString(t, env.greetdPath)
|
greetd := readFileString(t, env.greetdPath)
|
||||||
if !strings.Contains(greetd, "auth sufficient pam_fprintd.so max-tries=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)
|
t.Fatalf("expected greetd PAM to receive fingerprint auth block:\n%s", greetd)
|
||||||
}
|
}
|
||||||
if strings.Contains(greetd, "auth sufficient pam_u2f.so cue nouserok timeout=10") {
|
if strings.Contains(greetd, "auth sufficient pam_u2f.so cue nouserok timeout=10") {
|
||||||
|
|||||||
@@ -64,7 +64,20 @@ func (m *Manager) findInstalledPath(pluginID string) (string, error) {
|
|||||||
return m.findInDir(systemDir, pluginID)
|
return m.findInDir(systemDir, pluginID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isSafePluginPathComponent rejects ids that aren't a single path component,
|
||||||
|
// so filepath.Join can't resolve (and later RemoveAll) outside the plugins dir
|
||||||
|
func isSafePluginPathComponent(s string) bool {
|
||||||
|
if s == "" || s == "." || s == ".." {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !strings.ContainsAny(s, "/\\")
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) findInDir(dir, pluginID string) (string, error) {
|
func (m *Manager) findInDir(dir, pluginID string) (string, error) {
|
||||||
|
if !isSafePluginPathComponent(pluginID) {
|
||||||
|
return "", fmt.Errorf("invalid plugin id: %q", pluginID)
|
||||||
|
}
|
||||||
|
|
||||||
// First, check if folder with exact ID name exists
|
// First, check if folder with exact ID name exists
|
||||||
exactPath := filepath.Join(dir, pluginID)
|
exactPath := filepath.Join(dir, pluginID)
|
||||||
if exists, _ := afero.DirExists(m.fs, exactPath); exists {
|
if exists, _ := afero.DirExists(m.fs, exactPath); exists {
|
||||||
@@ -507,6 +520,10 @@ func (m *Manager) findInstalledPathByIDOrName(idOrName string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) findInDirByIDOrName(dir, idOrName string) (string, error) {
|
func (m *Manager) findInDirByIDOrName(dir, idOrName string) (string, error) {
|
||||||
|
if !isSafePluginPathComponent(idOrName) {
|
||||||
|
return "", fmt.Errorf("invalid plugin id/name: %q", idOrName)
|
||||||
|
}
|
||||||
|
|
||||||
// Check exact folder name match first
|
// Check exact folder name match first
|
||||||
exactPath := filepath.Join(dir, idOrName)
|
exactPath := filepath.Join(dir, idOrName)
|
||||||
if exists, _ := afero.DirExists(m.fs, exactPath); exists {
|
if exists, _ := afero.DirExists(m.fs, exactPath); exists {
|
||||||
|
|||||||
@@ -135,15 +135,14 @@ func EscapeSingleQuotes(s string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MakeCommand returns a bash command string that runs `command` with the
|
// MakeCommand returns a bash command string that runs `command` with the
|
||||||
// detected tool. When the tool supports stdin passwords and password is
|
// detected tool, prompting interactively on a TTY where applicable. The
|
||||||
// non-empty, the password is piped in. Otherwise the tool is invoked with
|
// sudo-with-password case lives in ExecCommand, which pipes the password via
|
||||||
// no non-interactive flag so that an interactive TTY prompt is still
|
// stdin so it never lands in argv.
|
||||||
// possible for CLI callers.
|
|
||||||
//
|
//
|
||||||
// If detection fails, the returned shell string exits 1 with an error
|
// If detection fails, the returned shell string exits 1 with an error
|
||||||
// message so callers that treat the *exec.Cmd as infallible still fail
|
// message so callers that treat the *exec.Cmd as infallible still fail
|
||||||
// deterministically.
|
// deterministically.
|
||||||
func MakeCommand(password, command string) string {
|
func MakeCommand(command string) string {
|
||||||
t, err := Detect()
|
t, err := Detect()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return failingShell(err)
|
return failingShell(err)
|
||||||
@@ -151,9 +150,6 @@ func MakeCommand(password, command string) string {
|
|||||||
|
|
||||||
switch t {
|
switch t {
|
||||||
case ToolSudo:
|
case ToolSudo:
|
||||||
if password != "" {
|
|
||||||
return fmt.Sprintf("echo '%s' | sudo -S %s", EscapeSingleQuotes(password), command)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("sudo %s", command)
|
return fmt.Sprintf("sudo %s", command)
|
||||||
case ToolDoas:
|
case ToolDoas:
|
||||||
return fmt.Sprintf("doas sh -c '%s'", EscapeSingleQuotes(command))
|
return fmt.Sprintf("doas sh -c '%s'", EscapeSingleQuotes(command))
|
||||||
@@ -166,9 +162,19 @@ func MakeCommand(password, command string) string {
|
|||||||
|
|
||||||
// ExecCommand builds an exec.Cmd that runs `command` as root via the
|
// ExecCommand builds an exec.Cmd that runs `command` as root via the
|
||||||
// detected tool. Detection errors surface at Run() time as a failing
|
// detected tool. Detection errors surface at Run() time as a failing
|
||||||
// command writing a clear error to stderr.
|
// 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 {
|
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.
|
// 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 }
|
||||||
@@ -747,12 +747,16 @@ func (s *Screenshoter) processFrame(frame *wlr_screencopy.ZwlrScreencopyFrameV1,
|
|||||||
bpp := format.BytesPerPixel()
|
bpp := format.BytesPerPixel()
|
||||||
if int(e.Stride) < int(e.Width)*bpp {
|
if int(e.Stride) < int(e.Width)*bpp {
|
||||||
log.Error("invalid stride from compositor", "stride", e.Stride, "width", e.Width, "bpp", bpp)
|
log.Error("invalid stride from compositor", "stride", e.Stride, "width", e.Width, "bpp", bpp)
|
||||||
|
// bail out here or the dispatch loop waits forever on a ready/failed
|
||||||
|
// event that never comes (frame.Copy is never called)
|
||||||
|
failed = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
buf, err = CreateShmBuffer(int(e.Width), int(e.Height), int(e.Stride))
|
buf, err = CreateShmBuffer(int(e.Width), int(e.Height), int(e.Stride))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("failed to create buffer", "err", err)
|
log.Error("failed to create buffer", "err", err)
|
||||||
|
failed = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
buf.Format = format
|
buf.Format = format
|
||||||
@@ -771,6 +775,7 @@ func (s *Screenshoter) processFrame(frame *wlr_screencopy.ZwlrScreencopyFrameV1,
|
|||||||
pool, err = s.shm.CreatePool(buf.Fd(), int32(buf.Size()))
|
pool, err = s.shm.CreatePool(buf.Fd(), int32(buf.Size()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("failed to create pool", "err", err)
|
log.Error("failed to create pool", "err", err)
|
||||||
|
failed = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,6 +784,7 @@ func (s *Screenshoter) processFrame(frame *wlr_screencopy.ZwlrScreencopyFrameV1,
|
|||||||
pool.Destroy()
|
pool.Destroy()
|
||||||
pool = nil
|
pool = nil
|
||||||
log.Error("failed to create wl_buffer", "err", err)
|
log.Error("failed to create wl_buffer", "err", err)
|
||||||
|
failed = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,20 +138,22 @@ func handleCopyEntry(conn net.Conn, req models.Request, m *Manager) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filePath := m.EntryToFile(entry)
|
if entry.AltMimeType == "" {
|
||||||
if filePath != "" {
|
filePath := m.EntryToFile(entry)
|
||||||
if err := m.CopyFile(filePath); err != nil {
|
if filePath != "" {
|
||||||
models.RespondError(conn, req.ID, err.Error())
|
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
|
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())
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,7 +279,29 @@ func (m *Manager) setupDataDeviceSync() {
|
|||||||
}
|
}
|
||||||
w.Close()
|
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 {
|
if err := dataMgr.GetDataDeviceWithProxy(dataDevice, m.seat); err != nil {
|
||||||
@@ -324,22 +346,42 @@ func (m *Manager) releaseCurrentSource() {
|
|||||||
source.Destroy()
|
source.Destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) readAndStore(r *os.File, mimeType string) {
|
func readPipeTimeout(r *os.File) []byte {
|
||||||
defer r.Close()
|
|
||||||
|
|
||||||
cfg := m.getConfig()
|
|
||||||
|
|
||||||
done := make(chan []byte, 1)
|
done := make(chan []byte, 1)
|
||||||
go func() {
|
go func() {
|
||||||
data, _ := io.ReadAll(r)
|
data, _ := io.ReadAll(r)
|
||||||
done <- data
|
done <- data
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var data []byte
|
|
||||||
select {
|
select {
|
||||||
case data = <-done:
|
case data := <-done:
|
||||||
|
return data
|
||||||
case <-time.After(500 * time.Millisecond):
|
case <-time.After(500 * time.Millisecond):
|
||||||
return
|
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 {
|
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 {
|
if !cfg.Disabled && m.db != nil {
|
||||||
m.storeClipboardEntry(data, mimeType)
|
m.storeClipboardEntry(data, mimeType, altData, altMime)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.updateState()
|
m.updateState()
|
||||||
m.notifySubscribers()
|
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 mimeType == "text/uri-list" {
|
||||||
if imgData, imgMime, ok := m.tryReadImageFromURI(data); ok {
|
if imgData, imgMime, ok := m.tryReadImageFromURI(data); ok {
|
||||||
data = imgData
|
data = imgData
|
||||||
@@ -366,11 +408,13 @@ func (m *Manager) storeClipboardEntry(data []byte, mimeType string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entry := Entry{
|
entry := Entry{
|
||||||
Data: data,
|
Data: data,
|
||||||
MimeType: mimeType,
|
MimeType: mimeType,
|
||||||
Size: len(data),
|
Size: len(data),
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
IsImage: m.isImageMimeType(mimeType),
|
IsImage: m.isImageMimeType(mimeType),
|
||||||
|
AltData: altData,
|
||||||
|
AltMimeType: altMime,
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
@@ -483,6 +527,12 @@ func encodeEntry(e Entry) ([]byte, error) {
|
|||||||
} else {
|
} else {
|
||||||
buf.WriteByte(0)
|
buf.WriteByte(0)
|
||||||
}
|
}
|
||||||
|
if e.AltMimeType != "" {
|
||||||
|
binary.Write(buf, binary.BigEndian, uint32(len(e.AltMimeType)))
|
||||||
|
buf.WriteString(e.AltMimeType)
|
||||||
|
binary.Write(buf, binary.BigEndian, uint32(len(e.AltData)))
|
||||||
|
buf.Write(e.AltData)
|
||||||
|
}
|
||||||
|
|
||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
@@ -547,6 +597,21 @@ func decodeEntryFields(data []byte, withData bool) (Entry, error) {
|
|||||||
e.Pinned = pinnedByte == 1
|
e.Pinned = pinnedByte == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if buf.Len() >= 4 {
|
||||||
|
var altMimeLen uint32
|
||||||
|
binary.Read(buf, binary.BigEndian, &altMimeLen)
|
||||||
|
altMimeBytes := make([]byte, altMimeLen)
|
||||||
|
buf.Read(altMimeBytes)
|
||||||
|
e.AltMimeType = string(altMimeBytes)
|
||||||
|
|
||||||
|
var altDataLen uint32
|
||||||
|
binary.Read(buf, binary.BigEndian, &altDataLen)
|
||||||
|
if withData {
|
||||||
|
e.AltData = make([]byte, altDataLen)
|
||||||
|
buf.Read(e.AltData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,10 +628,27 @@ func computeHash(data []byte) uint64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func extractHash(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 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 {
|
func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
|
||||||
@@ -611,6 +693,23 @@ func (m *Manager) selectMimeType(mimes []string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var altTextMimeTypes = []string{
|
||||||
|
"text/plain;charset=utf-8",
|
||||||
|
"text/plain",
|
||||||
|
"UTF8_STRING",
|
||||||
|
"STRING",
|
||||||
|
"TEXT",
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectAltTextMimeType(mimes []string) string {
|
||||||
|
for _, pref := range altTextMimeTypes {
|
||||||
|
if slices.Contains(mimes, pref) {
|
||||||
|
return pref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) isImageMimeType(mime string) bool {
|
func (m *Manager) isImageMimeType(mime string) bool {
|
||||||
return strings.HasPrefix(mime, "image/")
|
return strings.HasPrefix(mime, "image/")
|
||||||
}
|
}
|
||||||
@@ -929,13 +1028,15 @@ func (m *Manager) CreateHistoryEntryFromPinned(pinnedEntry *Entry) error {
|
|||||||
|
|
||||||
// Create a new unpinned entry with the same data
|
// Create a new unpinned entry with the same data
|
||||||
newEntry := Entry{
|
newEntry := Entry{
|
||||||
Data: pinnedEntry.Data,
|
Data: pinnedEntry.Data,
|
||||||
MimeType: pinnedEntry.MimeType,
|
MimeType: pinnedEntry.MimeType,
|
||||||
Size: pinnedEntry.Size,
|
Size: pinnedEntry.Size,
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
IsImage: pinnedEntry.IsImage,
|
IsImage: pinnedEntry.IsImage,
|
||||||
Preview: pinnedEntry.Preview,
|
Preview: pinnedEntry.Preview,
|
||||||
Pinned: false,
|
Pinned: false,
|
||||||
|
AltData: pinnedEntry.AltData,
|
||||||
|
AltMimeType: pinnedEntry.AltMimeType,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.storeEntry(newEntry); err != nil {
|
if err := m.storeEntry(newEntry); err != nil {
|
||||||
@@ -1061,6 +1162,23 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetClipboardEntry takes the selection serving the entry's primary
|
||||||
|
// representation plus its stored alternate, so history restores keep
|
||||||
|
// both the text and image sides pasteable.
|
||||||
|
func (m *Manager) SetClipboardEntry(entry *Entry) error {
|
||||||
|
if int64(len(entry.Data)) > m.config.MaxEntrySize {
|
||||||
|
return fmt.Errorf("data too large")
|
||||||
|
}
|
||||||
|
|
||||||
|
offers := clipboardstore.ExpandOffers(slices.Clone(entry.Data), entry.MimeType)
|
||||||
|
if entry.AltMimeType != "" {
|
||||||
|
offers = append(offers, clipboardstore.ExpandOffers(slices.Clone(entry.AltData), entry.AltMimeType)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.takeSelection(offers)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// takeSelection makes the daemon the selection owner, serving the given
|
// takeSelection makes the daemon the selection owner, serving the given
|
||||||
// offers until another client claims the clipboard.
|
// offers until another client claims the clipboard.
|
||||||
func (m *Manager) takeSelection(offers []clipboardstore.Offer) {
|
func (m *Manager) takeSelection(offers []clipboardstore.Offer) {
|
||||||
@@ -1154,16 +1272,20 @@ func (m *Manager) PasteText() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entry := history[0]
|
entry := history[0]
|
||||||
if entry.IsImage {
|
|
||||||
return "", fmt.Errorf("clipboard contains image, not text")
|
|
||||||
}
|
|
||||||
|
|
||||||
fullEntry, err := m.GetEntry(entry.ID)
|
fullEntry, err := m.GetEntry(entry.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
func (m *Manager) Close() {
|
||||||
@@ -1839,21 +1961,34 @@ func (m *Manager) EntryToFile(entry *Entry) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) dbusConnForFlatpak() (*dbus.Conn, error) {
|
||||||
|
m.dbusConnMutex.Lock()
|
||||||
|
defer m.dbusConnMutex.Unlock()
|
||||||
|
|
||||||
|
if m.dbusConn != nil {
|
||||||
|
return m.dbusConn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := dbus.ConnectSessionBus()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect session bus: %w", err)
|
||||||
|
}
|
||||||
|
if !conn.SupportsUnixFDs() {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("D-Bus connection does not support Unix FD passing")
|
||||||
|
}
|
||||||
|
m.dbusConn = conn
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) ExportFileForFlatpak(filePath string) (string, error) {
|
func (m *Manager) ExportFileForFlatpak(filePath string) (string, error) {
|
||||||
if _, err := os.Stat(filePath); err != nil {
|
if _, err := os.Stat(filePath); err != nil {
|
||||||
return "", fmt.Errorf("file not found: %w", err)
|
return "", fmt.Errorf("file not found: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.dbusConn == nil {
|
dbusConn, err := m.dbusConnForFlatpak()
|
||||||
conn, err := dbus.ConnectSessionBus()
|
if err != nil {
|
||||||
if err != nil {
|
return "", err
|
||||||
return "", fmt.Errorf("connect session bus: %w", err)
|
|
||||||
}
|
|
||||||
if !conn.SupportsUnixFDs() {
|
|
||||||
conn.Close()
|
|
||||||
return "", fmt.Errorf("D-Bus connection does not support Unix FD passing")
|
|
||||||
}
|
|
||||||
m.dbusConn = conn
|
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
@@ -1862,7 +1997,7 @@ func (m *Manager) ExportFileForFlatpak(filePath string) (string, error) {
|
|||||||
}
|
}
|
||||||
fd := int(file.Fd())
|
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 docIds []string
|
||||||
var extra map[string]dbus.Variant
|
var extra map[string]dbus.Variant
|
||||||
|
|||||||
@@ -144,6 +144,72 @@ func TestEncodeDecodeEntry_LargeData(t *testing.T) {
|
|||||||
assert.Equal(t, original.Size, decoded.Size)
|
assert.Equal(t, original.Size, decoded.Size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEncodeDecodeEntry_AltRepresentation(t *testing.T) {
|
||||||
|
original := Entry{
|
||||||
|
ID: 555,
|
||||||
|
Data: []byte{0x42, 0x4D, 0x01, 0x02},
|
||||||
|
MimeType: "image/bmp",
|
||||||
|
Preview: "[[ image 4 B bmp 85x19 ]]",
|
||||||
|
Size: 4,
|
||||||
|
Timestamp: time.Now().Truncate(time.Second),
|
||||||
|
IsImage: true,
|
||||||
|
Hash: computeHash([]byte{0x42, 0x4D, 0x01, 0x02}),
|
||||||
|
Pinned: true,
|
||||||
|
AltData: []byte("real text from OneNote"),
|
||||||
|
AltMimeType: "text/plain;charset=utf-8",
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := encodeEntry(original)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
decoded, err := decodeEntry(encoded)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, original.Data, decoded.Data)
|
||||||
|
assert.Equal(t, original.MimeType, decoded.MimeType)
|
||||||
|
assert.True(t, decoded.Pinned)
|
||||||
|
assert.Equal(t, original.AltData, decoded.AltData)
|
||||||
|
assert.Equal(t, original.AltMimeType, decoded.AltMimeType)
|
||||||
|
|
||||||
|
meta, err := decodeEntryMeta(encoded)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Empty(t, meta.Data)
|
||||||
|
assert.Equal(t, original.AltMimeType, meta.AltMimeType)
|
||||||
|
|
||||||
|
assert.Equal(t, original.Hash, extractHash(encoded))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractHash_NoAlt(t *testing.T) {
|
||||||
|
entry := Entry{
|
||||||
|
ID: 1,
|
||||||
|
Data: []byte("plain entry"),
|
||||||
|
MimeType: "text/plain",
|
||||||
|
Preview: "plain entry",
|
||||||
|
Size: 11,
|
||||||
|
Timestamp: time.Now().Truncate(time.Second),
|
||||||
|
Hash: computeHash([]byte("plain entry")),
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := encodeEntry(entry)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, entry.Hash, extractHash(encoded))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectAltTextMimeType(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
mimes []string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{[]string{"image/bmp", "TEXT", "text/html", "text/plain", "text/plain;charset=utf-8", "UTF8_STRING"}, "text/plain;charset=utf-8"},
|
||||||
|
{[]string{"image/png", "UTF8_STRING"}, "UTF8_STRING"},
|
||||||
|
{[]string{"image/png", "text/html"}, ""},
|
||||||
|
{[]string{"image/png"}, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
assert.Equal(t, tt.expected, selectAltTextMimeType(tt.mimes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStateEqual_BothNil(t *testing.T) {
|
func TestStateEqual_BothNil(t *testing.T) {
|
||||||
assert.False(t, stateEqual(nil, nil))
|
assert.False(t, stateEqual(nil, nil))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,15 +95,17 @@ type SearchResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
Data []byte `json:"data,omitempty"`
|
Data []byte `json:"data,omitempty"`
|
||||||
MimeType string `json:"mimeType"`
|
MimeType string `json:"mimeType"`
|
||||||
Preview string `json:"preview"`
|
Preview string `json:"preview"`
|
||||||
Size int `json:"size"`
|
Size int `json:"size"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
IsImage bool `json:"isImage"`
|
IsImage bool `json:"isImage"`
|
||||||
Hash uint64 `json:"hash,omitempty"`
|
Hash uint64 `json:"hash,omitempty"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
|
AltData []byte `json:"altData,omitempty"`
|
||||||
|
AltMimeType string `json:"altMimeType,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type State struct {
|
type State struct {
|
||||||
@@ -153,7 +155,9 @@ type Manager struct {
|
|||||||
notifierWg sync.WaitGroup
|
notifierWg sync.WaitGroup
|
||||||
lastState *State
|
lastState *State
|
||||||
|
|
||||||
dbusConn *dbus.Conn
|
// lazily created by dbusConnForFlatpak under dbusConnMutex
|
||||||
|
dbusConn *dbus.Conn
|
||||||
|
dbusConnMutex sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) GetState() State {
|
func (m *Manager) GetState() State {
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ func (sm *SubscriptionManager) Start() error {
|
|||||||
return fmt.Errorf("subscription manager already running")
|
return fmt.Errorf("subscription manager already running")
|
||||||
}
|
}
|
||||||
sm.running = true
|
sm.running = true
|
||||||
|
// replace the channel closed by the previous Stop(); doing it here rather
|
||||||
|
// than in Stop() guarantees a lagging eventHandler still observes the close
|
||||||
|
sm.eventChan = make(chan SubscriptionEvent, 100)
|
||||||
sm.mu.Unlock()
|
sm.mu.Unlock()
|
||||||
|
|
||||||
subID, err := sm.createSubscription()
|
subID, err := sm.createSubscription()
|
||||||
@@ -206,6 +209,8 @@ func (sm *SubscriptionManager) parseEvent(attrs ipp.Attributes) SubscriptionEven
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SubscriptionManager) Events() <-chan SubscriptionEvent {
|
func (sm *SubscriptionManager) Events() <-chan SubscriptionEvent {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
return sm.eventChan
|
return sm.eventChan
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +233,13 @@ func (sm *SubscriptionManager) Stop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sm.stopChan = make(chan struct{})
|
sm.stopChan = make(chan struct{})
|
||||||
|
|
||||||
|
// the writer (notificationLoop) joined above, so closing is safe; without
|
||||||
|
// this close Manager.eventHandler never returns and Unsubscribe deadlocks
|
||||||
|
// on eventWG.Wait(). Start() allocates the replacement.
|
||||||
|
sm.mu.Lock()
|
||||||
|
close(sm.eventChan)
|
||||||
|
sm.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SubscriptionManager) cancelSubscription() {
|
func (sm *SubscriptionManager) cancelSubscription() {
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ func (sm *DBusSubscriptionManager) Start() error {
|
|||||||
return fmt.Errorf("subscription manager already running")
|
return fmt.Errorf("subscription manager already running")
|
||||||
}
|
}
|
||||||
sm.running = true
|
sm.running = true
|
||||||
|
// replaced here rather than in Stop(); see SubscriptionManager.Start()
|
||||||
|
sm.eventChan = make(chan SubscriptionEvent, 100)
|
||||||
sm.mu.Unlock()
|
sm.mu.Unlock()
|
||||||
|
|
||||||
conn, err := dbus.ConnectSystemBus()
|
conn, err := dbus.ConnectSystemBus()
|
||||||
@@ -252,6 +254,8 @@ func (sm *DBusSubscriptionManager) parseDBusSignal(sig *dbus.Signal) Subscriptio
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (sm *DBusSubscriptionManager) Events() <-chan SubscriptionEvent {
|
func (sm *DBusSubscriptionManager) Events() <-chan SubscriptionEvent {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
return sm.eventChan
|
return sm.eventChan
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +282,12 @@ func (sm *DBusSubscriptionManager) Stop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sm.stopChan = make(chan struct{})
|
sm.stopChan = make(chan struct{})
|
||||||
|
|
||||||
|
// the writer (dbusListenerLoop) joined above, so closing is safe; see
|
||||||
|
// SubscriptionManager.Stop()
|
||||||
|
sm.mu.Lock()
|
||||||
|
close(sm.eventChan)
|
||||||
|
sm.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *DBusSubscriptionManager) cancelSubscription() {
|
func (sm *DBusSubscriptionManager) cancelSubscription() {
|
||||||
|
|||||||
@@ -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) {
|
func handleSubscribe(conn net.Conn, req models.Request, m *Manager, clientID string) {
|
||||||
|
if id := params.StringOpt(req.Params, "clientId", ""); id != "" {
|
||||||
|
clientID = id
|
||||||
|
}
|
||||||
|
|
||||||
bus, err := params.String(req.Params, "bus")
|
bus, err := params.String(req.Params, "bus")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
models.RespondError(conn, req.ID, err.Error())
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
|||||||
@@ -137,22 +137,25 @@ func (m *Manager) consumeSelfEcho(value uint32) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) watchSettingsChanges() {
|
func (m *Manager) watchSettingsChanges() {
|
||||||
conn, err := dbus.ConnectSessionBus()
|
// reuse the shared session connection; a dedicated one was unreachable
|
||||||
if err != nil {
|
// from Close() and leaked with this goroutine
|
||||||
log.Warnf("color-scheme watcher: session bus connect: %v", err)
|
if m.sessionConn == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
conn := m.sessionConn
|
||||||
|
|
||||||
if err := conn.AddMatchSignal(
|
if err := conn.AddMatchSignal(
|
||||||
dbus.WithMatchInterface(dbusPortalSettingsInterface),
|
dbus.WithMatchInterface(dbusPortalSettingsInterface),
|
||||||
dbus.WithMatchMember("SettingChanged"),
|
dbus.WithMatchMember("SettingChanged"),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
log.Warnf("Failed to watch portal settings changes: %v", err)
|
log.Warnf("Failed to watch portal settings changes: %v", err)
|
||||||
conn.Close()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
signals := make(chan *dbus.Signal, 64)
|
signals := make(chan *dbus.Signal, 64)
|
||||||
|
m.stateMutex.Lock()
|
||||||
|
m.settingsSignals = signals
|
||||||
|
m.stateMutex.Unlock()
|
||||||
conn.Signal(signals)
|
conn.Signal(signals)
|
||||||
|
|
||||||
for sig := range signals {
|
for sig := range signals {
|
||||||
@@ -309,6 +312,18 @@ func (m *Manager) Close() {
|
|||||||
m.systemConn.Close()
|
m.systemConn.Close()
|
||||||
}
|
}
|
||||||
if m.sessionConn != nil {
|
if m.sessionConn != nil {
|
||||||
|
m.sessionConn.RemoveMatchSignal(
|
||||||
|
dbus.WithMatchInterface(dbusPortalSettingsInterface),
|
||||||
|
dbus.WithMatchMember("SettingChanged"),
|
||||||
|
)
|
||||||
|
m.stateMutex.Lock()
|
||||||
|
signals := m.settingsSignals
|
||||||
|
m.settingsSignals = nil
|
||||||
|
m.stateMutex.Unlock()
|
||||||
|
if signals != nil {
|
||||||
|
m.sessionConn.RemoveSignal(signals)
|
||||||
|
close(signals)
|
||||||
|
}
|
||||||
m.sessionConn.Close()
|
m.sessionConn.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,4 +71,6 @@ type Manager struct {
|
|||||||
screensaverGnomeClaimed bool
|
screensaverGnomeClaimed bool
|
||||||
selfEchoMu sync.Mutex
|
selfEchoMu sync.Mutex
|
||||||
selfEchoes []colorSchemeEcho
|
selfEchoes []colorSchemeEcho
|
||||||
|
// registered on sessionConn by watchSettingsChanges; guarded by stateMutex
|
||||||
|
settingsSignals chan *dbus.Signal
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,14 @@ type ConnectionStateChecker interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type IWDAgent struct {
|
type IWDAgent struct {
|
||||||
conn *dbus.Conn
|
conn *dbus.Conn
|
||||||
objPath dbus.ObjectPath
|
objPath dbus.ObjectPath
|
||||||
prompts PromptBroker
|
prompts PromptBroker
|
||||||
onUserCanceled func()
|
onUserCanceled func()
|
||||||
onPromptRetry func(ssid string)
|
onPromptRetry func(ssid string)
|
||||||
lastRequestSSID string
|
takePendingSecret func(ssid string) (string, bool)
|
||||||
stateChecker ConnectionStateChecker
|
lastRequestSSID string
|
||||||
|
stateChecker ConnectionStateChecker
|
||||||
}
|
}
|
||||||
|
|
||||||
const iwdAgentIntrospectXML = `
|
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)
|
return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.takePendingSecret != nil {
|
||||||
|
if psk, ok := a.takePendingSecret(ssid); ok {
|
||||||
|
a.lastRequestSSID = ssid
|
||||||
|
return psk, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if a.prompts == nil {
|
if a.prompts == nil {
|
||||||
if a.onUserCanceled != nil {
|
if a.onUserCanceled != nil {
|
||||||
a.onUserCanceled()
|
a.onUserCanceled()
|
||||||
@@ -126,19 +134,24 @@ func (a *IWDAgent) RequestPassphrase(network dbus.ObjectPath) (string, *dbus.Err
|
|||||||
return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
|
return "", dbus.NewError("net.connman.iwd.Agent.Error.Canceled", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.lastRequestSSID == ssid {
|
retry := a.lastRequestSSID == ssid
|
||||||
if a.onPromptRetry != nil {
|
if retry && a.onPromptRetry != nil {
|
||||||
a.onPromptRetry(ssid)
|
a.onPromptRetry(ssid)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
a.lastRequestSSID = ssid
|
a.lastRequestSSID = ssid
|
||||||
|
|
||||||
|
reason := ""
|
||||||
|
if retry {
|
||||||
|
reason = "wrong-password"
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
token, err := a.prompts.Ask(ctx, PromptRequest{
|
token, err := a.prompts.Ask(ctx, PromptRequest{
|
||||||
SSID: ssid,
|
SSID: ssid,
|
||||||
Fields: []string{"psk"},
|
Fields: []string{"psk"},
|
||||||
|
Reason: reason,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if a.onUserCanceled != nil {
|
if a.onUserCanceled != nil {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const (
|
|||||||
type connectAttempt struct {
|
type connectAttempt struct {
|
||||||
ssid string
|
ssid string
|
||||||
netPath dbus.ObjectPath
|
netPath dbus.ObjectPath
|
||||||
|
saved bool
|
||||||
start time.Time
|
start time.Time
|
||||||
deadline time.Time
|
deadline time.Time
|
||||||
sawAuthish bool
|
sawAuthish bool
|
||||||
@@ -53,6 +54,37 @@ type IWDBackend struct {
|
|||||||
attemptMutex sync.RWMutex
|
attemptMutex sync.RWMutex
|
||||||
recentScans map[string]time.Time
|
recentScans map[string]time.Time
|
||||||
recentScansMu sync.Mutex
|
recentScansMu sync.Mutex
|
||||||
|
pendingPSK *pendingReplacementPSK
|
||||||
|
pendingPSKMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type pendingReplacementPSK struct {
|
||||||
|
ssid string
|
||||||
|
psk string
|
||||||
|
expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IWDBackend) storePendingPSK(ssid, psk string) {
|
||||||
|
b.pendingPSKMu.Lock()
|
||||||
|
b.pendingPSK = &pendingReplacementPSK{
|
||||||
|
ssid: ssid,
|
||||||
|
psk: psk,
|
||||||
|
expires: time.Now().Add(30 * time.Second),
|
||||||
|
}
|
||||||
|
b.pendingPSKMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IWDBackend) takePendingPSK(ssid string) (string, bool) {
|
||||||
|
b.pendingPSKMu.Lock()
|
||||||
|
defer b.pendingPSKMu.Unlock()
|
||||||
|
|
||||||
|
pending := b.pendingPSK
|
||||||
|
if pending == nil || pending.ssid != ssid || time.Now().After(pending.expires) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
b.pendingPSK = nil
|
||||||
|
return pending.psk, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIWDBackend() (*IWDBackend, error) {
|
func NewIWDBackend() (*IWDBackend, error) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func (b *IWDBackend) StartMonitoring(onStateChange func()) error {
|
|||||||
}
|
}
|
||||||
agent.onUserCanceled = b.OnUserCanceledPrompt
|
agent.onUserCanceled = b.OnUserCanceledPrompt
|
||||||
agent.onPromptRetry = b.OnPromptRetry
|
agent.onPromptRetry = b.OnPromptRetry
|
||||||
|
agent.takePendingSecret = b.takePendingPSK
|
||||||
b.iwdAgent = agent
|
b.iwdAgent = agent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package network
|
package network
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -314,6 +315,133 @@ func TestConnectAttempt_Finalization(t *testing.T) {
|
|||||||
backend.stateMutex.RUnlock()
|
backend.stateMutex.RUnlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIWDBackend_PendingPSK(t *testing.T) {
|
||||||
|
backend, _ := NewIWDBackend()
|
||||||
|
|
||||||
|
_, ok := backend.takePendingPSK("Home")
|
||||||
|
assert.False(t, ok)
|
||||||
|
|
||||||
|
backend.storePendingPSK("Home", "newpass")
|
||||||
|
|
||||||
|
_, ok = backend.takePendingPSK("Other")
|
||||||
|
assert.False(t, ok, "pending PSK should not match a different SSID")
|
||||||
|
|
||||||
|
psk, ok := backend.takePendingPSK("Home")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "newpass", psk)
|
||||||
|
|
||||||
|
_, ok = backend.takePendingPSK("Home")
|
||||||
|
assert.False(t, ok, "pending PSK should be consumed on take")
|
||||||
|
|
||||||
|
backend.storePendingPSK("Home", "newpass")
|
||||||
|
backend.pendingPSKMu.Lock()
|
||||||
|
backend.pendingPSK.expires = time.Now().Add(-time.Second)
|
||||||
|
backend.pendingPSKMu.Unlock()
|
||||||
|
|
||||||
|
_, ok = backend.takePendingPSK("Home")
|
||||||
|
assert.False(t, ok, "expired pending PSK should not be returned")
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakePromptBroker struct {
|
||||||
|
asked chan PromptRequest
|
||||||
|
reply PromptReply
|
||||||
|
replyErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePromptBroker) Ask(ctx context.Context, req PromptRequest) (string, error) {
|
||||||
|
f.asked <- req
|
||||||
|
return "token", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePromptBroker) Wait(ctx context.Context, token string) (PromptReply, error) {
|
||||||
|
return f.reply, f.replyErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePromptBroker) Resolve(token string, reply PromptReply) error { return nil }
|
||||||
|
|
||||||
|
func (f *fakePromptBroker) Cancel(path string, setting string) error { return nil }
|
||||||
|
|
||||||
|
func TestIWDBackend_BadCredentialsSavedNetwork_PromptsReplacement(t *testing.T) {
|
||||||
|
backend, _ := NewIWDBackend()
|
||||||
|
backend.state = &BackendState{}
|
||||||
|
broker := &fakePromptBroker{
|
||||||
|
asked: make(chan PromptRequest, 1),
|
||||||
|
reply: PromptReply{Cancel: true},
|
||||||
|
}
|
||||||
|
backend.promptBroker = broker
|
||||||
|
|
||||||
|
att := &connectAttempt{
|
||||||
|
ssid: "Home",
|
||||||
|
netPath: "/test",
|
||||||
|
saved: true,
|
||||||
|
start: time.Now(),
|
||||||
|
deadline: time.Now().Add(15 * time.Second),
|
||||||
|
}
|
||||||
|
|
||||||
|
backend.finalizeAttempt(att, "bad-credentials")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case req := <-broker.asked:
|
||||||
|
assert.Equal(t, "Home", req.SSID)
|
||||||
|
assert.Equal(t, "wrong-password", req.Reason)
|
||||||
|
assert.Equal(t, []string{"psk"}, req.Fields)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("expected replacement credentials prompt for saved network")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIWDBackend_BadCredentialsUnsavedNetwork_NoReplacementPrompt(t *testing.T) {
|
||||||
|
backend, _ := NewIWDBackend()
|
||||||
|
backend.state = &BackendState{}
|
||||||
|
broker := &fakePromptBroker{
|
||||||
|
asked: make(chan PromptRequest, 1),
|
||||||
|
reply: PromptReply{Cancel: true},
|
||||||
|
}
|
||||||
|
backend.promptBroker = broker
|
||||||
|
|
||||||
|
att := &connectAttempt{
|
||||||
|
ssid: "Home",
|
||||||
|
netPath: "/test",
|
||||||
|
start: time.Now(),
|
||||||
|
deadline: time.Now().Add(15 * time.Second),
|
||||||
|
}
|
||||||
|
|
||||||
|
backend.finalizeAttempt(att, "bad-credentials")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-broker.asked:
|
||||||
|
t.Fatal("unsaved network should not trigger a replacement prompt")
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIWDBackend_BadCredentialsAfterPromptRetry_NoReplacementPrompt(t *testing.T) {
|
||||||
|
backend, _ := NewIWDBackend()
|
||||||
|
backend.state = &BackendState{}
|
||||||
|
broker := &fakePromptBroker{
|
||||||
|
asked: make(chan PromptRequest, 1),
|
||||||
|
reply: PromptReply{Cancel: true},
|
||||||
|
}
|
||||||
|
backend.promptBroker = broker
|
||||||
|
|
||||||
|
att := &connectAttempt{
|
||||||
|
ssid: "Home",
|
||||||
|
netPath: "/test",
|
||||||
|
saved: true,
|
||||||
|
sawPromptRetry: true,
|
||||||
|
start: time.Now(),
|
||||||
|
deadline: time.Now().Add(15 * time.Second),
|
||||||
|
}
|
||||||
|
|
||||||
|
backend.finalizeAttempt(att, "bad-credentials")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-broker.asked:
|
||||||
|
t.Fatal("attempt that already prompted should not trigger a replacement prompt")
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConnectAttempt_DoubleFinalization(t *testing.T) {
|
func TestConnectAttempt_DoubleFinalization(t *testing.T) {
|
||||||
backend, _ := NewIWDBackend()
|
backend, _ := NewIWDBackend()
|
||||||
backend.state = &BackendState{}
|
backend.state = &BackendState{}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package network
|
package network
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
"github.com/godbus/dbus/v5"
|
"github.com/godbus/dbus/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -476,6 +478,73 @@ func (b *IWDBackend) finalizeAttempt(att *connectAttempt, code string) {
|
|||||||
if b.onStateChange != nil {
|
if b.onStateChange != nil {
|
||||||
b.onStateChange()
|
b.onStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if code == errdefs.ErrBadCredentials {
|
||||||
|
b.maybeReplaceSavedPSK(att)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IWDBackend) maybeReplaceSavedPSK(att *connectAttempt) {
|
||||||
|
if b.promptBroker == nil || !att.saved {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
att.mu.Lock()
|
||||||
|
prompted := att.sawPromptRetry
|
||||||
|
att.mu.Unlock()
|
||||||
|
if prompted {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b.sigWG.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer b.sigWG.Done()
|
||||||
|
b.requestReplacementPSK(att.ssid)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IWDBackend) requestReplacementPSK(ssid string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-b.stopChan:
|
||||||
|
cancel()
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
token, err := b.promptBroker.Ask(ctx, PromptRequest{
|
||||||
|
SSID: ssid,
|
||||||
|
SettingName: "802-11-wireless-security",
|
||||||
|
Fields: []string{"psk"},
|
||||||
|
Reason: "wrong-password",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("failed to request replacement credentials for %s: %v", ssid, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, err := b.promptBroker.Wait(ctx, token)
|
||||||
|
if err != nil || reply.Cancel {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
psk, ok := reply.Secrets["psk"]
|
||||||
|
if !ok || psk == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.ForgetWiFiNetwork(ssid); err != nil {
|
||||||
|
log.Warnf("failed to forget %s before credential replacement: %v", ssid, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.storePendingPSK(ssid, psk)
|
||||||
|
|
||||||
|
if err := b.ConnectWiFi(ConnectionRequest{SSID: ssid}); err != nil {
|
||||||
|
log.Warnf("failed to reconnect %s with replacement credentials: %v", ssid, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *IWDBackend) startAttemptWatchdog(att *connectAttempt) {
|
func (b *IWDBackend) startAttemptWatchdog(att *connectAttempt) {
|
||||||
@@ -560,7 +629,7 @@ func (b *IWDBackend) ConnectWiFi(req ConnectionRequest) error {
|
|||||||
return fmt.Errorf("no WiFi device available")
|
return fmt.Errorf("no WiFi device available")
|
||||||
}
|
}
|
||||||
|
|
||||||
networkPath, err := b.findNetworkPath(req.SSID)
|
networkPath, saved, err := b.findNetworkPath(req.SSID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.setConnectError(errdefs.ErrNoSuchSSID)
|
b.setConnectError(errdefs.ErrNoSuchSSID)
|
||||||
if b.onStateChange != nil {
|
if b.onStateChange != nil {
|
||||||
@@ -572,6 +641,7 @@ func (b *IWDBackend) ConnectWiFi(req ConnectionRequest) error {
|
|||||||
att := &connectAttempt{
|
att := &connectAttempt{
|
||||||
ssid: req.SSID,
|
ssid: req.SSID,
|
||||||
netPath: networkPath,
|
netPath: networkPath,
|
||||||
|
saved: saved,
|
||||||
start: time.Now(),
|
start: time.Now(),
|
||||||
deadline: time.Now().Add(15 * time.Second),
|
deadline: time.Now().Add(15 * time.Second),
|
||||||
}
|
}
|
||||||
@@ -619,26 +689,39 @@ func (b *IWDBackend) ConnectWiFi(req ConnectionRequest) error {
|
|||||||
return nil
|
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)
|
obj := b.conn.Object(iwdBusName, iwdObjectPath)
|
||||||
|
|
||||||
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
|
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
|
||||||
err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects)
|
err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var netPath dbus.ObjectPath
|
||||||
|
saved := false
|
||||||
for path, interfaces := range objects {
|
for path, interfaces := range objects {
|
||||||
if netProps, ok := interfaces[iwdNetworkInterface]; ok {
|
if netProps, ok := interfaces[iwdNetworkInterface]; ok {
|
||||||
if nameVar, ok := netProps["Name"]; ok {
|
if nameVar, ok := netProps["Name"]; ok {
|
||||||
if name, ok := nameVar.Value().(string); ok && name == ssid {
|
if name, ok := nameVar.Value().(string); ok && name == ssid {
|
||||||
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 {
|
func (b *IWDBackend) DisconnectWiFi() error {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package network
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
@@ -57,6 +58,11 @@ type NetworkManagerBackend struct {
|
|||||||
wifiDev any
|
wifiDev any
|
||||||
wifiDevices map[string]*wifiDeviceInfo
|
wifiDevices map[string]*wifiDeviceInfo
|
||||||
|
|
||||||
|
// devMutex guards ethernetDevices/wifiDevices (written by the signal pump,
|
||||||
|
// read by request handlers). Not reentrant — never hold it across calls
|
||||||
|
// into other backend methods.
|
||||||
|
devMutex sync.RWMutex
|
||||||
|
|
||||||
dbusConn *dbus.Conn
|
dbusConn *dbus.Conn
|
||||||
signals chan *dbus.Signal
|
signals chan *dbus.Signal
|
||||||
sigWG sync.WaitGroup
|
sigWG sync.WaitGroup
|
||||||
@@ -185,12 +191,12 @@ func (b *NetworkManagerBackend) Initialize() error {
|
|||||||
}
|
}
|
||||||
hwAddr, _ := w.GetPropertyHwAddress()
|
hwAddr, _ := w.GetPropertyHwAddress()
|
||||||
|
|
||||||
b.ethernetDevices[iface] = ðernetDeviceInfo{
|
b.setEthernetDeviceInfo(iface, ðernetDeviceInfo{
|
||||||
device: dev,
|
device: dev,
|
||||||
wired: w,
|
wired: w,
|
||||||
name: iface,
|
name: iface,
|
||||||
hwAddress: hwAddr,
|
hwAddress: hwAddr,
|
||||||
}
|
})
|
||||||
|
|
||||||
if b.ethernetDevice == nil {
|
if b.ethernetDevice == nil {
|
||||||
b.ethernetDevice = dev
|
b.ethernetDevice = dev
|
||||||
@@ -214,12 +220,12 @@ func (b *NetworkManagerBackend) Initialize() error {
|
|||||||
}
|
}
|
||||||
hwAddr, _ := w.GetPropertyHwAddress()
|
hwAddr, _ := w.GetPropertyHwAddress()
|
||||||
|
|
||||||
b.wifiDevices[iface] = &wifiDeviceInfo{
|
b.setWifiDeviceInfo(iface, &wifiDeviceInfo{
|
||||||
device: dev,
|
device: dev,
|
||||||
wireless: w,
|
wireless: w,
|
||||||
name: iface,
|
name: iface,
|
||||||
hwAddress: hwAddr,
|
hwAddress: hwAddr,
|
||||||
}
|
})
|
||||||
|
|
||||||
if b.wifiDevice == nil {
|
if b.wifiDevice == nil {
|
||||||
b.wifiDevice = dev
|
b.wifiDevice = dev
|
||||||
@@ -267,6 +273,80 @@ func (b *NetworkManagerBackend) Initialize() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *NetworkManagerBackend) ethernetDevicesSnapshot() map[string]*ethernetDeviceInfo {
|
||||||
|
b.devMutex.RLock()
|
||||||
|
defer b.devMutex.RUnlock()
|
||||||
|
out := make(map[string]*ethernetDeviceInfo, len(b.ethernetDevices))
|
||||||
|
maps.Copy(out, b.ethernetDevices)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *NetworkManagerBackend) wifiDevicesSnapshot() map[string]*wifiDeviceInfo {
|
||||||
|
b.devMutex.RLock()
|
||||||
|
defer b.devMutex.RUnlock()
|
||||||
|
out := make(map[string]*wifiDeviceInfo, len(b.wifiDevices))
|
||||||
|
maps.Copy(out, b.wifiDevices)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *NetworkManagerBackend) ethernetDeviceByIface(iface string) (*ethernetDeviceInfo, bool) {
|
||||||
|
b.devMutex.RLock()
|
||||||
|
defer b.devMutex.RUnlock()
|
||||||
|
info, ok := b.ethernetDevices[iface]
|
||||||
|
return info, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *NetworkManagerBackend) wifiDeviceByIface(iface string) (*wifiDeviceInfo, bool) {
|
||||||
|
b.devMutex.RLock()
|
||||||
|
defer b.devMutex.RUnlock()
|
||||||
|
info, ok := b.wifiDevices[iface]
|
||||||
|
return info, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *NetworkManagerBackend) setEthernetDeviceInfo(iface string, info *ethernetDeviceInfo) {
|
||||||
|
b.devMutex.Lock()
|
||||||
|
b.ethernetDevices[iface] = info
|
||||||
|
b.devMutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *NetworkManagerBackend) setWifiDeviceInfo(iface string, info *wifiDeviceInfo) {
|
||||||
|
b.devMutex.Lock()
|
||||||
|
b.wifiDevices[iface] = info
|
||||||
|
b.devMutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeEthernetDeviceByPath deletes the device and returns a snapshot of
|
||||||
|
// what's left so the caller can pick a replacement without holding devMutex
|
||||||
|
func (b *NetworkManagerBackend) removeEthernetDeviceByPath(path dbus.ObjectPath) (removed *ethernetDeviceInfo, remaining map[string]*ethernetDeviceInfo, found bool) {
|
||||||
|
b.devMutex.Lock()
|
||||||
|
defer b.devMutex.Unlock()
|
||||||
|
for iface, info := range b.ethernetDevices {
|
||||||
|
if info.device.GetPath() != path {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(b.ethernetDevices, iface)
|
||||||
|
remaining = make(map[string]*ethernetDeviceInfo, len(b.ethernetDevices))
|
||||||
|
maps.Copy(remaining, b.ethernetDevices)
|
||||||
|
return info, remaining, true
|
||||||
|
}
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *NetworkManagerBackend) removeWifiDeviceByPath(path dbus.ObjectPath) (removed *wifiDeviceInfo, remaining map[string]*wifiDeviceInfo, found bool) {
|
||||||
|
b.devMutex.Lock()
|
||||||
|
defer b.devMutex.Unlock()
|
||||||
|
for iface, info := range b.wifiDevices {
|
||||||
|
if info.device.GetPath() != path {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(b.wifiDevices, iface)
|
||||||
|
remaining = make(map[string]*wifiDeviceInfo, len(b.wifiDevices))
|
||||||
|
maps.Copy(remaining, b.wifiDevices)
|
||||||
|
return info, remaining, true
|
||||||
|
}
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
|
||||||
func (b *NetworkManagerBackend) Close() {
|
func (b *NetworkManagerBackend) Close() {
|
||||||
close(b.stopChan)
|
close(b.stopChan)
|
||||||
b.StopMonitoring()
|
b.StopMonitoring()
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ func (b *NetworkManagerBackend) GetEthernetDevices() []EthernetDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *NetworkManagerBackend) DisconnectEthernetDevice(device string) error {
|
func (b *NetworkManagerBackend) DisconnectEthernetDevice(device string) error {
|
||||||
info, ok := b.ethernetDevices[device]
|
info, ok := b.ethernetDeviceByIface(device)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("ethernet device %s not found", device)
|
return fmt.Errorf("ethernet device %s not found", device)
|
||||||
}
|
}
|
||||||
@@ -345,9 +345,10 @@ func (b *NetworkManagerBackend) DisconnectEthernetDevice(device string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *NetworkManagerBackend) updateAllEthernetDevices() {
|
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()
|
state, _ := info.device.GetPropertyState()
|
||||||
connected := state == gonetworkmanager.NmDeviceStateActivated
|
connected := state == gonetworkmanager.NmDeviceStateActivated
|
||||||
driver, _ := info.device.GetPropertyDriver()
|
driver, _ := info.device.GetPropertyDriver()
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ func (b *NetworkManagerBackend) startSignalPump() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, info := range b.wifiDevices {
|
for _, info := range b.wifiDevicesSnapshot() {
|
||||||
if err := conn.AddMatchSignal(
|
if err := conn.AddMatchSignal(
|
||||||
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
|
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
|
||||||
dbus.WithMatchInterface(dbusPropsInterface),
|
dbus.WithMatchInterface(dbusPropsInterface),
|
||||||
@@ -124,7 +124,7 @@ func (b *NetworkManagerBackend) startSignalPump() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, info := range b.ethernetDevices {
|
for _, info := range b.ethernetDevicesSnapshot() {
|
||||||
if err := conn.AddMatchSignal(
|
if err := conn.AddMatchSignal(
|
||||||
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
|
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
|
||||||
dbus.WithMatchInterface(dbusPropsInterface),
|
dbus.WithMatchInterface(dbusPropsInterface),
|
||||||
@@ -227,7 +227,7 @@ func (b *NetworkManagerBackend) stopSignalPump() {
|
|||||||
dbus.WithMatchMember("StateChanged"),
|
dbus.WithMatchMember("StateChanged"),
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, info := range b.wifiDevices {
|
for _, info := range b.wifiDevicesSnapshot() {
|
||||||
b.dbusConn.RemoveMatchSignal(
|
b.dbusConn.RemoveMatchSignal(
|
||||||
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
|
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
|
||||||
dbus.WithMatchInterface(dbusPropsInterface),
|
dbus.WithMatchInterface(dbusPropsInterface),
|
||||||
@@ -235,7 +235,7 @@ func (b *NetworkManagerBackend) stopSignalPump() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, info := range b.ethernetDevices {
|
for _, info := range b.ethernetDevicesSnapshot() {
|
||||||
b.dbusConn.RemoveMatchSignal(
|
b.dbusConn.RemoveMatchSignal(
|
||||||
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
|
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
|
||||||
dbus.WithMatchInterface(dbusPropsInterface),
|
dbus.WithMatchInterface(dbusPropsInterface),
|
||||||
@@ -550,12 +550,12 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
|
|||||||
}
|
}
|
||||||
hwAddr, _ := w.GetPropertyHwAddress()
|
hwAddr, _ := w.GetPropertyHwAddress()
|
||||||
|
|
||||||
b.ethernetDevices[iface] = ðernetDeviceInfo{
|
b.setEthernetDeviceInfo(iface, ðernetDeviceInfo{
|
||||||
device: dev,
|
device: dev,
|
||||||
wired: w,
|
wired: w,
|
||||||
name: iface,
|
name: iface,
|
||||||
hwAddress: hwAddr,
|
hwAddress: hwAddr,
|
||||||
}
|
})
|
||||||
|
|
||||||
if b.ethernetDevice == nil {
|
if b.ethernetDevice == nil {
|
||||||
b.ethernetDevice = dev
|
b.ethernetDevice = dev
|
||||||
@@ -573,12 +573,12 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
|
|||||||
}
|
}
|
||||||
hwAddr, _ := w.GetPropertyHwAddress()
|
hwAddr, _ := w.GetPropertyHwAddress()
|
||||||
|
|
||||||
b.wifiDevices[iface] = &wifiDeviceInfo{
|
b.setWifiDeviceInfo(iface, &wifiDeviceInfo{
|
||||||
device: dev,
|
device: dev,
|
||||||
wireless: w,
|
wireless: w,
|
||||||
name: iface,
|
name: iface,
|
||||||
hwAddress: hwAddr,
|
hwAddress: hwAddr,
|
||||||
}
|
})
|
||||||
|
|
||||||
if b.wifiDevice == nil {
|
if b.wifiDevice == nil {
|
||||||
b.wifiDevice = dev
|
b.wifiDevice = dev
|
||||||
@@ -603,57 +603,49 @@ func (b *NetworkManagerBackend) handleDeviceRemoved(devicePath dbus.ObjectPath)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
for iface, info := range b.ethernetDevices {
|
if _, remaining, found := b.removeEthernetDeviceByPath(devicePath); found {
|
||||||
if info.device.GetPath() == devicePath {
|
if b.ethernetDevice != nil {
|
||||||
delete(b.ethernetDevices, iface)
|
dev := b.ethernetDevice.(gonetworkmanager.Device)
|
||||||
|
if dev.GetPath() == devicePath {
|
||||||
if b.ethernetDevice != nil {
|
b.ethernetDevice = nil
|
||||||
dev := b.ethernetDevice.(gonetworkmanager.Device)
|
for _, r := range remaining {
|
||||||
if dev.GetPath() == devicePath {
|
b.ethernetDevice = r.device
|
||||||
b.ethernetDevice = nil
|
break
|
||||||
for _, remaining := range b.ethernetDevices {
|
|
||||||
b.ethernetDevice = remaining.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 _, remaining, found := b.removeWifiDeviceByPath(devicePath); found {
|
||||||
if info.device.GetPath() == devicePath {
|
if b.wifiDevice != nil {
|
||||||
delete(b.wifiDevices, iface)
|
dev := b.wifiDevice.(gonetworkmanager.Device)
|
||||||
|
if dev.GetPath() == devicePath {
|
||||||
if b.wifiDevice != nil {
|
b.wifiDevice = nil
|
||||||
dev := b.wifiDevice.(gonetworkmanager.Device)
|
b.wifiDev = nil
|
||||||
if dev.GetPath() == devicePath {
|
for _, r := range remaining {
|
||||||
b.wifiDevice = nil
|
b.wifiDevice = r.device
|
||||||
b.wifiDev = nil
|
b.wifiDev = r.wireless
|
||||||
for _, remaining := range b.wifiDevices {
|
break
|
||||||
b.wifiDevice = remaining.device
|
|
||||||
b.wifiDev = remaining.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 connectedIP string
|
||||||
var anyConnected bool
|
var anyConnected bool
|
||||||
|
|
||||||
for name, info := range b.ethernetDevices {
|
for name, info := range b.ethernetDevicesSnapshot() {
|
||||||
state, err := info.device.GetPropertyState()
|
state, err := info.device.GetPropertyState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -973,7 +973,7 @@ func (b *NetworkManagerBackend) SetWiFiAutoconnect(ssid string, autoconnect bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *NetworkManagerBackend) ScanWiFiDevice(device string) error {
|
func (b *NetworkManagerBackend) ScanWiFiDevice(device string) error {
|
||||||
devInfo, ok := b.wifiDevices[device]
|
devInfo, ok := b.wifiDeviceByIface(device)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("WiFi device not found: %s", device)
|
return fmt.Errorf("WiFi device not found: %s", device)
|
||||||
}
|
}
|
||||||
@@ -995,7 +995,7 @@ func (b *NetworkManagerBackend) ScanWiFiDevice(device string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *NetworkManagerBackend) DisconnectWiFiDevice(device string) error {
|
func (b *NetworkManagerBackend) DisconnectWiFiDevice(device string) error {
|
||||||
devInfo, ok := b.wifiDevices[device]
|
devInfo, ok := b.wifiDeviceByIface(device)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("WiFi device not found: %s", device)
|
return fmt.Errorf("WiFi device not found: %s", device)
|
||||||
}
|
}
|
||||||
@@ -1047,7 +1047,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
|
|||||||
wifiConnected := b.state.WiFiConnected
|
wifiConnected := b.state.WiFiConnected
|
||||||
b.stateMutex.RUnlock()
|
b.stateMutex.RUnlock()
|
||||||
|
|
||||||
for name, devInfo := range b.wifiDevices {
|
for name, devInfo := range b.wifiDevicesSnapshot() {
|
||||||
state, _ := devInfo.device.GetPropertyState()
|
state, _ := devInfo.device.GetPropertyState()
|
||||||
connected := state == gonetworkmanager.NmDeviceStateActivated
|
connected := state == gonetworkmanager.NmDeviceStateActivated
|
||||||
|
|
||||||
@@ -1211,7 +1211,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
|
|||||||
|
|
||||||
func (b *NetworkManagerBackend) getWifiDeviceForConnection(deviceName string) (*wifiDeviceInfo, error) {
|
func (b *NetworkManagerBackend) getWifiDeviceForConnection(deviceName string) (*wifiDeviceInfo, error) {
|
||||||
if deviceName != "" {
|
if deviceName != "" {
|
||||||
devInfo, ok := b.wifiDevices[deviceName]
|
devInfo, ok := b.wifiDeviceByIface(deviceName)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("WiFi device not found: %s", deviceName)
|
return nil, fmt.Errorf("WiFi device not found: %s", deviceName)
|
||||||
}
|
}
|
||||||
@@ -1224,7 +1224,7 @@ func (b *NetworkManagerBackend) getWifiDeviceForConnection(deviceName string) (*
|
|||||||
|
|
||||||
dev := b.wifiDevice.(gonetworkmanager.Device)
|
dev := b.wifiDevice.(gonetworkmanager.Device)
|
||||||
iface, _ := dev.GetPropertyInterface()
|
iface, _ := dev.GetPropertyInterface()
|
||||||
if devInfo, ok := b.wifiDevices[iface]; ok {
|
if devInfo, ok := b.wifiDeviceByIface(iface); ok {
|
||||||
return devInfo, nil
|
return devInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
|
|||||||
handleGetNetworkInfo(conn, req, manager)
|
handleGetNetworkInfo(conn, req, manager)
|
||||||
case "network.qrcode":
|
case "network.qrcode":
|
||||||
handleGetNetworkQRCode(conn, req, manager)
|
handleGetNetworkQRCode(conn, req, manager)
|
||||||
|
case "network.qrcode-content":
|
||||||
|
handleGetNetworkQRCodeContent(conn, req, manager)
|
||||||
case "network.delete-qrcode":
|
case "network.delete-qrcode":
|
||||||
handleDeleteQRCode(conn, req, manager)
|
handleDeleteQRCode(conn, req, manager)
|
||||||
case "network.ethernet.info":
|
case "network.ethernet.info":
|
||||||
@@ -341,6 +343,22 @@ func handleGetNetworkQRCode(conn net.Conn, req models.Request, manager *Manager)
|
|||||||
models.Respond(conn, req.ID, content)
|
models.Respond(conn, req.ID, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleGetNetworkQRCodeContent(conn net.Conn, req models.Request, manager *Manager) {
|
||||||
|
ssid, err := params.String(req.Params, "ssid")
|
||||||
|
if err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := manager.GetWiFiQRContent(ssid)
|
||||||
|
if err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
models.Respond(conn, req.ID, content)
|
||||||
|
}
|
||||||
|
|
||||||
func handleDeleteQRCode(conn net.Conn, req models.Request, _ *Manager) {
|
func handleDeleteQRCode(conn net.Conn, req models.Request, _ *Manager) {
|
||||||
path, err := params.String(req.Params, "path")
|
path, err := params.String(req.Params, "path")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -473,6 +473,10 @@ func (m *Manager) GetNetworkInfoDetailed(ssid string) (*NetworkInfoResponse, err
|
|||||||
return m.backend.GetWiFiNetworkDetails(ssid)
|
return m.backend.GetWiFiNetworkDetails(ssid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) GetWiFiQRContent(ssid string) (string, error) {
|
||||||
|
return m.backend.GetWiFiQRCodeContent(ssid)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) GetNetworkQRCode(ssid string) ([2]string, error) {
|
func (m *Manager) GetNetworkQRCode(ssid string) ([2]string, error) {
|
||||||
content, err := m.backend.GetWiFiQRCodeContent(ssid)
|
content, err := m.backend.GetWiFiQRCodeContent(ssid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/qrcode"
|
||||||
)
|
)
|
||||||
|
|
||||||
const qrCodeTmpPrefix = "/tmp/dank-wifi-qrcode-"
|
const qrCodeTmpPrefix = "/tmp/dank-wifi-qrcode-"
|
||||||
|
|
||||||
func FormatWiFiQRString(securityType, ssid, password string) string {
|
func FormatWiFiQRString(securityType, ssid, password string) string {
|
||||||
return 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) {
|
func qrCodePaths(ssid string) (themed, normal string) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime/debug"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -41,7 +42,7 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||||
)
|
)
|
||||||
|
|
||||||
const APIVersion = 27
|
const APIVersion = 28
|
||||||
|
|
||||||
var CLIVersion = "dev"
|
var CLIVersion = "dev"
|
||||||
|
|
||||||
@@ -398,6 +399,11 @@ func InitializeSysUpdateManager() error {
|
|||||||
|
|
||||||
func handleConnection(conn net.Conn) {
|
func handleConnection(conn net.Conn) {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Errorf("handleConnection panic recovered: panic=%v\n%s", r, debug.Stack())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
caps := getCapabilities()
|
caps := getCapabilities()
|
||||||
capsData, _ := json.Marshal(caps)
|
capsData, _ := json.Marshal(caps)
|
||||||
@@ -415,10 +421,21 @@ func handleConnection(conn net.Conn) {
|
|||||||
continue
|
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 {
|
func getCapabilities() Capabilities {
|
||||||
caps := []string{"plugins"}
|
caps := []string{"plugins"}
|
||||||
|
|
||||||
@@ -581,6 +598,11 @@ func notifyCapabilityChange() {
|
|||||||
func handleSubscribe(conn net.Conn, req models.Request) {
|
func handleSubscribe(conn net.Conn, req models.Request) {
|
||||||
clientID := fmt.Sprintf("meta-client-%p", conn)
|
clientID := fmt.Sprintf("meta-client-%p", conn)
|
||||||
|
|
||||||
|
dbusClient := dbusClientID
|
||||||
|
if id, ok := models.Get[string](req, "clientId"); ok && id != "" {
|
||||||
|
dbusClient = id
|
||||||
|
}
|
||||||
|
|
||||||
var services []string
|
var services []string
|
||||||
if servicesParam, ok := models.Get[[]any](req, "services"); ok {
|
if servicesParam, ok := models.Get[[]any](req, "services"); ok {
|
||||||
for _, s := range servicesParam {
|
for _, s := range servicesParam {
|
||||||
@@ -1249,10 +1271,10 @@ func handleSubscribe(conn net.Conn, req models.Request) {
|
|||||||
|
|
||||||
if shouldSubscribe("dbus") && dbusManager != nil {
|
if shouldSubscribe("dbus") && dbusManager != nil {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
dbusChan := dbusManager.SubscribeSignals(dbusClientID)
|
dbusChan := dbusManager.SubscribeSignals(dbusClient)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer dbusManager.UnsubscribeSignals(dbusClientID)
|
defer dbusManager.UnsubscribeSignals(dbusClient)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package sysupdate
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -31,7 +32,36 @@ func (aptBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
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 {
|
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 {
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -52,7 +53,11 @@ func (b dnfBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine fun
|
|||||||
}
|
}
|
||||||
|
|
||||||
func dnfUpgradeArgv(bin string, opts UpgradeOptions) []string {
|
func dnfUpgradeArgv(bin string, opts UpgradeOptions) []string {
|
||||||
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) {
|
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 {
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 100 {
|
||||||
return string(out), nil
|
return string(out), nil
|
||||||
}
|
}
|
||||||
|
if detail := lastNonEmptyLine(string(out)); detail != "" {
|
||||||
|
return "", fmt.Errorf("%w: %s", err, detail)
|
||||||
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lastNonEmptyLine(text string) string {
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
for i := len(lines) - 1; i >= 0; i-- {
|
||||||
|
if line := strings.TrimSpace(lines[i]); line != "" {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func dnfCheckUpdatesArgv(bin string) []string {
|
func dnfCheckUpdatesArgv(bin string) []string {
|
||||||
subcommand := "check-update"
|
subcommand := "check-update"
|
||||||
if bin == "dnf5" {
|
if bin == "dnf5" {
|
||||||
|
|||||||
@@ -95,11 +95,21 @@ func (flatpakBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine f
|
|||||||
if !BackendHasTargets(flatpakBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
if !BackendHasTargets(flatpakBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return Run(ctx, flatpakUpgradeArgv(), RunOptions{OnLine: onLine})
|
return Run(ctx, flatpakUpgradeArgv(opts), RunOptions{OnLine: onLine})
|
||||||
}
|
}
|
||||||
|
|
||||||
func flatpakUpgradeArgv() []string {
|
func flatpakUpgradeArgv(opts UpgradeOptions) []string {
|
||||||
return []string{"flatpak", "update", "-y", "--noninteractive"}
|
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 {
|
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 {
|
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 {
|
type archHelperBackend struct {
|
||||||
@@ -99,23 +103,27 @@ func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onL
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if os.Getenv("DMS_FORCE_PKEXEC") == "1" {
|
if os.Getenv("DMS_FORCE_PKEXEC") == "1" {
|
||||||
argv := append([]string{"pkexec"}, archHelperUpgradeArgv(b.id, opts.IncludeAUR)...)
|
argv := append([]string{"pkexec"}, archHelperUpgradeArgv(b.id, opts.IncludeAUR, opts.Ignored)...)
|
||||||
return Run(ctx, argv, RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
|
return Run(ctx, argv, RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
|
||||||
}
|
}
|
||||||
term := findTerminal(opts.Terminal)
|
term := findTerminal(opts.Terminal)
|
||||||
if term == "" {
|
if term == "" {
|
||||||
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
|
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
|
||||||
}
|
}
|
||||||
cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR), " ")
|
cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR, opts.Ignored), " ")
|
||||||
title := fmt.Sprintf("DMS — System Update (%s)", b.id)
|
title := fmt.Sprintf("DMS — System Update (%s)", b.id)
|
||||||
return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine})
|
return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine})
|
||||||
}
|
}
|
||||||
|
|
||||||
func archHelperUpgradeArgv(id string, includeAUR bool) []string {
|
func archHelperUpgradeArgv(id string, includeAUR bool, ignored []string) []string {
|
||||||
argv := []string{id, "-Syu", "--noconfirm", "--needed"}
|
argv := []string{id, "-Syu", "--noconfirm", "--needed"}
|
||||||
if !includeAUR {
|
if !includeAUR {
|
||||||
argv = append(argv, "--repo")
|
argv = append(argv, "--repo")
|
||||||
}
|
}
|
||||||
|
ignored = shellSafeNames(ignored)
|
||||||
|
if len(ignored) > 0 {
|
||||||
|
argv = append(argv, "--ignore", strings.Join(ignored, ","))
|
||||||
|
}
|
||||||
return argv
|
return argv
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +256,10 @@ func parseArchUpdates(text, backendID string, repo RepoKind) []Package {
|
|||||||
if line == "" {
|
if line == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// pacman -Qu / paru -Qua flag IgnorePkg entries with a trailing marker
|
||||||
|
if strings.HasSuffix(line, "[ignored]") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
m := archUpdateLine.FindStringSubmatch(line)
|
m := archUpdateLine.FindStringSubmatch(line)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -92,6 +92,17 @@ foo`,
|
|||||||
{Name: "bat", Repo: RepoSystem, Backend: "pacman", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
|
{Name: "bat", Repo: RepoSystem, Backend: "pacman", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "skips IgnorePkg entries",
|
||||||
|
input: `bat 0.26.0-1 -> 0.26.1-2
|
||||||
|
linux 6.18.0-1 -> 6.18.1-1 [ignored]
|
||||||
|
discord 0.0.108-1 -> 0.0.109-1 [ignored]`,
|
||||||
|
backendID: "pacman",
|
||||||
|
repo: RepoSystem,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bat", Repo: RepoSystem, Backend: "pacman", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "extra whitespace tolerated",
|
name: "extra whitespace tolerated",
|
||||||
input: " bat 0.26.0-1 -> 0.26.1-2 ",
|
input: " bat 0.26.0-1 -> 0.26.1-2 ",
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -81,5 +83,20 @@ func (zypperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine fu
|
|||||||
}
|
}
|
||||||
|
|
||||||
func zypperUpgradeArgv(opts UpgradeOptions) []string {
|
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),
|
DryRun: params.BoolOpt(req.Params, "dry", false),
|
||||||
CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
|
CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
|
||||||
Terminal: params.StringOpt(req.Params, "terminal", ""),
|
Terminal: params.StringOpt(req.Params, "terminal", ""),
|
||||||
|
Ignored: stringSliceOpt(req.Params, "ignored"),
|
||||||
}
|
}
|
||||||
if err := m.Upgrade(opts); err != nil {
|
if err := m.Upgrade(opts); err != nil {
|
||||||
models.RespondError(conn, req.ID, err.Error())
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
@@ -53,3 +54,21 @@ func handleUpgrade(conn net.Conn, req models.Request, m *Manager) {
|
|||||||
}
|
}
|
||||||
models.Respond(conn, req.ID, m.GetState())
|
models.Respond(conn, req.ID, m.GetState())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func stringSliceOpt(p map[string]any, key string) []string {
|
||||||
|
val, ok := params.Any(p, key)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
arr, ok := val.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(arr))
|
||||||
|
for _, v := range arr {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const (
|
|||||||
minIntervalSeconds = 5 * 60
|
minIntervalSeconds = 5 * 60
|
||||||
recentLogCapacity = 200
|
recentLogCapacity = 200
|
||||||
checkTimeout = 5 * time.Minute
|
checkTimeout = 5 * time.Minute
|
||||||
|
retryIntervalSeconds = 5 * 60
|
||||||
upgradeTimeout = 30 * time.Minute
|
upgradeTimeout = 30 * time.Minute
|
||||||
postUpgradeCompleteDelay = 3 * time.Second
|
postUpgradeCompleteDelay = 3 * time.Second
|
||||||
)
|
)
|
||||||
@@ -150,7 +151,7 @@ func (m *Manager) Refresh(opts RefreshOptions) {
|
|||||||
m.refreshSerial.Unlock()
|
m.refreshSerial.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.runRefresh(context.Background())
|
m.runRefresh(context.Background(), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Upgrade(opts UpgradeOptions) error {
|
func (m *Manager) Upgrade(opts UpgradeOptions) error {
|
||||||
@@ -237,12 +238,12 @@ func (m *Manager) scheduler() {
|
|||||||
case <-m.wakeSched:
|
case <-m.wakeSched:
|
||||||
t.Stop()
|
t.Stop()
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
m.runRefresh(context.Background())
|
m.runRefresh(context.Background(), false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) runRefresh(parent context.Context) {
|
func (m *Manager) runRefresh(parent context.Context, manual bool) {
|
||||||
m.refreshSerial.Lock()
|
m.refreshSerial.Lock()
|
||||||
defer m.refreshSerial.Unlock()
|
defer m.refreshSerial.Unlock()
|
||||||
|
|
||||||
@@ -284,27 +285,43 @@ func (m *Manager) runRefresh(parent context.Context) {
|
|||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.state.LastCheckUnix = now
|
m.state.LastCheckUnix = now
|
||||||
m.state.Packages = m.state.Packages[:0]
|
prev := m.state.Packages
|
||||||
|
next := make([]Package, 0, len(prev))
|
||||||
var firstErr error
|
var firstErr error
|
||||||
for i, r := range results {
|
for i, r := range results {
|
||||||
if r.err != nil {
|
if r.err != nil {
|
||||||
if firstErr == nil {
|
if firstErr == nil {
|
||||||
firstErr = fmt.Errorf("%s: %w", backends[i].ID(), r.err)
|
firstErr = fmt.Errorf("%s: %w", backends[i].ID(), r.err)
|
||||||
}
|
}
|
||||||
|
// Retain a failed backend's last known packages so a transient failure doesn't wipe the list.
|
||||||
|
for _, p := range prev {
|
||||||
|
if p.Backend == backends[i].ID() {
|
||||||
|
next = append(next, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
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)
|
m.state.NextCheckUnix = now + int64(m.state.IntervalSeconds)
|
||||||
if firstErr != nil {
|
switch {
|
||||||
m.state.Phase = PhaseError
|
case firstErr == nil:
|
||||||
m.state.Error = &ErrorInfo{Code: ErrCodeBackendFailed, Message: firstErr.Error()}
|
|
||||||
} else {
|
|
||||||
m.state.Phase = PhaseIdle
|
m.state.Phase = PhaseIdle
|
||||||
m.state.LastSuccessUnix = now
|
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.mu.Unlock()
|
||||||
|
m.wake()
|
||||||
m.markDirty()
|
m.markDirty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,10 +345,15 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
|
|||||||
opts.Targets = append([]Package(nil), m.state.Packages...)
|
opts.Targets = append([]Package(nil), m.state.Packages...)
|
||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
}
|
}
|
||||||
|
opts.Targets = dropIgnoredTargets(opts.Targets, opts.Ignored)
|
||||||
|
|
||||||
backends := upgradeBackends(m.selection, opts)
|
backends := upgradeBackends(m.selection, opts)
|
||||||
if len(backends) == 0 {
|
if len(backends) == 0 {
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +451,24 @@ func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) {
|
|||||||
m.markDirty()
|
m.markDirty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dropIgnoredTargets(targets []Package, ignored []string) []Package {
|
||||||
|
if len(ignored) == 0 {
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
skip := make(map[string]bool, len(ignored))
|
||||||
|
for _, name := range ignored {
|
||||||
|
skip[name] = true
|
||||||
|
}
|
||||||
|
out := targets[:0]
|
||||||
|
for _, p := range targets {
|
||||||
|
if skip[p.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func upgradeBackends(sel Selection, opts UpgradeOptions) []Backend {
|
func upgradeBackends(sel Selection, opts UpgradeOptions) []Backend {
|
||||||
var out []Backend
|
var out []Backend
|
||||||
if sel.System != nil {
|
if sel.System != nil {
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
package sysupdate
|
package sysupdate
|
||||||
|
|
||||||
|
import "regexp"
|
||||||
|
|
||||||
|
var safePkgName = regexp.MustCompile(`^[A-Za-z0-9@._+:-]+$`)
|
||||||
|
|
||||||
|
// shellSafeNames drops names unsafe to interpolate into the apt/zypper sh -c scripts.
|
||||||
|
func shellSafeNames(names []string) []string {
|
||||||
|
out := make([]string, 0, len(names))
|
||||||
|
for _, n := range names {
|
||||||
|
if safePkgName.MatchString(n) {
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func BackendHasTargets(b Backend, targets []Package, includeAUR, includeFlatpak bool) bool {
|
func BackendHasTargets(b Backend, targets []Package, includeAUR, includeFlatpak bool) bool {
|
||||||
if b == nil || len(targets) == 0 {
|
if b == nil || len(targets) == 0 {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ type UpgradeOptions struct {
|
|||||||
CustomCommand string
|
CustomCommand string
|
||||||
Terminal string
|
Terminal string
|
||||||
Targets []Package
|
Targets []Package
|
||||||
|
Ignored []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type RefreshOptions struct {
|
type RefreshOptions struct {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package sysupdate
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,19 +36,55 @@ func TestUpgradeCommandBuilders(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "aur helper full update with aur",
|
name: "aur helper full update with aur",
|
||||||
got: archHelperUpgradeArgv("paru", true),
|
got: archHelperUpgradeArgv("paru", true, nil),
|
||||||
want: []string{"paru", "-Syu", "--noconfirm", "--needed"},
|
want: []string{"paru", "-Syu", "--noconfirm", "--needed"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "aur helper repo-only full update",
|
name: "aur helper repo-only full update",
|
||||||
got: archHelperUpgradeArgv("yay", false),
|
got: archHelperUpgradeArgv("yay", false, nil),
|
||||||
want: []string{"yay", "-Syu", "--noconfirm", "--needed", "--repo"},
|
want: []string{"yay", "-Syu", "--noconfirm", "--needed", "--repo"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "aur helper with ignored packages",
|
||||||
|
got: archHelperUpgradeArgv("paru", true, []string{"linux", "bad;name", "discord"}),
|
||||||
|
want: []string{"paru", "-Syu", "--noconfirm", "--needed", "--ignore", "linux,discord"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pacman with ignored packages",
|
||||||
|
got: pacmanUpgradeArgv(UpgradeOptions{Ignored: []string{"linux"}}),
|
||||||
|
want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed", "--ignore", "linux"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dnf with ignored packages",
|
||||||
|
got: dnfUpgradeArgv("dnf5", UpgradeOptions{Ignored: []string{"kernel", "mesa"}}),
|
||||||
|
want: []string{"pkexec", "dnf5", "upgrade", "--refresh", "-y", "--exclude=kernel,mesa"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "apt without ignored uses plain upgrade",
|
||||||
|
got: aptUpgradeArgv("apt-get", UpgradeOptions{}),
|
||||||
|
want: []string{"pkexec", "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", "apt-get", "upgrade", "-y"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zypper without ignored uses plain update",
|
||||||
|
got: zypperUpgradeArgv(UpgradeOptions{}),
|
||||||
|
want: []string{"pkexec", "zypper", "--non-interactive", "update"},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "flatpak full update",
|
name: "flatpak full update",
|
||||||
got: flatpakUpgradeArgv(),
|
got: flatpakUpgradeArgv(UpgradeOptions{}),
|
||||||
want: []string{"flatpak", "update", "-y", "--noninteractive"},
|
want: []string{"flatpak", "update", "-y", "--noninteractive"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "flatpak update with ignored targets refs",
|
||||||
|
got: flatpakUpgradeArgv(UpgradeOptions{
|
||||||
|
Ignored: []string{"org.mozilla.firefox"},
|
||||||
|
Targets: []Package{
|
||||||
|
{Name: "Discord", Repo: RepoFlatpak, Ref: "com.discordapp.Discord//stable"},
|
||||||
|
{Name: "bash", Repo: RepoSystem, Backend: "apt"},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
want: []string{"flatpak", "update", "-y", "--noninteractive", "com.discordapp.Discord//stable"},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "rpm-ostree upgrade",
|
name: "rpm-ostree upgrade",
|
||||||
got: rpmOstreeUpgradeArgv(UpgradeOptions{}),
|
got: rpmOstreeUpgradeArgv(UpgradeOptions{}),
|
||||||
@@ -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) {
|
func TestBackendHasTargetsRespectsBackendAndOptions(t *testing.T) {
|
||||||
targets := []Package{
|
targets := []Package{
|
||||||
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf5"},
|
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf5"},
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import (
|
|||||||
|
|
||||||
const animKelvinStep = 25
|
const animKelvinStep = 25
|
||||||
|
|
||||||
|
// Go timers freeze during suspend; cap sleeps so wall-clock deadlines can't be missed.
|
||||||
|
const maxScheduleWait = 5 * time.Minute
|
||||||
|
|
||||||
func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error) {
|
func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error) {
|
||||||
if err := config.Validate(); err != nil {
|
if err := config.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -304,8 +307,8 @@ func (m *Manager) setupControlHandlers(state *outputState, control *wlr_gamma_co
|
|||||||
out.rampSize = size
|
out.rampSize = size
|
||||||
out.failed = false
|
out.failed = false
|
||||||
out.retryCount = 0
|
out.retryCount = 0
|
||||||
|
out.lastTemp = 0
|
||||||
}
|
}
|
||||||
m.lastAppliedTemp = 0
|
|
||||||
m.applyCurrentTemp("gamma_size")
|
m.applyCurrentTemp("gamma_size")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -507,6 +510,10 @@ func (m *Manager) recalcSchedule(now time.Time) {
|
|||||||
config.ManualSunrise.Hour(), config.ManualSunrise.Minute(), config.ManualSunrise.Second(), 0, now.Location())
|
config.ManualSunrise.Hour(), config.ManualSunrise.Minute(), config.ManualSunrise.Second(), 0, now.Location())
|
||||||
sunset := time.Date(now.Year(), now.Month(), now.Day(),
|
sunset := time.Date(now.Year(), now.Month(), now.Day(),
|
||||||
config.ManualSunset.Hour(), config.ManualSunset.Minute(), config.ManualSunset.Second(), 0, now.Location())
|
config.ManualSunset.Hour(), config.ManualSunset.Minute(), config.ManualSunset.Second(), 0, now.Location())
|
||||||
|
if !sunset.After(sunrise) {
|
||||||
|
// night start past midnight belongs to the next day
|
||||||
|
sunset = sunset.Add(24 * time.Hour)
|
||||||
|
}
|
||||||
times = SunTimes{
|
times = SunTimes{
|
||||||
Dawn: sunrise.Add(-dur),
|
Dawn: sunrise.Add(-dur),
|
||||||
Sunrise: sunrise,
|
Sunrise: sunrise,
|
||||||
@@ -518,6 +525,8 @@ func (m *Manager) recalcSchedule(now time.Time) {
|
|||||||
lat, lon := m.getLocation()
|
lat, lon := m.getLocation()
|
||||||
if lat == nil || lon == nil {
|
if lat == nil || lon == nil {
|
||||||
m.gammaState = StateStatic
|
m.gammaState = StateStatic
|
||||||
|
// stale times from a previous config must not drive applies
|
||||||
|
m.schedule = sunSchedule{}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
times, cond = CalculateSunTimesWithTwilight(*lat, *lon, now, config.ElevationTwilight, config.ElevationDaylight)
|
times, cond = CalculateSunTimesWithTwilight(*lat, *lon, now, config.ElevationTwilight, config.ElevationDaylight)
|
||||||
@@ -611,7 +620,26 @@ func (m *Manager) getSunPosition(now time.Time) float64 {
|
|||||||
return 1.0
|
return 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shiftTimes(times SunTimes, d time.Duration) SunTimes {
|
||||||
|
return SunTimes{
|
||||||
|
Dawn: times.Dawn.Add(d),
|
||||||
|
Sunrise: times.Sunrise.Add(d),
|
||||||
|
Sunset: times.Sunset.Add(d),
|
||||||
|
Night: times.Night.Add(d),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeCycle maps early-morning hours back to yesterday's cycle when the
|
||||||
|
// schedule crosses midnight.
|
||||||
|
func activeCycle(now time.Time, times SunTimes) SunTimes {
|
||||||
|
if now.Before(times.Night.Add(-24 * time.Hour)) {
|
||||||
|
return shiftTimes(times, -24*time.Hour)
|
||||||
|
}
|
||||||
|
return times
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) getSunPositionNormal(now time.Time, times SunTimes) float64 {
|
func (m *Manager) getSunPositionNormal(now time.Time, times SunTimes) float64 {
|
||||||
|
times = activeCycle(now, times)
|
||||||
if now.Before(times.Dawn) {
|
if now.Before(times.Dawn) {
|
||||||
return 0.0
|
return 0.0
|
||||||
}
|
}
|
||||||
@@ -666,7 +694,7 @@ func (m *Manager) getNextDeadline(now time.Time) time.Time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) getDeadlineNormal(now time.Time, sched sunSchedule) time.Time {
|
func (m *Manager) getDeadlineNormal(now time.Time, sched sunSchedule) time.Time {
|
||||||
times := sched.times
|
times := activeCycle(now, sched.times)
|
||||||
switch {
|
switch {
|
||||||
case now.Before(times.Dawn):
|
case now.Before(times.Dawn):
|
||||||
return times.Dawn
|
return times.Dawn
|
||||||
@@ -737,8 +765,11 @@ func (m *Manager) schedulerLoop() {
|
|||||||
if enabled {
|
if enabled {
|
||||||
deadline := m.getNextDeadline(now)
|
deadline := m.getNextDeadline(now)
|
||||||
waitDur = time.Until(deadline)
|
waitDur = time.Until(deadline)
|
||||||
if waitDur < time.Second {
|
switch {
|
||||||
|
case waitDur < time.Second:
|
||||||
waitDur = time.Second
|
waitDur = time.Second
|
||||||
|
case waitDur > maxScheduleWait:
|
||||||
|
waitDur = maxScheduleWait
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
waitDur = 24 * time.Hour
|
waitDur = 24 * time.Hour
|
||||||
@@ -818,8 +849,6 @@ func (m *Manager) applyGamma(temp int) {
|
|||||||
return
|
return
|
||||||
case !m.controlsInitialized:
|
case !m.controlsInitialized:
|
||||||
return
|
return
|
||||||
case m.lastAppliedTemp == temp && m.lastAppliedGamma == gamma:
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var outs []*outputState
|
var outs []*outputState
|
||||||
@@ -845,6 +874,8 @@ func (m *Manager) applyGamma(temp int) {
|
|||||||
continue
|
continue
|
||||||
case out.gammaControl == nil:
|
case out.gammaControl == nil:
|
||||||
continue
|
continue
|
||||||
|
case out.lastTemp == temp && out.lastGamma == gamma:
|
||||||
|
continue
|
||||||
case !m.outputStillValid(out):
|
case !m.outputStillValid(out):
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -865,19 +896,19 @@ func (m *Manager) applyGamma(temp int) {
|
|||||||
for _, j := range jobs {
|
for _, j := range jobs {
|
||||||
err := m.setGammaBytes(j.out, j.data)
|
err := m.setGammaBytes(j.out, j.data)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
j.out.lastTemp = temp
|
||||||
|
j.out.lastGamma = gamma
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
log.Warnf("gamma: failed to set output %d: %v", j.out.id, err)
|
log.Warnf("gamma: failed to set output %d: %v", j.out.id, err)
|
||||||
j.out.failed = true
|
j.out.failed = true
|
||||||
j.out.rampSize = 0
|
j.out.rampSize = 0
|
||||||
|
j.out.lastTemp = 0
|
||||||
if isConnectionDeadErr(err) {
|
if isConnectionDeadErr(err) {
|
||||||
m.markConnectionDead(err)
|
m.markConnectionDead(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
m.lastAppliedTemp = temp
|
|
||||||
m.lastAppliedGamma = gamma
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) setGammaBytes(out *outputState, data []byte) error {
|
func (m *Manager) setGammaBytes(out *outputState, data []byte) error {
|
||||||
@@ -939,7 +970,8 @@ func (m *Manager) updateStateFromSchedule() {
|
|||||||
pos = m.getSunPosition(now)
|
pos = m.getSunPosition(now)
|
||||||
temp = m.getTempFromPosition(pos)
|
temp = m.getTempFromPosition(pos)
|
||||||
deadline = m.getNextDeadline(now)
|
deadline = m.getNextDeadline(now)
|
||||||
isDay = now.After(times.Sunrise) && now.Before(times.Sunset)
|
cycle := activeCycle(now, times)
|
||||||
|
isDay = now.After(cycle.Sunrise) && now.Before(cycle.Sunset)
|
||||||
}
|
}
|
||||||
|
|
||||||
newState := State{
|
newState := State{
|
||||||
@@ -1053,14 +1085,15 @@ func (m *Manager) handleResume() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compositors (Niri, Hyprland, wlroots-based) re-apply the cached gamma
|
// Compositor gamma state is unknown after resume; force a resend (#1235)
|
||||||
// ramp to DRM on resume; gamma_control objects stay valid. We just need
|
// and re-arm the scheduler timer, which froze during suspend.
|
||||||
// to force a resend so the schedule catches up with the current time of
|
m.outputs.Range(func(_ uint32, out *outputState) bool {
|
||||||
// day — the original #1235 regression was caused by lastAppliedTemp
|
out.lastTemp = 0
|
||||||
// matching and the send being skipped.
|
return true
|
||||||
|
})
|
||||||
m.recalcSchedule(time.Now())
|
m.recalcSchedule(time.Now())
|
||||||
m.lastAppliedTemp = 0
|
|
||||||
m.applyCurrentTemp("resume")
|
m.applyCurrentTemp("resume")
|
||||||
|
m.triggerUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) triggerUpdate() {
|
func (m *Manager) triggerUpdate() {
|
||||||
|
|||||||
@@ -102,9 +102,6 @@ type Manager struct {
|
|||||||
dbusSignal chan *dbus.Signal
|
dbusSignal chan *dbus.Signal
|
||||||
|
|
||||||
geoClient geolocation.Client
|
geoClient geolocation.Client
|
||||||
|
|
||||||
lastAppliedTemp int
|
|
||||||
lastAppliedGamma float64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type outputState struct {
|
type outputState struct {
|
||||||
@@ -117,6 +114,8 @@ type outputState struct {
|
|||||||
isVirtual bool
|
isVirtual bool
|
||||||
retryCount int
|
retryCount int
|
||||||
lastFailTime time.Time
|
lastFailTime time.Time
|
||||||
|
lastTemp int
|
||||||
|
lastGamma float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultConfig() Config {
|
func DefaultConfig() Config {
|
||||||
|
|||||||
@@ -384,10 +384,6 @@ func applyHyprlandRuleAction(actions *windowrules.Actions, rule, value string) {
|
|||||||
if f, err := strconv.ParseFloat(value, 64); err == nil {
|
if f, err := strconv.ParseFloat(value, 64); err == nil {
|
||||||
actions.Opacity = &f
|
actions.Opacity = &f
|
||||||
}
|
}
|
||||||
case "size":
|
|
||||||
actions.Size = value
|
|
||||||
case "move":
|
|
||||||
actions.Move = value
|
|
||||||
case "monitor":
|
case "monitor":
|
||||||
actions.Monitor = value
|
actions.Monitor = value
|
||||||
case "workspace":
|
case "workspace":
|
||||||
@@ -564,6 +560,13 @@ func hyprLuaBoolStr(b bool) string {
|
|||||||
return "false"
|
return "false"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hyprLuaExprWrap(v string) string {
|
||||||
|
if _, err := strconv.ParseFloat(v, 64); err == nil {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return strconv.Quote(v)
|
||||||
|
}
|
||||||
|
|
||||||
func luaAppendMatch(mc windowrules.MatchCriteria, dst *[]string) {
|
func luaAppendMatch(mc windowrules.MatchCriteria, dst *[]string) {
|
||||||
if mc.AppID != "" {
|
if mc.AppID != "" {
|
||||||
*dst = append(*dst, fmt.Sprintf(`class = %s`, strconv.Quote(mc.AppID)))
|
*dst = append(*dst, fmt.Sprintf(`class = %s`, strconv.Quote(mc.AppID)))
|
||||||
@@ -634,11 +637,11 @@ func luaAppendActions(a windowrules.Actions, dst *[]string) {
|
|||||||
if a.Opacity != nil {
|
if a.Opacity != nil {
|
||||||
*dst = append(*dst, fmt.Sprintf(`opacity = %s`, strconv.FormatFloat(*a.Opacity, 'g', -1, 64)))
|
*dst = append(*dst, fmt.Sprintf(`opacity = %s`, strconv.FormatFloat(*a.Opacity, 'g', -1, 64)))
|
||||||
}
|
}
|
||||||
if a.Size != "" {
|
if a.SizeWidth != "" && a.SizeHeight != "" {
|
||||||
*dst = append(*dst, fmt.Sprintf(`size = %s`, strconv.Quote(a.Size)))
|
*dst = append(*dst, fmt.Sprintf(`size = { %s, %s }`, hyprLuaExprWrap(a.SizeWidth), hyprLuaExprWrap(a.SizeHeight)))
|
||||||
}
|
}
|
||||||
if a.Move != "" {
|
if a.MoveX != "" && a.MoveY != "" {
|
||||||
*dst = append(*dst, fmt.Sprintf(`move = %s`, strconv.Quote(a.Move)))
|
*dst = append(*dst, fmt.Sprintf(`move = { %s, %s }`, hyprLuaExprWrap(a.MoveX), hyprLuaExprWrap(a.MoveY)))
|
||||||
}
|
}
|
||||||
if a.Monitor != "" {
|
if a.Monitor != "" {
|
||||||
*dst = append(*dst, fmt.Sprintf(`monitor = %s`, strconv.Quote(a.Monitor)))
|
*dst = append(*dst, fmt.Sprintf(`monitor = %s`, strconv.Quote(a.Monitor)))
|
||||||
@@ -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) {
|
func luaBoolLike(s string) (val bool, ok bool) {
|
||||||
@@ -1349,11 +1356,29 @@ func applyLuaActionKey(a *windowrules.Actions, key, raw string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "size":
|
case "size":
|
||||||
a.Size = strings.TrimSpace(luaStringValue(raw))
|
v := strings.TrimSpace(luaStringValue(raw))
|
||||||
return true
|
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":
|
case "move":
|
||||||
a.Move = strings.TrimSpace(luaStringValue(raw))
|
v := strings.TrimSpace(luaStringValue(raw))
|
||||||
return true
|
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":
|
case "monitor":
|
||||||
a.Monitor = strings.TrimSpace(luaStringValue(raw))
|
a.Monitor = strings.TrimSpace(luaStringValue(raw))
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -400,3 +400,196 @@ func TestBoolToInt(t *testing.T) {
|
|||||||
t.Error("boolToInt(false) should be 0")
|
t.Error("boolToInt(false) should be 0")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLuaAppendActionsTableSyntax(t *testing.T) {
|
||||||
|
actions := windowrules.Actions{
|
||||||
|
SizeWidth: "800",
|
||||||
|
SizeHeight: "600",
|
||||||
|
MoveX: "100",
|
||||||
|
MoveY: "200",
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []string
|
||||||
|
luaAppendActions(actions, &out)
|
||||||
|
joined := strings.Join(out, "\n")
|
||||||
|
for _, want := range []string{
|
||||||
|
`size = { 800, 600 }`,
|
||||||
|
`move = { 100, 200 }`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(joined, want) {
|
||||||
|
t.Errorf("expected output to contain %q, got:\n%s", want, joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLuaAppendActionsExprWrap(t *testing.T) {
|
||||||
|
actions := windowrules.Actions{
|
||||||
|
SizeWidth: "window_w * 0.5",
|
||||||
|
SizeHeight: "window_h - 50",
|
||||||
|
MoveX: "100",
|
||||||
|
MoveY: "(monitor_h / 2) + 17",
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []string
|
||||||
|
luaAppendActions(actions, &out)
|
||||||
|
joined := strings.Join(out, "\n")
|
||||||
|
for _, want := range []string{
|
||||||
|
`size = { "window_w * 0.5", "window_h - 50" }`,
|
||||||
|
`move = { 100, "(monitor_h / 2) + 17" }`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(joined, want) {
|
||||||
|
t.Errorf("expected output to contain %q, got:\n%s", want, joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyLuaActionKeyTableSyntax(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
raw string
|
||||||
|
wantSizeW string
|
||||||
|
wantSizeH string
|
||||||
|
wantMoveX string
|
||||||
|
wantMoveY string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "size table syntax",
|
||||||
|
key: "size",
|
||||||
|
raw: `{ 800, 600 }`,
|
||||||
|
wantSizeW: "800",
|
||||||
|
wantSizeH: "600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "move table syntax",
|
||||||
|
key: "move",
|
||||||
|
raw: `{ 100, 200 }`,
|
||||||
|
wantMoveX: "100",
|
||||||
|
wantMoveY: "200",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "size string syntax returns false",
|
||||||
|
key: "size",
|
||||||
|
raw: `"800x600"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "move string syntax returns false",
|
||||||
|
key: "move",
|
||||||
|
raw: `"100 200"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "size expressions",
|
||||||
|
key: "size",
|
||||||
|
raw: `{ "window_w * 0.5", "window_h - 50" }`,
|
||||||
|
wantSizeW: "window_w * 0.5",
|
||||||
|
wantSizeH: "window_h - 50",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "move expressions",
|
||||||
|
key: "move",
|
||||||
|
raw: `{ 100, "(monitor_h / 2) + 17" }`,
|
||||||
|
wantMoveX: "100",
|
||||||
|
wantMoveY: "(monitor_h / 2) + 17",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var a windowrules.Actions
|
||||||
|
result := applyLuaActionKey(&a, tt.key, tt.raw)
|
||||||
|
if tt.wantSizeW == "" && tt.wantSizeH == "" && tt.wantMoveX == "" && tt.wantMoveY == "" {
|
||||||
|
if result {
|
||||||
|
t.Errorf("expected applyLuaActionKey to return false for string syntax, got true")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !result {
|
||||||
|
t.Fatal("applyLuaActionKey returned false")
|
||||||
|
}
|
||||||
|
if tt.wantSizeW != "" && a.SizeWidth != tt.wantSizeW {
|
||||||
|
t.Errorf("SizeWidth = %q, want %q", a.SizeWidth, tt.wantSizeW)
|
||||||
|
}
|
||||||
|
if tt.wantSizeH != "" && a.SizeHeight != tt.wantSizeH {
|
||||||
|
t.Errorf("SizeHeight = %q, want %q", a.SizeHeight, tt.wantSizeH)
|
||||||
|
}
|
||||||
|
if tt.wantMoveX != "" && a.MoveX != tt.wantMoveX {
|
||||||
|
t.Errorf("MoveX = %q, want %q", a.MoveX, tt.wantMoveX)
|
||||||
|
}
|
||||||
|
if tt.wantMoveY != "" && a.MoveY != tt.wantMoveY {
|
||||||
|
t.Errorf("MoveY = %q, want %q", a.MoveY, tt.wantMoveY)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLuaRoundTripTableSyntax(t *testing.T) {
|
||||||
|
original := windowrules.Actions{
|
||||||
|
SizeWidth: "800",
|
||||||
|
SizeHeight: "600",
|
||||||
|
MoveX: "100",
|
||||||
|
MoveY: "200",
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []string
|
||||||
|
luaAppendActions(original, &out)
|
||||||
|
|
||||||
|
var parsed windowrules.Actions
|
||||||
|
for _, line := range out {
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(parts[0])
|
||||||
|
val := strings.TrimSpace(parts[1])
|
||||||
|
applyLuaActionKey(&parsed, key, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.SizeWidth != original.SizeWidth {
|
||||||
|
t.Errorf("SizeWidth = %q, want %q", parsed.SizeWidth, original.SizeWidth)
|
||||||
|
}
|
||||||
|
if parsed.SizeHeight != original.SizeHeight {
|
||||||
|
t.Errorf("SizeHeight = %q, want %q", parsed.SizeHeight, original.SizeHeight)
|
||||||
|
}
|
||||||
|
if parsed.MoveX != original.MoveX {
|
||||||
|
t.Errorf("MoveX = %q, want %q", parsed.MoveX, original.MoveX)
|
||||||
|
}
|
||||||
|
if parsed.MoveY != original.MoveY {
|
||||||
|
t.Errorf("MoveY = %q, want %q", parsed.MoveY, original.MoveY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLuaRoundTripTableSyntaxExpressions(t *testing.T) {
|
||||||
|
original := windowrules.Actions{
|
||||||
|
SizeWidth: "window_w * 0.5",
|
||||||
|
SizeHeight: "window_h - 50",
|
||||||
|
MoveX: "100",
|
||||||
|
MoveY: "(monitor_h / 2) + 17",
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []string
|
||||||
|
luaAppendActions(original, &out)
|
||||||
|
|
||||||
|
var parsed windowrules.Actions
|
||||||
|
for _, line := range out {
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(parts[0])
|
||||||
|
val := strings.TrimSpace(parts[1])
|
||||||
|
applyLuaActionKey(&parsed, key, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.SizeWidth != original.SizeWidth {
|
||||||
|
t.Errorf("SizeWidth = %q, want %q", parsed.SizeWidth, original.SizeWidth)
|
||||||
|
}
|
||||||
|
if parsed.SizeHeight != original.SizeHeight {
|
||||||
|
t.Errorf("SizeHeight = %q, want %q", parsed.SizeHeight, original.SizeHeight)
|
||||||
|
}
|
||||||
|
if parsed.MoveX != original.MoveX {
|
||||||
|
t.Errorf("MoveX = %q, want %q", parsed.MoveX, original.MoveX)
|
||||||
|
}
|
||||||
|
if parsed.MoveY != original.MoveY {
|
||||||
|
t.Errorf("MoveY = %q, want %q", parsed.MoveY, original.MoveY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
|
||||||
@@ -168,7 +167,8 @@ func ConvertMangoRulesToWindowRules(mangoRules []MangoWindowRule) []windowrules.
|
|||||||
}
|
}
|
||||||
if w, ok := f["width"]; ok {
|
if w, ok := f["width"]; ok {
|
||||||
if h, ok2 := f["height"]; ok2 {
|
if h, ok2 := f["height"]; ok2 {
|
||||||
actions.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("tags", rule.Actions.Workspace)
|
||||||
add("monitor", rule.Actions.Monitor)
|
add("monitor", rule.Actions.Monitor)
|
||||||
|
|
||||||
if rule.Actions.Size != "" {
|
if rule.Actions.SizeWidth != "" && rule.Actions.SizeHeight != "" {
|
||||||
if w, h, ok := splitSize(rule.Actions.Size); ok {
|
add("width", rule.Actions.SizeWidth)
|
||||||
add("width", w)
|
add("height", rule.Actions.SizeHeight)
|
||||||
add("height", h)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addBool := func(k string, b *bool) {
|
addBool := func(k string, b *bool) {
|
||||||
@@ -223,19 +221,6 @@ func formatMangoRule(rule windowrules.WindowRule) string {
|
|||||||
return "windowrule=" + strings.Join(parts, ",")
|
return "windowrule=" + strings.Join(parts, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
func splitSize(size string) (w, h string, ok bool) {
|
|
||||||
for _, sep := range []string{"x", "X", " "} {
|
|
||||||
if parts := strings.Split(size, sep); len(parts) == 2 {
|
|
||||||
w = strings.TrimSpace(parts[0])
|
|
||||||
h = strings.TrimSpace(parts[1])
|
|
||||||
if _, err := strconv.ParseFloat(w, 64); err == nil {
|
|
||||||
return w, h, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
type MangoWritableProvider struct {
|
type MangoWritableProvider struct {
|
||||||
configDir string
|
configDir string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ func TestMangoSetAndLoadRoundTrip(t *testing.T) {
|
|||||||
Actions: windowrules.Actions{
|
Actions: windowrules.Actions{
|
||||||
OpenFloating: &floating,
|
OpenFloating: &floating,
|
||||||
Workspace: "9",
|
Workspace: "9",
|
||||||
Size: "1000x900",
|
SizeWidth: "1000",
|
||||||
|
SizeHeight: "900",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,8 +99,11 @@ func TestMangoSetAndLoadRoundTrip(t *testing.T) {
|
|||||||
if got.Actions.Workspace != "9" {
|
if got.Actions.Workspace != "9" {
|
||||||
t.Errorf("Workspace = %q, want 9", got.Actions.Workspace)
|
t.Errorf("Workspace = %q, want 9", got.Actions.Workspace)
|
||||||
}
|
}
|
||||||
if got.Actions.Size != "1000x900" {
|
if got.Actions.SizeWidth != "1000" {
|
||||||
t.Errorf("Size = %q, want 1000x900", got.Actions.Size)
|
t.Errorf("SizeWidth = %q, want 1000", got.Actions.SizeWidth)
|
||||||
|
}
|
||||||
|
if got.Actions.SizeHeight != "900" {
|
||||||
|
t.Errorf("SizeHeight = %q, want 900", got.Actions.SizeHeight)
|
||||||
}
|
}
|
||||||
if got.Actions.OpenFloating == nil || !*got.Actions.OpenFloating {
|
if got.Actions.OpenFloating == nil || !*got.Actions.OpenFloating {
|
||||||
t.Errorf("OpenFloating = %v, want true", got.Actions.OpenFloating)
|
t.Errorf("OpenFloating = %v, want true", got.Actions.OpenFloating)
|
||||||
@@ -114,3 +118,40 @@ func TestMangoSetAndLoadRoundTrip(t *testing.T) {
|
|||||||
t.Errorf("after remove got %d rules, want 0", len(loaded))
|
t.Errorf("after remove got %d rules, want 0", len(loaded))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMangoRoundTripWithSizeWidthHeight(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
provider := NewMangoWritableProvider(tmpDir)
|
||||||
|
|
||||||
|
rule := windowrules.WindowRule{
|
||||||
|
ID: "rule_roundtrip",
|
||||||
|
Name: "Size Test",
|
||||||
|
Enabled: true,
|
||||||
|
MatchCriteria: windowrules.MatchCriteria{
|
||||||
|
AppID: "testapp",
|
||||||
|
},
|
||||||
|
Actions: windowrules.Actions{
|
||||||
|
SizeWidth: "800",
|
||||||
|
SizeHeight: "600",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := provider.SetRule(rule); err != nil {
|
||||||
|
t.Fatalf("SetRule: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := provider.LoadDMSRules()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadDMSRules: %v", err)
|
||||||
|
}
|
||||||
|
if len(loaded) != 1 {
|
||||||
|
t.Fatalf("got %d rules, want 1", len(loaded))
|
||||||
|
}
|
||||||
|
got := loaded[0]
|
||||||
|
if got.Actions.SizeWidth != "800" {
|
||||||
|
t.Errorf("SizeWidth = %q, want 800", got.Actions.SizeWidth)
|
||||||
|
}
|
||||||
|
if got.Actions.SizeHeight != "600" {
|
||||||
|
t.Errorf("SizeHeight = %q, want 600", got.Actions.SizeHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,8 +51,10 @@ type Actions struct {
|
|||||||
DefaultFloatingX *int `json:"defaultFloatingX,omitempty"`
|
DefaultFloatingX *int `json:"defaultFloatingX,omitempty"`
|
||||||
DefaultFloatingY *int `json:"defaultFloatingY,omitempty"`
|
DefaultFloatingY *int `json:"defaultFloatingY,omitempty"`
|
||||||
DefaultFloatingRelativeTo string `json:"defaultFloatingRelativeTo,omitempty"`
|
DefaultFloatingRelativeTo string `json:"defaultFloatingRelativeTo,omitempty"`
|
||||||
Size string `json:"size,omitempty"`
|
MoveX string `json:"moveX,omitempty"`
|
||||||
Move string `json:"move,omitempty"`
|
MoveY string `json:"moveY,omitempty"`
|
||||||
|
SizeWidth string `json:"sizeWidth,omitempty"`
|
||||||
|
SizeHeight string `json:"sizeHeight,omitempty"`
|
||||||
Monitor string `json:"monitor,omitempty"`
|
Monitor string `json:"monitor,omitempty"`
|
||||||
Workspace string `json:"workspace,omitempty"`
|
Workspace string `json:"workspace,omitempty"`
|
||||||
Tile *bool `json:"tile,omitempty"`
|
Tile *bool `json:"tile,omitempty"`
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func (ctx *Context) GetDispatch() func() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return func() error {
|
return func() (dispatchErr error) {
|
||||||
proxy, ok := ctx.objects.Load(senderID)
|
proxy, ok := ctx.objects.Load(senderID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil // Proxy already deleted via delete_id, silently ignore
|
return nil // Proxy already deleted via delete_id, silently ignore
|
||||||
@@ -111,6 +111,14 @@ func (ctx *Context) GetDispatch() func() error {
|
|||||||
return fmt.Errorf("%w (senderID=%d)", ErrDispatchSenderUnsupported, senderID)
|
return fmt.Errorf("%w (senderID=%d)", ErrDispatchSenderUnsupported, senderID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generated Dispatch methods don't bounds-check wire data; surface a
|
||||||
|
// decoder panic as an error instead of crashing the process
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
dispatchErr = fmt.Errorf("dispatch: panic handling opcode=%d senderID=%d: %v", opcode, senderID, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
sender.Dispatch(opcode, fd, data)
|
sender.Dispatch(opcode, fd, data)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-10
@@ -17,15 +17,21 @@ This is a DMS maintained repo for VoidLinux until these packages are officially
|
|||||||
|
|
||||||
### Using the Self-Hosted Repositories
|
### Using the Self-Hosted Repositories
|
||||||
|
|
||||||
We serve both stable release and development packages directly from our repository branches.
|
We serve both stable release and development packages from Cloudflare R2 at
|
||||||
|
`void.danklinux.com`.
|
||||||
|
|
||||||
|
> **Repository migration:** the former GitHub Pages repositories will be
|
||||||
|
> frozen for 14 days at cutover. Their retirement date will be announced when
|
||||||
|
> the snapshots are frozen. Replace any existing `avengemedia.github.io`
|
||||||
|
> entries with the URLs below.
|
||||||
|
|
||||||
#### 1. Add Repository Configurations
|
#### 1. Add Repository Configurations
|
||||||
|
|
||||||
Create configuration files in `/etc/xbps.d/` pointing to our repositories (needed for both stable and git/nightly variants):
|
Create configuration files in `/etc/xbps.d/` pointing to our repositories (needed for both stable and git/nightly variants):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
echo "repository=https://avengemedia.github.io/DankMaterialShell/current" | sudo tee /etc/xbps.d/dms.conf
|
echo "repository=https://void.danklinux.com/dms/current" | sudo tee /etc/xbps.d/dms.conf
|
||||||
echo "repository=https://avengemedia.github.io/DankLinux/current" | sudo tee /etc/xbps.d/danklinux.conf
|
echo "repository=https://void.danklinux.com/danklinux/current" | sudo tee /etc/xbps.d/danklinux.conf
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. Install DMS
|
#### 2. Install DMS
|
||||||
@@ -52,7 +58,8 @@ checkout at `srcpkgs/<pkg>/template` to build or submit it.
|
|||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
Installing `dms` automatically pulls in `quickshell`, `accountsservice`, `dgop`,
|
Installing `dms` automatically pulls in `quickshell`, `accountsservice`, `dgop`,
|
||||||
`matugen` (which drives the Material You theming), `dbus`, and `elogind`.
|
`matugen` (which drives the Material You theming), `dbus`, `elogind`, and
|
||||||
|
`mesa-dri` (GL drivers, required for compositors to render).
|
||||||
The rest are optional, install whichever features you want:
|
The rest are optional, install whichever features you want:
|
||||||
|
|
||||||
| Package | Enables |
|
| Package | Enables |
|
||||||
@@ -117,7 +124,7 @@ sudo ln -sf /etc/sv/dbus /var/service/dbus
|
|||||||
sudo ln -sf /etc/sv/elogind /var/service/elogind
|
sudo ln -sf /etc/sv/elogind /var/service/elogind
|
||||||
```
|
```
|
||||||
|
|
||||||
The `dankinstall` Void path does this automatically after installing packages.
|
The `dankinstall` Void path enables both services after installing packages.
|
||||||
|
|
||||||
## Greeter (optional)
|
## Greeter (optional)
|
||||||
|
|
||||||
@@ -128,8 +135,16 @@ dms greeter enable # configures greetd + the Void seat/PAM bits below
|
|||||||
dms greeter sync # optional: share theming with the shell
|
dms greeter sync # optional: share theming with the shell
|
||||||
```
|
```
|
||||||
|
|
||||||
`dms greeter enable` handles what logind does automatically on systemd: it points
|
`dms-greeter` requires D-Bus and elogind. `dms greeter enable` enables the
|
||||||
greetd at the greeter, enables `seatd`, adds `_greeter` to the `_seatd`/`video`/
|
`dbus` and `elogind` runit services, configures greetd for elogind
|
||||||
`input` groups, and adds `pam_rundir` to `/etc/pam.d/greetd` (so the post-login
|
(`LIBSEAT_BACKEND=logind`), adds `_greeter` to the `video` and `input` groups,
|
||||||
session gets an `XDG_RUNTIME_DIR`). A Wayland compositor and a working DRM device
|
and adds `pam_rundir` to `/etc/pam.d/greetd` (so the post-login session gets an
|
||||||
(`/dev/dri/card*`) are required and not pulled in automatically.
|
`XDG_RUNTIME_DIR`). It disables seatd if enabled: on Void, seatd is the
|
||||||
|
alternative to elogind, and running both fights over the seat. Greeter sessions
|
||||||
|
are launched through `dbus-run-session`. A Wayland compositor and a working DRM
|
||||||
|
device (`/dev/dri/card*`) are required and not pulled in automatically.
|
||||||
|
|
||||||
|
`dms greeter enable` also rewrites `/etc/sv/greetd/run` to wait for the `dbus`
|
||||||
|
and `elogind` services, preventing a first-boot race that can leave the greeter
|
||||||
|
on a black screen. greetd package updates restore the stock run script; re-run
|
||||||
|
`dms greeter enable` afterwards.
|
||||||
|
|||||||
@@ -2,18 +2,19 @@
|
|||||||
#
|
#
|
||||||
# greetd greeter for DankMaterialShell
|
# greetd greeter for DankMaterialShell
|
||||||
# Builds from the same DMS release tarball as 'dms'; keep version/checksum in sync.
|
# Builds from the same DMS release tarball as 'dms'; keep version/checksum in sync.
|
||||||
|
# CI rewrites version/checksum from the selected release tag before building.
|
||||||
# Setup is done by `dms greeter enable`, not by this package — see distro/void/README.md.
|
# Setup is done by `dms greeter enable`, not by this package — see distro/void/README.md.
|
||||||
pkgname=dms-greeter
|
pkgname=dms-greeter
|
||||||
version=1.4.6
|
version=1.5.0
|
||||||
revision=1
|
revision=2
|
||||||
short_desc="DankMaterialShell greeter for greetd"
|
short_desc="DankMaterialShell greeter for greetd"
|
||||||
maintainer="AvengeMedia <AvengeMedia.US@gmail.com>"
|
maintainer="AvengeMedia <AvengeMedia.US@gmail.com>"
|
||||||
license="MIT"
|
license="MIT"
|
||||||
homepage="https://danklinux.com"
|
homepage="https://danklinux.com"
|
||||||
distfiles="https://github.com/AvengeMedia/DankMaterialShell/archive/refs/tags/v${version}.tar.gz"
|
distfiles="https://github.com/AvengeMedia/DankMaterialShell/archive/refs/tags/v${version}.tar.gz"
|
||||||
checksum=f54601e522c883fa9cce02bec070e4321e47389a1cf453e7ad0bb7379ad91b61
|
checksum=14f2678d6a15223ba069d1b8c8a21a5564b735d190c231f62ed44fd8bf48677c
|
||||||
|
|
||||||
depends="greetd quickshell acl-progs seatd pam_rundir"
|
depends="greetd quickshell acl-progs dbus elogind pam_rundir mesa-dri"
|
||||||
|
|
||||||
# Cache dir the greeter uses as $HOME (owned by greetd's _greeter user).
|
# Cache dir the greeter uses as $HOME (owned by greetd's _greeter user).
|
||||||
make_dirs="/var/cache/dms-greeter 0750 _greeter _greeter"
|
make_dirs="/var/cache/dms-greeter 0750 _greeter _greeter"
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
#
|
#
|
||||||
# NOTE: the binary is built with the `distro_binary` build tag, which is the
|
# NOTE: the binary is built with the `distro_binary` build tag, which is the
|
||||||
# packaged variant upstream ships (it drops the in-app self-update command).
|
# packaged variant upstream ships (it drops the in-app self-update command).
|
||||||
|
# CI rewrites version/checksum from the selected release tag before building.
|
||||||
pkgname=dms
|
pkgname=dms
|
||||||
version=1.4.6
|
version=1.5.0
|
||||||
revision=1
|
revision=2
|
||||||
build_style=go
|
build_style=go
|
||||||
build_wrksrc="core"
|
build_wrksrc="core"
|
||||||
go_import_path="github.com/AvengeMedia/DankMaterialShell/core"
|
go_import_path="github.com/AvengeMedia/DankMaterialShell/core"
|
||||||
@@ -19,10 +20,10 @@ license="MIT"
|
|||||||
homepage="https://danklinux.com"
|
homepage="https://danklinux.com"
|
||||||
changelog="https://github.com/AvengeMedia/DankMaterialShell/releases"
|
changelog="https://github.com/AvengeMedia/DankMaterialShell/releases"
|
||||||
distfiles="https://github.com/AvengeMedia/DankMaterialShell/archive/refs/tags/v${version}.tar.gz"
|
distfiles="https://github.com/AvengeMedia/DankMaterialShell/archive/refs/tags/v${version}.tar.gz"
|
||||||
checksum=f54601e522c883fa9cce02bec070e4321e47389a1cf453e7ad0bb7379ad91b61
|
checksum=14f2678d6a15223ba069d1b8c8a21a5564b735d190c231f62ed44fd8bf48677c
|
||||||
|
|
||||||
# Optional feature deps are listed in distro/void/README.md.
|
# Optional feature deps are listed in distro/void/README.md.
|
||||||
depends="quickshell accountsservice dgop matugen dbus elogind"
|
depends="quickshell accountsservice dgop matugen dbus elogind mesa-dri"
|
||||||
|
|
||||||
post_install() {
|
post_install() {
|
||||||
# QML shell tree (build_style=go already installed the dms binary)
|
# QML shell tree (build_style=go already installed the dms binary)
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
The Wolverine
|
Marble Tabby
|
||||||
|
|||||||
@@ -9,38 +9,64 @@ import qs.Common
|
|||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
readonly property Transition add: Transition {
|
// 0ms ViewTransitions break ListView delegate cleanup, so null the set when the shortest
|
||||||
DankAnim {
|
// duration truncates to 0. Keep this gate - don't inline these back into add/remove/etc.
|
||||||
property: "opacity"
|
readonly property bool enabled: Math.floor(Theme.currentAnimationBaseDuration * 0.4) >= 1
|
||||||
from: 0
|
|
||||||
to: 1
|
readonly property Transition add: enabled ? _add : null
|
||||||
duration: Theme.expressiveDurations.expressiveEffects
|
readonly property Transition remove: null
|
||||||
easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel
|
readonly property Transition displaced: enabled ? _displaced : null
|
||||||
|
readonly property Transition move: enabled ? _move : null
|
||||||
|
|
||||||
|
readonly property int _staggerMs: Math.round(Theme.currentAnimationBaseDuration * 0.03)
|
||||||
|
readonly property int _staggerCap: 8
|
||||||
|
|
||||||
|
readonly property Transition _add: Transition {
|
||||||
|
id: addTransition
|
||||||
|
|
||||||
|
SequentialAnimation {
|
||||||
|
PropertyAction {
|
||||||
|
property: "opacity"
|
||||||
|
value: 0
|
||||||
|
}
|
||||||
|
PauseAnimation {
|
||||||
|
duration: Math.max(0, Math.min(addTransition.ViewTransition.index - (addTransition.ViewTransition.targetIndexes[0] ?? 0), root._staggerCap)) * root._staggerMs
|
||||||
|
}
|
||||||
|
DankAnim {
|
||||||
|
property: "opacity"
|
||||||
|
from: 0
|
||||||
|
to: 1
|
||||||
|
duration: Theme.expressiveDurations.fast
|
||||||
|
easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly property Transition remove: Transition {
|
readonly property Transition _displaced: Transition {
|
||||||
DankAnim {
|
DankAnim {
|
||||||
property: "opacity"
|
property: "y"
|
||||||
to: 0
|
|
||||||
duration: Theme.expressiveDurations.fast
|
duration: Theme.expressiveDurations.fast
|
||||||
easing.bezierCurve: Theme.expressiveCurves.emphasizedAccel
|
easing.bezierCurve: Theme.expressiveCurves.standard
|
||||||
|
}
|
||||||
|
DankAnim {
|
||||||
|
property: "opacity"
|
||||||
|
to: 1
|
||||||
|
duration: Theme.expressiveDurations.fast
|
||||||
|
easing.bezierCurve: Theme.expressiveCurves.standard
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly property Transition displaced: Transition {
|
readonly property Transition _move: Transition {
|
||||||
DankAnim {
|
DankAnim {
|
||||||
property: "y"
|
property: "y"
|
||||||
duration: Theme.expressiveDurations.normal
|
duration: Theme.expressiveDurations.fast
|
||||||
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
|
easing.bezierCurve: Theme.expressiveCurves.standard
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
readonly property Transition move: Transition {
|
|
||||||
DankAnim {
|
DankAnim {
|
||||||
property: "y"
|
property: "opacity"
|
||||||
duration: Theme.expressiveDurations.normal
|
to: 1
|
||||||
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
|
duration: Theme.expressiveDurations.fast
|
||||||
|
easing.bezierCurve: Theme.expressiveCurves.standard
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import qs.Services
|
|||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
readonly property var log: Log.scoped("Paths")
|
||||||
|
|
||||||
readonly property url home: StandardPaths.standardLocations(StandardPaths.HomeLocation)[0]
|
readonly property url home: StandardPaths.standardLocations(StandardPaths.HomeLocation)[0]
|
||||||
readonly property url pictures: StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0]
|
readonly property url pictures: StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0]
|
||||||
readonly property url xdgCache: StandardPaths.standardLocations(StandardPaths.GenericCacheLocation)[0]
|
readonly property url xdgCache: StandardPaths.standardLocations(StandardPaths.GenericCacheLocation)[0]
|
||||||
@@ -23,7 +25,13 @@ Singleton {
|
|||||||
Component.onCompleted: mkdir(imagecache)
|
Component.onCompleted: mkdir(imagecache)
|
||||||
|
|
||||||
function stringify(path: url): string {
|
function stringify(path: url): string {
|
||||||
return path.toString().replace(/%20/g, " ");
|
const raw = path.toString();
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(raw);
|
||||||
|
} catch (e) {
|
||||||
|
log.warn("failed to decode path:", raw, e);
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandTilde(path: string): string {
|
function expandTilde(path: string): string {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Singleton {
|
|||||||
readonly property var log: Log.scoped("Proc")
|
readonly property var log: Log.scoped("Proc")
|
||||||
|
|
||||||
readonly property int noTimeout: -1
|
readonly property int noTimeout: -1
|
||||||
|
readonly property string dmsBin: Quickshell.env("DMS_EXECUTABLE") || "dms"
|
||||||
property int defaultDebounceMs: 50
|
property int defaultDebounceMs: 50
|
||||||
property int defaultTimeoutMs: 10000
|
property int defaultTimeoutMs: 10000
|
||||||
property var _procDebouncers: ({})
|
property var _procDebouncers: ({})
|
||||||
|
|||||||
@@ -1252,10 +1252,11 @@ Singleton {
|
|||||||
saveSettings();
|
saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
function addLauncherHistory(query) {
|
function addLauncherHistory(query, skipLastQuery) {
|
||||||
let q = query.trim();
|
let q = query.trim();
|
||||||
|
|
||||||
setLauncherLastQuery(q);
|
if (!skipLastQuery)
|
||||||
|
setLauncherLastQuery(q);
|
||||||
|
|
||||||
if (!q)
|
if (!q)
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -190,7 +190,12 @@ Singleton {
|
|||||||
property int firstDayOfWeek: -1
|
property int firstDayOfWeek: -1
|
||||||
property bool showWeekNumber: false
|
property bool showWeekNumber: false
|
||||||
property string calendarBackend: "auto"
|
property string calendarBackend: "auto"
|
||||||
property bool use24HourClock: true
|
property string clockFormat: "auto"
|
||||||
|
readonly property bool localeUses24Hour: {
|
||||||
|
const fmt = Qt.locale().timeFormat(Locale.ShortFormat).replace(/'[^']*'/g, "");
|
||||||
|
return !/[aA]/.test(fmt);
|
||||||
|
}
|
||||||
|
readonly property bool use24HourClock: clockFormat === "24h" ? true : (clockFormat === "12h" ? false : localeUses24Hour)
|
||||||
property bool showSeconds: false
|
property bool showSeconds: false
|
||||||
property bool padHours12Hour: false
|
property bool padHours12Hour: false
|
||||||
property bool useFahrenheit: false
|
property bool useFahrenheit: false
|
||||||
@@ -455,6 +460,7 @@ Singleton {
|
|||||||
property bool scrollTitleEnabled: true
|
property bool scrollTitleEnabled: true
|
||||||
property bool mediaAdaptiveWidthEnabled: true
|
property bool mediaAdaptiveWidthEnabled: true
|
||||||
property bool audioVisualizerEnabled: true
|
property bool audioVisualizerEnabled: true
|
||||||
|
property bool mediaUseAlbumArtAccent: false
|
||||||
property string audioScrollMode: "volume"
|
property string audioScrollMode: "volume"
|
||||||
property int audioWheelScrollAmount: 5
|
property int audioWheelScrollAmount: 5
|
||||||
property bool audioDeviceScrollVolumeEnabled: false
|
property bool audioDeviceScrollVolumeEnabled: false
|
||||||
@@ -491,9 +497,6 @@ Singleton {
|
|||||||
property bool greeterEnableFprint: false
|
property bool greeterEnableFprint: false
|
||||||
property bool greeterEnableU2f: false
|
property bool greeterEnableU2f: false
|
||||||
property string greeterWallpaperPath: ""
|
property string greeterWallpaperPath: ""
|
||||||
property bool greeterUse24HourClock: true
|
|
||||||
property bool greeterShowSeconds: false
|
|
||||||
property bool greeterPadHours12Hour: false
|
|
||||||
property string greeterLockDateFormat: ""
|
property string greeterLockDateFormat: ""
|
||||||
property string greeterFontFamily: ""
|
property string greeterFontFamily: ""
|
||||||
property string greeterWallpaperFillMode: ""
|
property string greeterWallpaperFillMode: ""
|
||||||
@@ -517,6 +520,7 @@ Singleton {
|
|||||||
property var appDrawerSectionViewModes: ({})
|
property var appDrawerSectionViewModes: ({})
|
||||||
onAppDrawerSectionViewModesChanged: saveSettings()
|
onAppDrawerSectionViewModesChanged: saveSettings()
|
||||||
property bool niriOverviewOverlayEnabled: true
|
property bool niriOverviewOverlayEnabled: true
|
||||||
|
property string niriOverviewLauncherStyle: "full"
|
||||||
property string dankLauncherV2Size: "compact"
|
property string dankLauncherV2Size: "compact"
|
||||||
property bool dankLauncherV2ShowSourceBadges: true
|
property bool dankLauncherV2ShowSourceBadges: true
|
||||||
property bool dankLauncherV2BorderEnabled: false
|
property bool dankLauncherV2BorderEnabled: false
|
||||||
@@ -615,10 +619,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setDashTabEnabled(id, on) {
|
function setDashTabEnabled(id, on) {
|
||||||
const current = getDashTabs();
|
dashTabs = getDashTabs().map(t => t.id === id ? {
|
||||||
if (!on && id !== "settings" && current.filter(t => t.enabled && t.id !== "settings").length <= 1)
|
|
||||||
return;
|
|
||||||
dashTabs = current.map(t => t.id === id ? {
|
|
||||||
"id": t.id,
|
"id": t.id,
|
||||||
"enabled": on
|
"enabled": on
|
||||||
} : t);
|
} : t);
|
||||||
@@ -955,6 +956,7 @@ Singleton {
|
|||||||
property int updaterIntervalSeconds: 1800
|
property int updaterIntervalSeconds: 1800
|
||||||
property bool updaterIncludeFlatpak: true
|
property bool updaterIncludeFlatpak: true
|
||||||
property bool updaterAllowAUR: true
|
property bool updaterAllowAUR: true
|
||||||
|
property var updaterIgnoredPackages: []
|
||||||
|
|
||||||
property string displayNameMode: "system"
|
property string displayNameMode: "system"
|
||||||
property var screenPreferences: ({})
|
property var screenPreferences: ({})
|
||||||
@@ -1750,6 +1752,11 @@ Singleton {
|
|||||||
delete obj.lockScreenActiveMonitor;
|
delete obj.lockScreenActiveMonitor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (obj?.use24HourClock !== undefined && obj?.clockFormat === undefined) {
|
||||||
|
obj.clockFormat = obj.use24HourClock ? "24h" : "12h";
|
||||||
|
delete obj.use24HourClock;
|
||||||
|
}
|
||||||
|
|
||||||
Store.parse(root, obj);
|
Store.parse(root, obj);
|
||||||
|
|
||||||
if (obj?.directionalAnimationMode === 3 && frameMode !== "connected")
|
if (obj?.directionalAnimationMode === 3 && frameMode !== "connected")
|
||||||
|
|||||||
@@ -1336,9 +1336,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isGreeterMode) {
|
if (!isGreeterMode) {
|
||||||
if (!matugenAvailable) {
|
PortalService.setLightMode(light);
|
||||||
PortalService.setLightMode(light);
|
|
||||||
}
|
|
||||||
if (typeof SettingsData !== "undefined") {
|
if (typeof SettingsData !== "undefined") {
|
||||||
SettingsData.updateCosmicThemeMode(light);
|
SettingsData.updateCosmicThemeMode(light);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -476,7 +476,7 @@ Singleton {
|
|||||||
if (exitCode === 0)
|
if (exitCode === 0)
|
||||||
return "missing_enrollment";
|
return "missing_enrollment";
|
||||||
if (exitCode === 127 || (output || "").includes("__missing_command__"))
|
if (exitCode === 127 || (output || "").includes("__missing_command__"))
|
||||||
return "probe_failed";
|
return "missing_pam_support";
|
||||||
return pamFprintDetected ? "probe_failed" : "missing_pam_support";
|
return pamFprintDetected ? "probe_failed" : "missing_pam_support";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ var SPEC = {
|
|||||||
firstDayOfWeek: { def: -1 },
|
firstDayOfWeek: { def: -1 },
|
||||||
showWeekNumber: { def: false },
|
showWeekNumber: { def: false },
|
||||||
calendarBackend: { def: "auto" },
|
calendarBackend: { def: "auto" },
|
||||||
use24HourClock: { def: true },
|
clockFormat: { def: "auto", onChange: "markGreeterSyncPending" },
|
||||||
showSeconds: { def: false },
|
showSeconds: { def: false, onChange: "markGreeterSyncPending" },
|
||||||
padHours12Hour: { def: false },
|
padHours12Hour: { def: false, onChange: "markGreeterSyncPending" },
|
||||||
useFahrenheit: { def: false },
|
useFahrenheit: { def: false },
|
||||||
windSpeedUnit: { def: "kmh" },
|
windSpeedUnit: { def: "kmh" },
|
||||||
nightModeEnabled: { def: false },
|
nightModeEnabled: { def: false },
|
||||||
@@ -185,6 +185,7 @@ var SPEC = {
|
|||||||
scrollTitleEnabled: { def: true },
|
scrollTitleEnabled: { def: true },
|
||||||
mediaAdaptiveWidthEnabled: { def: true },
|
mediaAdaptiveWidthEnabled: { def: true },
|
||||||
audioVisualizerEnabled: { def: true },
|
audioVisualizerEnabled: { def: true },
|
||||||
|
mediaUseAlbumArtAccent: { def: false },
|
||||||
audioScrollMode: { def: "volume" },
|
audioScrollMode: { def: "volume" },
|
||||||
audioWheelScrollAmount: { def: 5 },
|
audioWheelScrollAmount: { def: 5 },
|
||||||
audioDeviceScrollVolumeEnabled: { def: false },
|
audioDeviceScrollVolumeEnabled: { def: false },
|
||||||
@@ -229,9 +230,6 @@ var SPEC = {
|
|||||||
greeterEnableFprint: { def: false, onChange: "scheduleAuthApply" },
|
greeterEnableFprint: { def: false, onChange: "scheduleAuthApply" },
|
||||||
greeterEnableU2f: { def: false, onChange: "scheduleAuthApply" },
|
greeterEnableU2f: { def: false, onChange: "scheduleAuthApply" },
|
||||||
greeterWallpaperPath: { def: "", onChange: "markGreeterSyncPending" },
|
greeterWallpaperPath: { def: "", onChange: "markGreeterSyncPending" },
|
||||||
greeterUse24HourClock: { def: true, onChange: "markGreeterSyncPending" },
|
|
||||||
greeterShowSeconds: { def: false, onChange: "markGreeterSyncPending" },
|
|
||||||
greeterPadHours12Hour: { def: false, onChange: "markGreeterSyncPending" },
|
|
||||||
greeterLockDateFormat: { def: "", onChange: "markGreeterSyncPending" },
|
greeterLockDateFormat: { def: "", onChange: "markGreeterSyncPending" },
|
||||||
greeterFontFamily: { def: "", onChange: "markGreeterSyncPending" },
|
greeterFontFamily: { def: "", onChange: "markGreeterSyncPending" },
|
||||||
greeterWallpaperFillMode: { def: "", onChange: "markGreeterSyncPending" },
|
greeterWallpaperFillMode: { def: "", onChange: "markGreeterSyncPending" },
|
||||||
@@ -253,6 +251,7 @@ var SPEC = {
|
|||||||
spotlightSectionViewModes: { def: {} },
|
spotlightSectionViewModes: { def: {} },
|
||||||
appDrawerSectionViewModes: { def: {} },
|
appDrawerSectionViewModes: { def: {} },
|
||||||
niriOverviewOverlayEnabled: { def: true },
|
niriOverviewOverlayEnabled: { def: true },
|
||||||
|
niriOverviewLauncherStyle: { def: "full" },
|
||||||
dankLauncherV2Size: { def: "compact" },
|
dankLauncherV2Size: { def: "compact" },
|
||||||
dankLauncherV2ShowSourceBadges: { def: true },
|
dankLauncherV2ShowSourceBadges: { def: true },
|
||||||
dankLauncherV2BorderEnabled: { def: false },
|
dankLauncherV2BorderEnabled: { def: false },
|
||||||
@@ -516,6 +515,7 @@ var SPEC = {
|
|||||||
updaterIntervalSeconds: { def: 1800 },
|
updaterIntervalSeconds: { def: 1800 },
|
||||||
updaterIncludeFlatpak: { def: true },
|
updaterIncludeFlatpak: { def: true },
|
||||||
updaterAllowAUR: { def: true },
|
updaterAllowAUR: { def: true },
|
||||||
|
updaterIgnoredPackages: { def: [] },
|
||||||
|
|
||||||
displayNameMode: { def: "system" },
|
displayNameMode: { def: "system" },
|
||||||
screenPreferences: { def: {} },
|
screenPreferences: { def: {} },
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user