mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-30 06:57:49 -04:00
Compare commits
151
Commits
stable
...
stable-2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10cee892b4 | ||
|
|
0299652efb | ||
|
|
e7707fb5e4 | ||
|
|
03f4dc0aaf | ||
|
|
eb8c50c4b8 | ||
|
|
9c18799369 | ||
|
|
dcd8d81467 | ||
|
|
366826e8c7 | ||
|
|
7e758c935f | ||
|
|
1905f2135b | ||
|
|
974c4d3167 | ||
|
|
3df95c8f4f | ||
|
|
ad05bdf70b | ||
|
|
d22b5c38c9 | ||
|
|
5b52ccad7c | ||
|
|
9b762b8ec0 | ||
|
|
2ba4f1355c | ||
|
|
08f7acad06 | ||
|
|
c25da363ec | ||
|
|
74b4175876 | ||
|
|
2b5bf15e76 | ||
|
|
2c42fb8492 | ||
|
|
bee384abbd | ||
|
|
ee249838f5 | ||
|
|
3c365533c7 | ||
|
|
47744d34b7 | ||
|
|
c813f65581 | ||
|
|
527bb0613e | ||
|
|
41e0f1d434 | ||
|
|
9be1bc03e9 | ||
|
|
57dd939d85 | ||
|
|
5fea906c9f | ||
|
|
b528af8469 | ||
|
|
957e87afa5 | ||
|
|
8c5bfac638 | ||
|
|
c504ebcb95 | ||
|
|
e36ef50482 | ||
|
|
8aac4c767a | ||
|
|
7a7f67bc24 | ||
|
|
9fd55f6f5e | ||
|
|
4a1fadc349 | ||
|
|
12f8b67d4f | ||
|
|
ff1bbe807d | ||
|
|
d90ff54ab9 | ||
|
|
15ddcf2ae1 | ||
|
|
8e10f620c9 | ||
|
|
e038d76fb6 | ||
|
|
7ae25d1d32 | ||
|
|
dba4d5a114 | ||
|
|
0d7298b996 | ||
|
|
d29daf8fdd | ||
|
|
43cb8c70b7 | ||
|
|
1efbf723f8 | ||
|
|
d694107f36 | ||
|
|
3eab6a52e0 | ||
|
|
bb06467edf | ||
|
|
726d9525c0 | ||
|
|
10008104ce | ||
|
|
0acaaa4b44 | ||
|
|
7ed525a679 | ||
|
|
463df0167f | ||
|
|
e6f0a5f219 | ||
|
|
d2ad778e6f | ||
|
|
69fc822ffd | ||
|
|
93d36ac7cd | ||
|
|
3f75012b7c | ||
|
|
433a84d824 | ||
|
|
b34a80b78f | ||
|
|
3dc1aed318 | ||
|
|
f9c73a77cf | ||
|
|
a10ee7d3ed | ||
|
|
a2a18ed7db | ||
|
|
471318575f | ||
|
|
7d337602d2 | ||
|
|
88fd192b0d | ||
|
|
523ef916b9 | ||
|
|
181e624c0d | ||
|
|
7ea6f5f7e0 | ||
|
|
28b9bb246b | ||
|
|
2f371015c4 | ||
|
|
3f02a44f5e | ||
|
|
ef4b3ae53f | ||
|
|
3f0769f169 | ||
|
|
6da523875f | ||
|
|
994d5bb150 | ||
|
|
3d95e0e8b8 | ||
|
|
d9e9cddae6 | ||
|
|
fdb4f88aec | ||
|
|
2c0998f1dc | ||
|
|
5ef5a7391b | ||
|
|
4e0985fbbb | ||
|
|
1979ccf886 | ||
|
|
2d7b65a8c3 | ||
|
|
5a210112aa | ||
|
|
260d4fda73 | ||
|
|
290d04c511 | ||
|
|
6d316539d7 | ||
|
|
defab4fbc8 | ||
|
|
d2e191d642 | ||
|
|
fe1241bf91 | ||
|
|
6d00fd7295 | ||
|
|
1c54262b9f | ||
|
|
7622a6cd3c | ||
|
|
50ba96a279 | ||
|
|
148c7f6240 | ||
|
|
53b9bfa2a3 | ||
|
|
17f32f6c55 | ||
|
|
7c2b4228f6 | ||
|
|
c831a9926a | ||
|
|
514c3e3eb8 | ||
|
|
09449c0b37 | ||
|
|
6c3a646dc4 | ||
|
|
a680e1bd39 | ||
|
|
36ebe0e3ef | ||
|
|
0102bf6d40 | ||
|
|
dbf07ea644 | ||
|
|
58d741ec07 | ||
|
|
2befd45974 | ||
|
|
99eb2a06d0 | ||
|
|
163c4d5a1f | ||
|
|
d4ecaf82d4 | ||
|
|
ec4e04f06f | ||
|
|
5aa2e3f0d4 | ||
|
|
11f99cc13f | ||
|
|
0ae0b96885 | ||
|
|
f0826a953f | ||
|
|
b5da117206 | ||
|
|
430db4e493 | ||
|
|
2a636c7092 | ||
|
|
8848ad82f1 | ||
|
|
404d5362e5 | ||
|
|
4ab57f14b8 | ||
|
|
ce81dfc95a | ||
|
|
a5511fe8d7 | ||
|
|
7d930f8f6c | ||
|
|
11ef76b9d0 | ||
|
|
e48de4cc2a | ||
|
|
1683fceac5 | ||
|
|
2a83c5c69b | ||
|
|
1acfae1755 | ||
|
|
1e1dbebb70 | ||
|
|
53412d1f22 | ||
|
|
9ece8f26fa | ||
|
|
fd1295f959 | ||
|
|
1e3f16ac9d | ||
|
|
4fa6f58720 | ||
|
|
ff45b3b4b2 | ||
|
|
12e54e0225 | ||
|
|
f63d8bf4fb | ||
|
|
1e8423992d | ||
|
|
c4ecb988b4 |
No files matched your search
@@ -93,7 +93,7 @@ Injected: `pluginId`, `pluginService`, `popoutService`, `entryId`, `dashHost` (`
|
|||||||
|
|
||||||
Base provides: `options`, `pluginData`, `getData(key, def)`, `setData(key, value)`.
|
Base provides: `options`, `pluginData`, `getData(key, def)`, `setData(key, value)`.
|
||||||
|
|
||||||
Optional: `implicitHeight`, `focusTarget`, `restoreFocus()`, `handleKeyEvent(event)` (return true to consume; Ctrl+Tab is reserved), `blocksTabNavigation`, `menuActions`, `signal tabRequested(string id)`, `signal navFocusRequested`.
|
Optional: `implicitHeight`, `focusTarget`, `restoreFocus()`, `handleKeyEvent(event)` (return true to consume; Ctrl+Tab and Ctrl+PgUp/PgDown are reserved), `blocksTabNavigation`, `menuActions`, `signal tabRequested(string id)`, `signal navFocusRequested`.
|
||||||
|
|
||||||
For tiles inside a tab, use `Card` from `qs.Modules.DankDash.Overview` so colors, radius and tones match the overview.
|
For tiles inside a tab, use `Card` from `qs.Modules.DankDash.Overview` so colors, radius and tones match the overview.
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Select your Installation Method
|
label: Select your Installation Method
|
||||||
options:
|
options:
|
||||||
- DankInstaller
|
- Dux Installer
|
||||||
- Distro Packaging
|
- Distro Packaging
|
||||||
- Source
|
- Source
|
||||||
validations:
|
validations:
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Select your Installation Method
|
label: Select your Installation Method
|
||||||
options:
|
options:
|
||||||
- DankInstaller
|
- Dux Installer
|
||||||
- Distro Packaging
|
- Distro Packaging
|
||||||
- Source
|
- Source
|
||||||
validations:
|
validations:
|
||||||
|
|||||||
@@ -28,4 +28,4 @@
|
|||||||
- [ ] New user-facing strings are wrapped in `I18n.tr()` with translator context, reusing existing terms where possible
|
- [ ] New user-facing strings are wrapped in `I18n.tr()` with translator context, reusing existing terms where possible
|
||||||
- [ ] Go changes: ran `make fmt`, added/updated tests, `make test` passes, and `go mod tidy` is clean
|
- [ ] Go changes: ran `make fmt`, added/updated tests, `make test` passes, and `go mod tidy` is clean
|
||||||
- [ ] QML changes: ran `make lint-qml` with no new warnings
|
- [ ] QML changes: ran `make lint-qml` with no new warnings
|
||||||
- [ ] I have opened a corresponding pull request in dlx-docs to document any new behaviors: https://github.com/duxlabs-dev/dux-docs
|
- [ ] I have opened a corresponding pull request in dux-docs to document any new behaviors: https://github.com/duxlabs-dev/dux-docs
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
# Shared by dux-shell and dux-repo, which both write dms/current; keep both copies identical.
|
||||||
|
# ETag guard: publish aborts if another writer replaced x86_64-repodata mid-run.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
echo "usage: $0 download|publish" >&2
|
echo "usage: $0 download|is-indexed|index|publish" >&2
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,13 +21,54 @@ require_env() {
|
|||||||
|
|
||||||
require_env R2_BUCKET R2_ENDPOINT R2_PREFIX REPOSITORY_DIR
|
require_env R2_BUCKET R2_ENDPOINT R2_PREFIX REPOSITORY_DIR
|
||||||
|
|
||||||
|
REPODATA_KEY="${R2_PREFIX}/current/x86_64-repodata"
|
||||||
|
ETAG_STATE="$REPOSITORY_DIR/.repodata-etag"
|
||||||
|
|
||||||
aws_r2() {
|
aws_r2() {
|
||||||
aws --endpoint-url "$R2_ENDPOINT" "$@"
|
aws --endpoint-url "$R2_ENDPOINT" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Print the remote repodata ETag, or "none" when the object does not exist yet.
|
||||||
|
remote_repodata_etag() {
|
||||||
|
local out
|
||||||
|
if out="$(aws_r2 s3api head-object --bucket "$R2_BUCKET" --key "$REPODATA_KEY" \
|
||||||
|
--query ETag --output text 2>&1)"; then
|
||||||
|
printf '%s\n' "$out"
|
||||||
|
elif grep -qiE 'Not Found|404' <<<"$out"; then
|
||||||
|
echo none
|
||||||
|
else
|
||||||
|
printf '%s\n' "$out" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
record_repodata_etag() {
|
||||||
|
remote_repodata_etag > "$ETAG_STATE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Abort when another publisher replaced the index since we recorded it.
|
||||||
|
assert_repodata_unchanged() {
|
||||||
|
local stage="$1" expected actual
|
||||||
|
if [[ ! -s "$ETAG_STATE" ]]; then
|
||||||
|
echo "error: $ETAG_STATE is missing; run '$0 download' before publishing" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
expected="$(<"$ETAG_STATE")"
|
||||||
|
actual="$(remote_repodata_etag)"
|
||||||
|
if [[ "$expected" != "$actual" ]]; then
|
||||||
|
echo "error: ${REPODATA_KEY} changed while this run was building ($stage)" >&2
|
||||||
|
echo " recorded ETag: $expected" >&2
|
||||||
|
echo " current ETag: $actual" >&2
|
||||||
|
echo "another publisher won the race; nothing was overwritten. Re-run to retry." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
download() {
|
download() {
|
||||||
mkdir -p "$REPOSITORY_DIR/current" "$REPOSITORY_DIR/previous-current"
|
mkdir -p "$REPOSITORY_DIR/current" "$REPOSITORY_DIR/previous-current"
|
||||||
|
|
||||||
|
# ETag must be taken before the sync so a mid-sync publish is detected.
|
||||||
|
record_repodata_etag
|
||||||
aws_r2 s3 sync \
|
aws_r2 s3 sync \
|
||||||
"s3://${R2_BUCKET}/${R2_PREFIX}/current/" \
|
"s3://${R2_BUCKET}/${R2_PREFIX}/current/" \
|
||||||
"$REPOSITORY_DIR/current/" \
|
"$REPOSITORY_DIR/current/" \
|
||||||
@@ -34,8 +77,62 @@ download() {
|
|||||||
cp -a "$REPOSITORY_DIR/current/." "$REPOSITORY_DIR/previous-current/"
|
cp -a "$REPOSITORY_DIR/current/." "$REPOSITORY_DIR/previous-current/"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Staged pkgvers count as indexed too.
|
||||||
|
indexed_pkgvers() {
|
||||||
|
local repodata="$REPOSITORY_DIR/current/x86_64-repodata" member
|
||||||
|
[[ -s "$repodata" ]] || return 0
|
||||||
|
for member in index.plist stage.plist; do
|
||||||
|
tar -xOf "$repodata" "$member" 2>/dev/null | python3 -c '
|
||||||
|
import plistlib, sys
|
||||||
|
data = sys.stdin.buffer.read()
|
||||||
|
for pkg in (plistlib.loads(data).values() if data.strip() else ()):
|
||||||
|
print(pkg["pkgver"])
|
||||||
|
' || true
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Also catches packages uploaded by a run that died before its repodata PUT.
|
||||||
|
is_indexed() {
|
||||||
|
local known file
|
||||||
|
shopt -s nullglob
|
||||||
|
known="$(indexed_pkgvers)"
|
||||||
|
for file in "$REPOSITORY_DIR"/current/*.xbps; do
|
||||||
|
if ! grep -qxF "$(xbps-uhelper binpkgver "$(basename "$file")")" <<<"$known"; then
|
||||||
|
echo "$(basename "$file") is not in ${R2_PREFIX}/current/x86_64-repodata"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
index() {
|
||||||
|
require_env XBPS_PRIVATE_KEY
|
||||||
|
local dir="$REPOSITORY_DIR/current" sig
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
local pkgs=("$dir"/*.xbps)
|
||||||
|
if (( ${#pkgs[@]} == 0 )); then
|
||||||
|
echo "error: no .xbps files to index in $dir" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$dir"/*.sig
|
||||||
|
xbps-rindex -a "${pkgs[@]}"
|
||||||
|
xbps-rindex --remove-obsoletes "$dir"
|
||||||
|
|
||||||
|
# Unchanged packages keep their .sig2 byte-for-byte; drop those of removed packages.
|
||||||
|
for sig in "$dir"/*.sig2; do
|
||||||
|
[[ -f "${sig%.sig2}" ]] || rm -f "$sig"
|
||||||
|
done
|
||||||
|
|
||||||
|
XBPS_KEY_FILE="$(mktemp)"
|
||||||
|
trap 'rm -f "$XBPS_KEY_FILE"' EXIT
|
||||||
|
printf '%s\n' "$XBPS_PRIVATE_KEY" > "$XBPS_KEY_FILE"
|
||||||
|
xbps-rindex --sign --signedby "AvengeMedia <[email protected]>" --privkey "$XBPS_KEY_FILE" "$dir"
|
||||||
|
xbps-rindex --sign-pkg --privkey "$XBPS_KEY_FILE" "$dir"/*.xbps
|
||||||
|
}
|
||||||
|
|
||||||
build_manifest() {
|
build_manifest() {
|
||||||
local packages='[]'
|
local packages='[]' previous_sources='{}'
|
||||||
local file filename pkgver name version revision sha size
|
local file filename pkgver name version revision sha size
|
||||||
|
|
||||||
for file in "$REPOSITORY_DIR"/current/*.xbps; do
|
for file in "$REPOSITORY_DIR"/current/*.xbps; do
|
||||||
@@ -58,12 +155,21 @@ build_manifest() {
|
|||||||
<<<"$packages")"
|
<<<"$packages")"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# `sources` maps publishing repo -> last published commit; `source_commit` kept for older consumers.
|
||||||
|
if [[ -s "$REPOSITORY_DIR/previous-current/manifest.json" ]]; then
|
||||||
|
previous_sources="$(jq -c '.sources // {}' "$REPOSITORY_DIR/previous-current/manifest.json" 2>/dev/null || echo '{}')"
|
||||||
|
fi
|
||||||
|
|
||||||
jq -n \
|
jq -n \
|
||||||
--arg repository "$R2_PREFIX" \
|
--arg repository "$R2_PREFIX" \
|
||||||
|
--arg source_repository "$SOURCE_REPOSITORY" \
|
||||||
--arg source_commit "$SOURCE_COMMIT" \
|
--arg source_commit "$SOURCE_COMMIT" \
|
||||||
--arg published_at "$PUBLISHED_AT" \
|
--arg published_at "$PUBLISHED_AT" \
|
||||||
|
--argjson previous_sources "$previous_sources" \
|
||||||
--argjson packages "$packages" \
|
--argjson packages "$packages" \
|
||||||
'{schema: 1, repository: $repository, source_commit: $source_commit, published_at: $published_at, packages: $packages}' \
|
'{schema: 1, repository: $repository, source_repository: $source_repository, source_commit: $source_commit,
|
||||||
|
sources: ($previous_sources + {($source_repository): $source_commit}),
|
||||||
|
published_at: $published_at, packages: $packages}' \
|
||||||
> "$REPOSITORY_DIR/current/manifest.json"
|
> "$REPOSITORY_DIR/current/manifest.json"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,11 +216,16 @@ upload_current() {
|
|||||||
--only-show-errors
|
--only-show-errors
|
||||||
done
|
done
|
||||||
|
|
||||||
aws_r2 s3 cp \
|
# Orphaned .xbps from an aborted run are re-indexed by the next run.
|
||||||
"$REPOSITORY_DIR/current/x86_64-repodata" \
|
assert_repodata_unchanged "before uploading repodata"
|
||||||
"s3://${R2_BUCKET}/${R2_PREFIX}/current/x86_64-repodata" \
|
|
||||||
|
# Keep the ETag of our own PUT for the final check.
|
||||||
|
aws_r2 s3api put-object \
|
||||||
|
--bucket "$R2_BUCKET" \
|
||||||
|
--key "$REPODATA_KEY" \
|
||||||
|
--body "$REPOSITORY_DIR/current/x86_64-repodata" \
|
||||||
--cache-control 'no-cache' \
|
--cache-control 'no-cache' \
|
||||||
--only-show-errors
|
--query ETag --output text > "$ETAG_STATE"
|
||||||
|
|
||||||
# The manifest is the publication marker and is always uploaded last.
|
# The manifest is the publication marker and is always uploaded last.
|
||||||
aws_r2 s3 cp \
|
aws_r2 s3 cp \
|
||||||
@@ -123,7 +234,8 @@ upload_current() {
|
|||||||
--cache-control 'no-cache' \
|
--cache-control 'no-cache' \
|
||||||
--only-show-errors
|
--only-show-errors
|
||||||
|
|
||||||
# Once the new index and marker are live, remove objects no longer referenced.
|
# Remove retired objects only if the live index is still ours.
|
||||||
|
assert_repodata_unchanged "before removing retired objects"
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
for old in "$REPOSITORY_DIR"/previous-current/*; do
|
for old in "$REPOSITORY_DIR"/previous-current/*; do
|
||||||
filename="$(basename "$old")"
|
filename="$(basename "$old")"
|
||||||
@@ -137,8 +249,9 @@ upload_current() {
|
|||||||
|
|
||||||
publish() {
|
publish() {
|
||||||
require_env SOURCE_COMMIT
|
require_env SOURCE_COMMIT
|
||||||
|
SOURCE_REPOSITORY="${SOURCE_REPOSITORY:-${GITHUB_REPOSITORY:-unknown}}"
|
||||||
PUBLISHED_AT="${PUBLISHED_AT:-$(date -u +'%Y-%m-%dT%H:%M:%SZ')}"
|
PUBLISHED_AT="${PUBLISHED_AT:-$(date -u +'%Y-%m-%dT%H:%M:%SZ')}"
|
||||||
export PUBLISHED_AT
|
export PUBLISHED_AT SOURCE_REPOSITORY
|
||||||
|
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
local packages=("$REPOSITORY_DIR"/current/*.xbps)
|
local packages=("$REPOSITORY_DIR"/current/*.xbps)
|
||||||
@@ -157,6 +270,7 @@ publish() {
|
|||||||
}
|
}
|
||||||
done
|
done
|
||||||
|
|
||||||
|
assert_repodata_unchanged "before publishing"
|
||||||
verify_immutable_packages
|
verify_immutable_packages
|
||||||
build_manifest
|
build_manifest
|
||||||
archive_retired
|
archive_retired
|
||||||
@@ -165,6 +279,8 @@ publish() {
|
|||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
download) download ;;
|
download) download ;;
|
||||||
|
is-indexed) is_indexed ;;
|
||||||
|
index) index ;;
|
||||||
publish) publish ;;
|
publish) publish ;;
|
||||||
*) usage ;;
|
*) usage ;;
|
||||||
esac
|
esac
|
||||||
@@ -71,6 +71,9 @@ jobs:
|
|||||||
run: echo "base=$(gh pr view ${{ github.event.issue.number }} --json baseRefName --jq .baseRefName)" >> "$GITHUB_OUTPUT"
|
run: echo "base=$(gh pr view ${{ github.event.issue.number }} --json baseRefName --jq .baseRefName)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- uses: anthropics/claude-code-action@9ca9355b36297178e28d37c799d1c9c8a28e6507
|
- uses: anthropics/claude-code-action@9ca9355b36297178e28d37c799d1c9c8a28e6507
|
||||||
|
env:
|
||||||
|
CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1"
|
||||||
|
CLAUDE_CODE_EFFORT_LEVEL: high
|
||||||
id: claude
|
id: claude
|
||||||
with:
|
with:
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ jobs:
|
|||||||
- name: Build dms (distropkg)
|
- name: Build dms (distropkg)
|
||||||
run: go build -v -tags 'distro_binary withshell' ./cmd/dms
|
run: go build -v -tags 'distro_binary withshell' ./cmd/dms
|
||||||
|
|
||||||
- name: Build dankinstall
|
- name: Build duxinstall
|
||||||
run: go build -v ./cmd/dankinstall
|
run: go build -v ./cmd/duxinstall
|
||||||
|
|
||||||
build-freebsd:
|
build-freebsd:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -103,5 +103,5 @@ jobs:
|
|||||||
{
|
{
|
||||||
echo "## ${{ steps.derive.outputs.tag }} tagged on ${{ steps.derive.outputs.branch }} — Release workflow dispatched"
|
echo "## ${{ steps.derive.outputs.tag }} tagged on ${{ steps.derive.outputs.branch }} — Release workflow dispatched"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Distro builds are manual: run the per-distro workflows (COPR/OBS/PPA/XBPS) once the release is published."
|
echo "Distro builds (COPR/OBS/PPA/XBPS) are dispatched by the Release workflow once the release is published."
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
name: PR Labels
|
||||||
|
|
||||||
|
# pull_request_target: never check out PR code here, the token has write access
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, edited, reopened, synchronize]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pr-labels-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
label:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const pr = context.payload.pull_request;
|
||||||
|
const body = pr.body || "";
|
||||||
|
|
||||||
|
const checked = [...body.matchAll(/^\s*-\s*\[[xX]\]\s*(.+)$/gm)].map(m => m[1].trim());
|
||||||
|
const typeOfChange = {
|
||||||
|
"Bug fix": "fix",
|
||||||
|
"New feature": "feature",
|
||||||
|
"Breaking change": "breaking",
|
||||||
|
"Refactor / internal cleanup": "internal",
|
||||||
|
"Documentation": "docs",
|
||||||
|
};
|
||||||
|
const labels = new Set(
|
||||||
|
Object.entries(typeOfChange)
|
||||||
|
.filter(([item]) => checked.some(line => line.startsWith(item)))
|
||||||
|
.map(([, label]) => label)
|
||||||
|
);
|
||||||
|
|
||||||
|
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||||
|
...context.repo,
|
||||||
|
pull_number: pr.number,
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
const paths = files.map(f => f.filename);
|
||||||
|
const pathRules = [
|
||||||
|
["packaging", p => p.startsWith("distro/") || p === "flake.nix" || p === "flake.lock"],
|
||||||
|
["i18n", p => p.startsWith("quickshell/translations/poexports/")],
|
||||||
|
["core", p => p.startsWith("core/")],
|
||||||
|
["ui", p => p.startsWith("quickshell/") && !p.startsWith("quickshell/translations/")],
|
||||||
|
];
|
||||||
|
for (const [label, matches] of pathRules) {
|
||||||
|
if (paths.some(matches)) labels.add(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = new Set(pr.labels.map(l => l.name));
|
||||||
|
const missing = [...labels].filter(l => !existing.has(l));
|
||||||
|
if (missing.length === 0) return core.info("nothing to add");
|
||||||
|
|
||||||
|
const areaLabels = {
|
||||||
|
core: ["Go daemon and CLI under core/", "1d76db"],
|
||||||
|
ui: ["Quickshell UI under quickshell/", "c5def5"],
|
||||||
|
};
|
||||||
|
for (const name of missing.filter(l => l in areaLabels)) {
|
||||||
|
const [description, color] = areaLabels[name];
|
||||||
|
try {
|
||||||
|
await github.rest.issues.getLabel({ ...context.repo, name });
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status !== 404) throw error;
|
||||||
|
await github.rest.issues.createLabel({ ...context.repo, name, description, color });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
...context.repo,
|
||||||
|
issue_number: pr.number,
|
||||||
|
labels: missing,
|
||||||
|
});
|
||||||
|
core.info(`added: ${missing.join(", ")}`);
|
||||||
@@ -26,6 +26,11 @@ jobs:
|
|||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: core/go.mod
|
go-version-file: core/go.mod
|
||||||
|
cache-dependency-path: core/go.sum
|
||||||
|
|
||||||
|
- name: Download Go modules
|
||||||
|
working-directory: core
|
||||||
|
run: for attempt in 1 2 3; do go mod download && exit 0; sleep 15; done; exit 1
|
||||||
|
|
||||||
- name: run pre-commit hooks
|
- name: run pre-commit hooks
|
||||||
uses: j178/prek-action@v2
|
uses: j178/prek-action@v2
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ name: Prune Void R2 archive
|
|||||||
# Keeps the danklinux-void bucket inside the Cloudflare R2 free tier by
|
# Keeps the danklinux-void bucket inside the Cloudflare R2 free tier by
|
||||||
# deleting the oldest archive/<repo>/<timestamp>/ snapshots written by
|
# deleting the oldest archive/<repo>/<timestamp>/ snapshots written by
|
||||||
# publish-void-r2.sh. Live repositories (<repo>/current/) are never touched.
|
# publish-void-r2.sh. Live repositories (<repo>/current/) are never touched.
|
||||||
|
# <repo> comes from the listing, so new and legacy prefixes need no list.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ jobs:
|
|||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: ./core/go.mod
|
go-version-file: ./core/go.mod
|
||||||
|
cache-dependency-path: core/go.sum
|
||||||
|
|
||||||
- name: Format check
|
- name: Format check
|
||||||
if: matrix.goos == 'linux'
|
if: matrix.goos == 'linux'
|
||||||
@@ -87,7 +88,7 @@ jobs:
|
|||||||
if: matrix.goos == 'linux'
|
if: matrix.goos == 'linux'
|
||||||
run: go test -v ./...
|
run: go test -v ./...
|
||||||
|
|
||||||
- name: Build dankinstall (${{ env.ASSET }})
|
- name: Build duxinstall (${{ env.ASSET }})
|
||||||
if: matrix.goos == 'linux'
|
if: matrix.goos == 'linux'
|
||||||
env:
|
env:
|
||||||
GOOS: ${{ matrix.goos }}
|
GOOS: ${{ matrix.goos }}
|
||||||
@@ -95,12 +96,12 @@ jobs:
|
|||||||
GOARCH: ${{ matrix.arch }}
|
GOARCH: ${{ matrix.arch }}
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
cd cmd/dankinstall
|
cd cmd/duxinstall
|
||||||
go build -trimpath -ldflags "-s -w -X main.Version=${TAG}" \
|
go build -trimpath -ldflags "-s -w -X main.Version=${TAG}" \
|
||||||
-o ../../dankinstall-${{ env.ASSET }}
|
-o ../../duxinstall-${{ env.ASSET }}
|
||||||
cd ../..
|
cd ../..
|
||||||
gzip -9 -k dankinstall-${{ env.ASSET }}
|
gzip -9 -k duxinstall-${{ env.ASSET }}
|
||||||
sha256sum dankinstall-${{ env.ASSET }}.gz > dankinstall-${{ env.ASSET }}.gz.sha256
|
sha256sum duxinstall-${{ env.ASSET }}.gz > duxinstall-${{ env.ASSET }}.gz.sha256
|
||||||
|
|
||||||
- name: Sync embedded shell
|
- name: Sync embedded shell
|
||||||
run: make sync-shell
|
run: make sync-shell
|
||||||
@@ -148,8 +149,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: core-assets-${{ env.ASSET }}
|
name: core-assets-${{ env.ASSET }}
|
||||||
path: |
|
path: |
|
||||||
core/dankinstall-${{ env.ASSET }}.gz
|
core/duxinstall-${{ env.ASSET }}.gz
|
||||||
core/dankinstall-${{ env.ASSET }}.gz.sha256
|
core/duxinstall-${{ env.ASSET }}.gz.sha256
|
||||||
core/dms-${{ env.ASSET }}.gz
|
core/dms-${{ env.ASSET }}.gz
|
||||||
core/dms-${{ env.ASSET }}.gz.sha256
|
core/dms-${{ env.ASSET }}.gz.sha256
|
||||||
core/dms-distropkg-${{ env.ASSET }}.gz
|
core/dms-distropkg-${{ env.ASSET }}.gz
|
||||||
@@ -174,8 +175,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: core-assets-${{ env.ASSET }}
|
name: core-assets-${{ env.ASSET }}
|
||||||
path: |
|
path: |
|
||||||
core/dankinstall-${{ env.ASSET }}.gz
|
core/duxinstall-${{ env.ASSET }}.gz
|
||||||
core/dankinstall-${{ env.ASSET }}.gz.sha256
|
core/duxinstall-${{ env.ASSET }}.gz.sha256
|
||||||
core/dms-${{ env.ASSET }}.gz
|
core/dms-${{ env.ASSET }}.gz
|
||||||
core/dms-${{ env.ASSET }}.gz.sha256
|
core/dms-${{ env.ASSET }}.gz.sha256
|
||||||
core/dms-distropkg-${{ env.ASSET }}.gz
|
core/dms-distropkg-${{ env.ASSET }}.gz
|
||||||
@@ -247,6 +248,7 @@ jobs:
|
|||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: ./core/go.mod
|
go-version-file: ./core/go.mod
|
||||||
|
cache-dependency-path: core/go.sum
|
||||||
|
|
||||||
- name: Download core artifacts
|
- name: Download core artifacts
|
||||||
uses: actions/download-artifact@v5
|
uses: actions/download-artifact@v5
|
||||||
@@ -300,8 +302,8 @@ jobs:
|
|||||||
- **`dms-distropkg-arm64.gz`** - DMS CLI binary built with distro_package tag for ARM64 systems
|
- **`dms-distropkg-arm64.gz`** - DMS CLI binary built with distro_package tag for ARM64 systems
|
||||||
- **`dms-distropkg-freebsd-amd64.gz`** - DMS CLI binary built with distro_package tag for FreeBSD x86_64 systems
|
- **`dms-distropkg-freebsd-amd64.gz`** - DMS CLI binary built with distro_package tag for FreeBSD x86_64 systems
|
||||||
- **`dms-distropkg-freebsd-arm64.gz`** - DMS CLI binary built with distro_package tag for FreeBSD ARM64 systems
|
- **`dms-distropkg-freebsd-arm64.gz`** - DMS CLI binary built with distro_package tag for FreeBSD ARM64 systems
|
||||||
- **`dankinstall-amd64.gz`** - Installer binary for x86_64 systems
|
- **`duxinstall-amd64.gz`** - Installer binary for x86_64 systems
|
||||||
- **`dankinstall-arm64.gz`** - Installer binary for ARM64 systems
|
- **`duxinstall-arm64.gz`** - Installer binary for ARM64 systems
|
||||||
- **`dms-cli-<version>.tar.gz`** - Go source code with vendored modules (for distro packaging)
|
- **`dms-cli-<version>.tar.gz`** - Go source code with vendored modules (for distro packaging)
|
||||||
- **`dms-qml.tar.gz`** - QML source code only
|
- **`dms-qml.tar.gz`** - QML source code only
|
||||||
- **`dms-source.tar.gz`** - Full repository source with bundled DCommon (for distro packaging)
|
- **`dms-source.tar.gz`** - Full repository source with bundled DCommon (for distro packaging)
|
||||||
@@ -346,8 +348,8 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Copy dankinstall binaries
|
# Copy duxinstall binaries
|
||||||
cp _core_assets/dankinstall-*.gz* _release_assets/
|
cp _core_assets/duxinstall-*.gz* _release_assets/
|
||||||
|
|
||||||
# Copy completions
|
# Copy completions
|
||||||
cp _core_assets/completion.* _release_assets/ 2>/dev/null || true
|
cp _core_assets/completion.* _release_assets/ 2>/dev/null || true
|
||||||
@@ -557,3 +559,38 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
gh workflow run pin-release.yml --repo "$REPO" -f version="$TAG"
|
gh workflow run pin-release.yml --repo "$REPO" -f version="$TAG"
|
||||||
echo "dispatched pin-release ${TAG} to ${REPO}" >> "$GITHUB_STEP_SUMMARY"
|
echo "dispatched pin-release ${TAG} to ${REPO}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
# Build the stable dms package on every distro channel. Runs after the
|
||||||
|
# release job so every asset the packaging scripts download already exists.
|
||||||
|
distros:
|
||||||
|
name: Dispatch ${{ matrix.workflow }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [release]
|
||||||
|
if: ${{ !contains(inputs.tag, '-') }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- workflow: run-copr.yml
|
||||||
|
args: -f package=dms -f version=__VERSION__ -f release=1
|
||||||
|
- workflow: run-obs.yml
|
||||||
|
args: -f package=dms
|
||||||
|
- workflow: run-ppa.yml
|
||||||
|
args: -f package=dms
|
||||||
|
- workflow: run-xbps.yml
|
||||||
|
args: -f version=__TAG__ -f build_dms=true -f build_git=false
|
||||||
|
steps:
|
||||||
|
- name: Dispatch packaging workflow
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
WORKFLOW: ${{ matrix.workflow }}
|
||||||
|
ARGS: ${{ matrix.args }}
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
ARGS="${ARGS//__VERSION__/$VERSION}"
|
||||||
|
ARGS="${ARGS//__TAG__/$TAG}"
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
gh workflow run "$WORKFLOW" --repo "${{ github.repository }}" $ARGS
|
||||||
|
echo "dispatched ${WORKFLOW} for ${TAG} (${ARGS})" >> "$GITHUB_STEP_SUMMARY"
|
||||||
+77
-121
@@ -18,185 +18,141 @@ on:
|
|||||||
description: 'Release number (e.g., 1, 2, 3 for hotfixes)'
|
description: 'Release number (e.g., 1, 2, 3 for hotfixes)'
|
||||||
required: false
|
required: false
|
||||||
default: '1'
|
default: '1'
|
||||||
|
owners:
|
||||||
|
description: 'Copr owners to publish to'
|
||||||
|
required: false
|
||||||
|
default: 'all'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- all
|
||||||
|
- duxlabs
|
||||||
|
- avengemedia
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
determine-packages:
|
plan:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
packages: ${{ steps.set-packages.outputs.packages }}
|
matrix: ${{ steps.plan.outputs.matrix }}
|
||||||
|
version: ${{ steps.plan.outputs.version }}
|
||||||
|
release: ${{ steps.plan.outputs.release }}
|
||||||
steps:
|
steps:
|
||||||
- name: Set package list
|
- name: Plan owners and version
|
||||||
id: set-packages
|
id: plan
|
||||||
|
env:
|
||||||
|
OWNERS: ${{ inputs.owners || 'all' }}
|
||||||
|
PACKAGE: ${{ inputs.package || 'dms' }}
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
RELEASE: ${{ inputs.release || '1' }}
|
||||||
run: |
|
run: |
|
||||||
PACKAGE_INPUT="${{ github.event.inputs.package || 'dms' }}"
|
MATRIX=$(jq -cn --arg owners "$OWNERS" --arg pkg "$PACKAGE" '
|
||||||
echo "packages=[\"$PACKAGE_INPUT\"]" >> $GITHUB_OUTPUT
|
[{owner: "duxlabs", suffix: ""}, {owner: "avengemedia", suffix: "_LEGACY"}]
|
||||||
|
| map(select($owners == "all" or .owner == $owners) | .package = $pkg)
|
||||||
|
| {include: .}')
|
||||||
|
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
VERSION=$(curl -sL "https://api.github.com/repos/${{ github.repository }}/releases/latest" | jq -r '.tag_name' | sed 's/^v//')
|
||||||
|
fi
|
||||||
|
if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then
|
||||||
|
echo "::error::Could not determine the latest release of ${{ github.repository }}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release=$RELEASE" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Building $PACKAGE $VERSION-$RELEASE for: $(jq -r '[.include[].owner] | join(", ")' <<< "$MATRIX")"
|
||||||
|
|
||||||
build-and-upload:
|
build-and-upload:
|
||||||
needs: determine-packages
|
name: ${{ matrix.package }} to ${{ matrix.owner }}
|
||||||
|
needs: plan
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
fail-fast: false
|
||||||
package: ${{ fromJSON(needs.determine-packages.outputs.packages) }}
|
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.plan.outputs.version }}
|
||||||
|
RELEASE: ${{ needs.plan.outputs.release }}
|
||||||
|
PACKAGE: ${{ matrix.package }}
|
||||||
|
COPR_PROJECT: ${{ matrix.owner }}/dms
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Determine version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
# Get version from manual input or latest release
|
|
||||||
if [ -n "${{ github.event.inputs.version }}" ]; then
|
|
||||||
VERSION="${{ github.event.inputs.version }}"
|
|
||||||
echo "Using manual version: $VERSION"
|
|
||||||
else
|
|
||||||
VERSION=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r '.tag_name' | sed 's/^v//')
|
|
||||||
echo "Using latest release version: $VERSION"
|
|
||||||
fi
|
|
||||||
|
|
||||||
RELEASE="${{ github.event.inputs.release }}"
|
|
||||||
if [ -z "$RELEASE" ]; then
|
|
||||||
RELEASE="1"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
echo "release=$RELEASE" >> $GITHUB_OUTPUT
|
|
||||||
echo "✅ Building ${{ matrix.package }} version: $VERSION-$RELEASE"
|
|
||||||
|
|
||||||
- name: Setup build environment
|
- name: Setup build environment
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y rpm wget curl jq gzip
|
sudo apt-get install -y rpm wget curl jq gzip python3-pip
|
||||||
mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
|
mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
|
||||||
echo "✅ RPM build environment ready"
|
|
||||||
|
|
||||||
- name: Download release assets
|
- name: Download release assets
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
|
||||||
cd ~/rpmbuild/SOURCES
|
cd ~/rpmbuild/SOURCES
|
||||||
|
wget "https://github.com/${{ github.repository }}/releases/download/v${VERSION}/dms-qml.tar.gz" || {
|
||||||
echo "📦 Downloading DMS QML source for v${VERSION}..."
|
echo "::error::Failed to download dms-qml.tar.gz for v${VERSION}"
|
||||||
|
|
||||||
# Download DMS QML source
|
|
||||||
wget "https://github.com/duxlabs-dev/dux-shell/releases/download/v${VERSION}/dms-qml.tar.gz" || {
|
|
||||||
echo "❌ Failed to download dms-qml.tar.gz for v${VERSION}"
|
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "✅ Source downloaded"
|
|
||||||
echo "Note: dms-cli binary will be downloaded during build based on target architecture"
|
|
||||||
ls -lh
|
ls -lh
|
||||||
|
|
||||||
- name: Generate stable spec file
|
- name: Generate stable spec file
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
SPEC=~/rpmbuild/SPECS/${PACKAGE}.spec
|
||||||
RELEASE="${{ steps.version.outputs.release }}"
|
cp "distro/fedora/${PACKAGE}.spec" "$SPEC"
|
||||||
CHANGELOG_DATE="$(date '+%a %b %d %Y')"
|
sed -i -e "s/VERSION_PLACEHOLDER/${VERSION}/g" \
|
||||||
PACKAGE="${{ matrix.package }}"
|
-e "s/RELEASE_PLACEHOLDER/${RELEASE}/g" \
|
||||||
|
-e "s/CHANGELOG_DATE_PLACEHOLDER/$(date '+%a %b %d %Y')/g" "$SPEC"
|
||||||
# Copy spec file from repository
|
head -40 "$SPEC"
|
||||||
cp distro/fedora/${PACKAGE}.spec ~/rpmbuild/SPECS/${PACKAGE}.spec
|
|
||||||
|
|
||||||
# Replace placeholders with actual values
|
|
||||||
sed -i "s/VERSION_PLACEHOLDER/${VERSION}/g" ~/rpmbuild/SPECS/${PACKAGE}.spec
|
|
||||||
sed -i "s/RELEASE_PLACEHOLDER/${RELEASE}/g" ~/rpmbuild/SPECS/${PACKAGE}.spec
|
|
||||||
sed -i "s/CHANGELOG_DATE_PLACEHOLDER/${CHANGELOG_DATE}/g" ~/rpmbuild/SPECS/${PACKAGE}.spec
|
|
||||||
|
|
||||||
echo "✅ Spec file generated for ${PACKAGE} v${VERSION}-${RELEASE}"
|
|
||||||
echo ""
|
|
||||||
echo "=== Spec file preview ==="
|
|
||||||
head -40 ~/rpmbuild/SPECS/${PACKAGE}.spec
|
|
||||||
|
|
||||||
- name: Build SRPM
|
- name: Build SRPM
|
||||||
id: build
|
id: build
|
||||||
run: |
|
run: |
|
||||||
cd ~/rpmbuild/SPECS
|
rpmbuild -bs ~/rpmbuild/SPECS/${PACKAGE}.spec
|
||||||
PACKAGE="${{ matrix.package }}"
|
|
||||||
|
|
||||||
echo "🔨 Building SRPM for ${PACKAGE}..."
|
|
||||||
rpmbuild -bs ${PACKAGE}.spec
|
|
||||||
|
|
||||||
SRPM=$(ls ~/rpmbuild/SRPMS/${PACKAGE}-*.src.rpm | tail -n 1)
|
SRPM=$(ls ~/rpmbuild/SRPMS/${PACKAGE}-*.src.rpm | tail -n 1)
|
||||||
SRPM_NAME=$(basename "$SRPM")
|
echo "srpm_path=$SRPM" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "srpm_name=$(basename "$SRPM")" >> "$GITHUB_OUTPUT"
|
||||||
echo "srpm_path=$SRPM" >> $GITHUB_OUTPUT
|
|
||||||
echo "srpm_name=$SRPM_NAME" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
echo "✅ SRPM built: $SRPM_NAME"
|
|
||||||
echo ""
|
|
||||||
echo "=== SRPM Info ==="
|
|
||||||
rpm -qpi "$SRPM"
|
rpm -qpi "$SRPM"
|
||||||
|
|
||||||
|
# Per-owner name: two legs uploading the same artifact name get a 409.
|
||||||
- name: Upload SRPM artifact
|
- name: Upload SRPM artifact
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.package }}-stable-srpm-${{ steps.version.outputs.version }}
|
name: ${{ matrix.package }}-stable-srpm-${{ needs.plan.outputs.version }}-${{ matrix.owner }}
|
||||||
path: ${{ steps.build.outputs.srpm_path }}
|
path: ${{ steps.build.outputs.srpm_path }}
|
||||||
retention-days: 90
|
retention-days: 90
|
||||||
|
|
||||||
- name: Install Copr CLI
|
- name: Install Copr CLI
|
||||||
|
env:
|
||||||
|
COPR_LOGIN: ${{ secrets[format('COPR_LOGIN{0}', matrix.suffix)] }}
|
||||||
|
COPR_TOKEN: ${{ secrets[format('COPR_TOKEN{0}', matrix.suffix)] }}
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get install -y python3-pip
|
: "${COPR_LOGIN:?COPR_LOGIN${{ matrix.suffix }} is not set}" "${COPR_TOKEN:?COPR_TOKEN${{ matrix.suffix }} is not set}"
|
||||||
pip3 install copr-cli
|
pip3 install copr-cli
|
||||||
|
|
||||||
mkdir -p ~/.config
|
mkdir -p ~/.config
|
||||||
cat > ~/.config/copr << EOF
|
umask 077
|
||||||
[copr-cli]
|
printf '[copr-cli]\nlogin = %s\nusername = %s\ntoken = %s\ncopr_url = https://copr.fedorainfracloud.org\n' \
|
||||||
login = ${{ secrets.COPR_LOGIN }}
|
"$COPR_LOGIN" "${{ matrix.owner }}" "$COPR_TOKEN" > ~/.config/copr
|
||||||
username = avengemedia
|
|
||||||
token = ${{ secrets.COPR_TOKEN }}
|
|
||||||
copr_url = https://copr.fedorainfracloud.org
|
|
||||||
EOF
|
|
||||||
chmod 600 ~/.config/copr
|
|
||||||
|
|
||||||
echo "✅ Copr CLI configured"
|
|
||||||
|
|
||||||
- name: Determine Copr project
|
|
||||||
id: copr_project
|
|
||||||
run: |
|
|
||||||
PACKAGE="${{ matrix.package }}"
|
|
||||||
if [ "$PACKAGE" = "dms" ]; then
|
|
||||||
COPR_PROJECT="avengemedia/dms"
|
|
||||||
else
|
|
||||||
echo "❌ Unknown package: $PACKAGE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "copr_project=$COPR_PROJECT" >> $GITHUB_OUTPUT
|
|
||||||
echo "✅ Copr project: $COPR_PROJECT"
|
|
||||||
|
|
||||||
- name: Upload to Copr
|
- name: Upload to Copr
|
||||||
run: |
|
run: |
|
||||||
SRPM="${{ steps.build.outputs.srpm_path }}"
|
SRPM="${{ steps.build.outputs.srpm_path }}"
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
echo "Uploading $(basename "$SRPM") to ${COPR_PROJECT}"
|
||||||
COPR_PROJECT="${{ steps.copr_project.outputs.copr_project }}"
|
|
||||||
PACKAGE="${{ matrix.package }}"
|
|
||||||
|
|
||||||
echo "🚀 Uploading ${PACKAGE} SRPM to ${COPR_PROJECT}..."
|
|
||||||
echo " SRPM: $(basename $SRPM)"
|
|
||||||
echo " Version: $VERSION"
|
|
||||||
|
|
||||||
BUILD_OUTPUT=$(copr-cli build "$COPR_PROJECT" "$SRPM" --nowait 2>&1)
|
BUILD_OUTPUT=$(copr-cli build "$COPR_PROJECT" "$SRPM" --nowait 2>&1)
|
||||||
echo "$BUILD_OUTPUT"
|
echo "$BUILD_OUTPUT"
|
||||||
|
|
||||||
BUILD_ID=$(echo "$BUILD_OUTPUT" | grep -oP 'Build was added to.*\K[0-9]+' || echo "unknown")
|
BUILD_ID=$(echo "$BUILD_OUTPUT" | grep -oP 'Build was added to.*\K[0-9]+' || echo "unknown")
|
||||||
|
|
||||||
if [ "$BUILD_ID" != "unknown" ]; then
|
if [ "$BUILD_ID" != "unknown" ]; then
|
||||||
echo "✅ Build submitted successfully!"
|
echo "https://copr.fedorainfracloud.org/coprs/${COPR_PROJECT}/build/$BUILD_ID/"
|
||||||
echo "🔗 https://copr.fedorainfracloud.org/coprs/${COPR_PROJECT}/build/$BUILD_ID/"
|
|
||||||
else
|
else
|
||||||
echo "⚠️ Could not extract build ID, but upload may have succeeded"
|
echo "::warning::Could not extract build ID, but upload may have succeeded"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build summary
|
- name: Build summary
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
PACKAGE="${{ matrix.package }}"
|
{
|
||||||
COPR_PROJECT="${{ steps.copr_project.outputs.copr_project }}"
|
echo "### ${PACKAGE} stable to ${{ matrix.owner }}"
|
||||||
echo "### 🎉 ${PACKAGE} Stable Build Summary" >> $GITHUB_STEP_SUMMARY
|
echo ""
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "- **Version:** ${VERSION}-${RELEASE}"
|
||||||
echo "- **Package:** ${PACKAGE}" >> $GITHUB_STEP_SUMMARY
|
echo "- **SRPM:** ${{ steps.build.outputs.srpm_name }}"
|
||||||
echo "- **Version:** ${{ steps.version.outputs.version }}-${{ steps.version.outputs.release }}" >> $GITHUB_STEP_SUMMARY
|
echo "- **Project:** https://copr.fedorainfracloud.org/coprs/${COPR_PROJECT}/"
|
||||||
echo "- **SRPM:** ${{ steps.build.outputs.srpm_name }}" >> $GITHUB_STEP_SUMMARY
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "- **Project:** https://copr.fedorainfracloud.org/coprs/${COPR_PROJECT}/" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "Stable release has been built and uploaded to Copr!" >> $GITHUB_STEP_SUMMARY
|
|
||||||
@@ -16,6 +16,15 @@ on:
|
|||||||
description: "Release number for rebuilds (e.g., 2, 3, 4 to increment spec Release)"
|
description: "Release number for rebuilds (e.g., 2, 3, 4 to increment spec Release)"
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
owners:
|
||||||
|
description: "OBS owners to publish to"
|
||||||
|
required: false
|
||||||
|
default: "all"
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- all
|
||||||
|
- duxlabs
|
||||||
|
- avengemedia
|
||||||
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)
|
||||||
|
|
||||||
@@ -27,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
has_updates: ${{ steps.check.outputs.has_updates }}
|
has_updates: ${{ steps.check.outputs.has_updates }}
|
||||||
packages: ${{ steps.check.outputs.packages }}
|
matrix: ${{ steps.check.outputs.matrix }}
|
||||||
version: ${{ steps.check.outputs.version }}
|
version: ${{ steps.check.outputs.version }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -40,13 +49,13 @@ jobs:
|
|||||||
- name: Check for updates
|
- name: Check for updates
|
||||||
id: check
|
id: check
|
||||||
env:
|
env:
|
||||||
OBS_USERNAME: ${{ secrets.OBS_USERNAME }}
|
# duxlabs is dispatch-only until its OBS account exists; drop this gate then
|
||||||
OBS_PASSWORD: ${{ secrets.OBS_PASSWORD }}
|
OWNERS: ${{ github.event_name == 'workflow_dispatch' && inputs.owners || 'avengemedia' }}
|
||||||
run: |
|
run: |
|
||||||
# Helper function to check dms-git commit
|
# The public source API needs no credentials, so one job checks every owner
|
||||||
check_dms_git() {
|
check_dms_git() {
|
||||||
local CURRENT_COMMIT=$(git rev-parse --short=8 HEAD)
|
local CURRENT_COMMIT=$(git rev-parse --short=8 HEAD)
|
||||||
local OBS_SPEC=$(curl -s -u "$OBS_USERNAME:$OBS_PASSWORD" "https://api.opensuse.org/source/home:AvengeMedia:dms-git/dms-git/dms-git.spec" 2>/dev/null || echo "")
|
local OBS_SPEC=$(curl -s "https://api.opensuse.org/public/source/$1:dms-git/dms-git/dms-git.spec" 2>/dev/null || echo "")
|
||||||
local OBS_COMMIT=$(echo "$OBS_SPEC" | grep "^Version:" | grep -oP '\.[a-f0-9]{8}' | tr -d '.' || echo "")
|
local OBS_COMMIT=$(echo "$OBS_SPEC" | grep "^Version:" | grep -oP '\.[a-f0-9]{8}' | tr -d '.' || echo "")
|
||||||
|
|
||||||
if [[ -n "$OBS_COMMIT" && "$CURRENT_COMMIT" == "$OBS_COMMIT" ]]; then
|
if [[ -n "$OBS_COMMIT" && "$CURRENT_COMMIT" == "$OBS_COMMIT" ]]; then
|
||||||
@@ -58,14 +67,13 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Helper function to check dms stable tag
|
|
||||||
# Sets LATEST_TAG variable in parent scope if update needed
|
# Sets LATEST_TAG variable in parent scope if update needed
|
||||||
check_dms_stable() {
|
check_dms_stable() {
|
||||||
LATEST_TAG=$(curl -s https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
|
LATEST_TAG=$(curl -sL https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
|
||||||
local OBS_SPEC=$(curl -s -u "$OBS_USERNAME:$OBS_PASSWORD" "https://api.opensuse.org/source/home:AvengeMedia:dms/dms/dms.spec" 2>/dev/null || echo "")
|
local OBS_SPEC=$(curl -s "https://api.opensuse.org/public/source/$1:dms/dms/dms.spec" 2>/dev/null || echo "")
|
||||||
local OBS_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs || echo "")
|
local OBS_VERSION=$(echo "$OBS_SPEC" | grep "^Version:" | awk '{print $2}' | xargs || echo "")
|
||||||
|
|
||||||
if [[ -n "$LATEST_TAG" && "$LATEST_TAG" == "$OBS_VERSION" ]]; then
|
if [[ -n "$LATEST_TAG" && "${LATEST_TAG#v}" == "$OBS_VERSION" ]]; then
|
||||||
echo "📋 dms: Tag $LATEST_TAG already exists, skipping"
|
echo "📋 dms: Tag $LATEST_TAG already exists, skipping"
|
||||||
return 1 # No update needed
|
return 1 # No update needed
|
||||||
else
|
else
|
||||||
@@ -74,94 +82,85 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Main logic
|
|
||||||
REBUILD="${{ github.event.inputs.rebuild_release }}"
|
REBUILD="${{ github.event.inputs.rebuild_release }}"
|
||||||
|
PKG="${{ github.event.inputs.package }}"
|
||||||
|
VERSION=""
|
||||||
|
|
||||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]] && [[ -z "${{ github.event.inputs.package }}" ]]; then
|
# Returns via OWNER_PACKAGES, and VERSION when a stable tag is due
|
||||||
# Run from tag with no package specified - update stable package
|
plan_owner() {
|
||||||
echo "packages=dms" >> $GITHUB_OUTPUT
|
OWNER_PACKAGES=""
|
||||||
|
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]] && [[ -z "$PKG" ]]; then
|
||||||
|
OWNER_PACKAGES="dms"
|
||||||
VERSION="${GITHUB_REF#refs/tags/}"
|
VERSION="${GITHUB_REF#refs/tags/}"
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "Triggered by tag: $VERSION (always update)"
|
echo "Triggered by tag: $VERSION (always update)"
|
||||||
|
|
||||||
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
|
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||||
# Scheduled run - check dms-git only
|
if check_dms_git "$1"; then
|
||||||
echo "packages=dms-git" >> $GITHUB_OUTPUT
|
OWNER_PACKAGES="dms-git"
|
||||||
if check_dms_git; then
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
elif [[ -n "$PKG" ]]; then
|
||||||
# Manual workflow trigger
|
|
||||||
PKG="${{ github.event.inputs.package }}"
|
|
||||||
|
|
||||||
if [[ -n "$REBUILD" ]]; then
|
if [[ -n "$REBUILD" ]]; then
|
||||||
# Rebuild requested - always proceed
|
OWNER_PACKAGES="$PKG"
|
||||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "🔄 Manual rebuild requested: $PKG (db$REBUILD)"
|
echo "🔄 Manual rebuild requested: $PKG (db$REBUILD)"
|
||||||
|
|
||||||
elif [[ "$PKG" == "all" ]]; then
|
elif [[ "$PKG" == "all" || "$PKG" == "dms" ]]; then
|
||||||
# Check the stable package and build list of those needing updates
|
# "all" only checks the stable package
|
||||||
PACKAGES_TO_UPDATE=()
|
if check_dms_stable "$1"; then
|
||||||
if check_dms_stable; then
|
OWNER_PACKAGES="dms"
|
||||||
PACKAGES_TO_UPDATE+=("dms")
|
|
||||||
if [[ -n "$LATEST_TAG" ]]; then
|
if [[ -n "$LATEST_TAG" ]]; then
|
||||||
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
VERSION="$LATEST_TAG"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ${#PACKAGES_TO_UPDATE[@]} -gt 0 ]]; then
|
|
||||||
echo "packages=${PACKAGES_TO_UPDATE[*]}" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "✓ Packages to update: ${PACKAGES_TO_UPDATE[*]}"
|
|
||||||
else
|
|
||||||
echo "packages=" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
|
||||||
echo "✓ All packages up to date"
|
|
||||||
fi
|
|
||||||
|
|
||||||
elif [[ "$PKG" == "dms-git" ]]; then
|
elif [[ "$PKG" == "dms-git" ]]; then
|
||||||
if check_dms_git; then
|
if check_dms_git "$1"; then
|
||||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
OWNER_PACKAGES="$PKG"
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "packages=" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
elif [[ "$PKG" == "dms" ]]; then
|
|
||||||
if check_dms_stable; then
|
|
||||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
if [[ -n "$LATEST_TAG" ]]; then
|
|
||||||
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "packages=" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
else
|
else
|
||||||
# Unknown package - proceed anyway
|
OWNER_PACKAGES="$PKG"
|
||||||
echo "packages=$PKG" >> $GITHUB_OUTPUT
|
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "Manual trigger: $PKG"
|
echo "Manual trigger: $PKG"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# Fallback - proceed
|
OWNER_PACKAGES="all"
|
||||||
echo "packages=all" >> $GITHUB_OUTPUT
|
fi
|
||||||
echo "has_updates=true" >> $GITHUB_OUTPUT
|
}
|
||||||
|
|
||||||
|
# Planned per owner: the new projects may lag the legacy ones
|
||||||
|
INCLUDE='[]'
|
||||||
|
for T in duxlabs,,home:duxlabs avengemedia,_LEGACY,home:AvengeMedia; do
|
||||||
|
IFS=, read -r OWNER SUFFIX PROJECT <<< "$T"
|
||||||
|
[[ "$OWNERS" == all || "$OWNERS" == "$OWNER" ]] || continue
|
||||||
|
echo "==> $OWNER ($PROJECT)"
|
||||||
|
plan_owner "$PROJECT"
|
||||||
|
if [[ -z "$OWNER_PACKAGES" ]]; then
|
||||||
|
echo "✓ $OWNER: all packages up to date"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo "✓ $OWNER: packages to update: $OWNER_PACKAGES"
|
||||||
|
INCLUDE=$(jq -c --arg o "$OWNER" --arg s "$SUFFIX" --arg p "$PROJECT" --arg k "$OWNER_PACKAGES" \
|
||||||
|
'. + [{owner: $o, suffix: $s, project: $p, packages: $k}]' <<< "$INCLUDE")
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "matrix=$(jq -c '{include: .}' <<< "$INCLUDE")" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
if [[ "$INCLUDE" != "[]" ]]; then
|
||||||
|
echo "has_updates=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "has_updates=false" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
update-obs:
|
update-obs:
|
||||||
name: Upload to OBS
|
name: Upload to OBS (${{ matrix.owner }})
|
||||||
needs: check-updates
|
needs: check-updates
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: needs.check-updates.outputs.has_updates == 'true'
|
if: needs.check-updates.outputs.has_updates == 'true'
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix: ${{ fromJSON(needs.check-updates.outputs.matrix) }}
|
||||||
|
env:
|
||||||
|
OBS_BASE_PROJECT: ${{ matrix.project }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -179,12 +178,12 @@ jobs:
|
|||||||
# Use check-updates outputs when available
|
# Use check-updates outputs when available
|
||||||
if [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then
|
if [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then
|
||||||
# Use version from check-updates job
|
# Use version from check-updates job
|
||||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
echo "packages=${{ matrix.packages }}" >> $GITHUB_OUTPUT
|
||||||
echo "version=${{ needs.check-updates.outputs.version }}" >> $GITHUB_OUTPUT
|
echo "version=${{ needs.check-updates.outputs.version }}" >> $GITHUB_OUTPUT
|
||||||
echo "Using version from check-updates: ${{ needs.check-updates.outputs.version }}"
|
echo "Using version from check-updates: ${{ needs.check-updates.outputs.version }}"
|
||||||
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
|
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||||
# Scheduled run - dms-git only
|
# Scheduled run - dms-git only
|
||||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
echo "packages=${{ matrix.packages }}" >> $GITHUB_OUTPUT
|
||||||
echo "Triggered by schedule: updating git package"
|
echo "Triggered by schedule: updating git package"
|
||||||
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
elif [[ -n "${{ github.event.inputs.package }}" ]]; then
|
||||||
# Manual workflow dispatch
|
# Manual workflow dispatch
|
||||||
@@ -192,7 +191,7 @@ jobs:
|
|||||||
# Determine version for dms stable using the API
|
# Determine version for dms stable using the API
|
||||||
# GITHUB_REF is unreliable when "Use workflow from" a tag; API works from any ref
|
# GITHUB_REF is unreliable when "Use workflow from" a tag; API works from any ref
|
||||||
if [[ "${{ github.event.inputs.package }}" == "dms" ]] || [[ "${{ github.event.inputs.package }}" == "all" ]]; then
|
if [[ "${{ github.event.inputs.package }}" == "dms" ]] || [[ "${{ github.event.inputs.package }}" == "all" ]]; then
|
||||||
LATEST_TAG=$(curl -s https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
|
LATEST_TAG=$(curl -sL https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name"' | sed 's/.*"tag_name": "\([^"]*\)".*/\1/' || echo "")
|
||||||
if [[ -n "$LATEST_TAG" ]]; then
|
if [[ -n "$LATEST_TAG" ]]; then
|
||||||
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
echo "version=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||||
echo "Using latest release from API: $LATEST_TAG"
|
echo "Using latest release from API: $LATEST_TAG"
|
||||||
@@ -204,14 +203,14 @@ jobs:
|
|||||||
|
|
||||||
# Use filtered packages from check-updates when package="all" and no rebuild/tag specified
|
# Use filtered packages from check-updates when package="all" and no rebuild/tag specified
|
||||||
if [[ "${{ github.event.inputs.package }}" == "all" ]] && [[ -z "${{ github.event.inputs.rebuild_release }}" ]] && [[ ! "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
if [[ "${{ github.event.inputs.package }}" == "all" ]] && [[ -z "${{ github.event.inputs.rebuild_release }}" ]] && [[ ! "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
echo "packages=${{ matrix.packages }}" >> $GITHUB_OUTPUT
|
||||||
echo "Manual trigger: all (filtered to: ${{ needs.check-updates.outputs.packages }})"
|
echo "Manual trigger: all (filtered to: ${{ matrix.packages }})"
|
||||||
else
|
else
|
||||||
echo "packages=${{ github.event.inputs.package }}" >> $GITHUB_OUTPUT
|
echo "packages=${{ github.event.inputs.package }}" >> $GITHUB_OUTPUT
|
||||||
echo "Manual trigger: ${{ github.event.inputs.package }}"
|
echo "Manual trigger: ${{ github.event.inputs.package }}"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "packages=${{ needs.check-updates.outputs.packages }}" >> $GITHUB_OUTPUT
|
echo "packages=${{ matrix.packages }}" >> $GITHUB_OUTPUT
|
||||||
if [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then
|
if [[ -n "${{ needs.check-updates.outputs.version }}" ]]; then
|
||||||
echo "version=${{ needs.check-updates.outputs.version }}" >> $GITHUB_OUTPUT
|
echo "version=${{ needs.check-updates.outputs.version }}" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
@@ -308,22 +307,21 @@ jobs:
|
|||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: ./core/go.mod
|
go-version-file: ./core/go.mod
|
||||||
|
cache-dependency-path: core/go.sum
|
||||||
|
|
||||||
- name: Install OSC
|
- name: Install OSC
|
||||||
|
env:
|
||||||
|
OBS_USERNAME: ${{ secrets[format('OBS_USERNAME{0}', matrix.suffix)] }}
|
||||||
|
OBS_PASSWORD: ${{ secrets[format('OBS_PASSWORD{0}', matrix.suffix)] }}
|
||||||
run: |
|
run: |
|
||||||
|
: "${OBS_USERNAME:?OBS_USERNAME${{ matrix.suffix }} is not set}" "${OBS_PASSWORD:?OBS_PASSWORD${{ matrix.suffix }} is not set}"
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y osc
|
sudo apt-get install -y osc
|
||||||
|
|
||||||
mkdir -p ~/.config/osc
|
mkdir -p ~/.config/osc
|
||||||
cat > ~/.config/osc/oscrc << EOF
|
umask 077
|
||||||
[general]
|
printf '[general]\napiurl = https://api.opensuse.org\n\n[https://api.opensuse.org]\nuser = %s\npass = %s\n' \
|
||||||
apiurl = https://api.opensuse.org
|
"$OBS_USERNAME" "$OBS_PASSWORD" > ~/.config/osc/oscrc
|
||||||
|
|
||||||
[https://api.opensuse.org]
|
|
||||||
user = ${{ secrets.OBS_USERNAME }}
|
|
||||||
pass = ${{ secrets.OBS_PASSWORD }}
|
|
||||||
EOF
|
|
||||||
chmod 600 ~/.config/osc/oscrc
|
|
||||||
|
|
||||||
# Cache OBS bundled Go toolchains
|
# Cache OBS bundled Go toolchains
|
||||||
- name: Cache OBS bundled Go toolchains (dms-git)
|
- name: Cache OBS bundled Go toolchains (dms-git)
|
||||||
@@ -404,7 +402,7 @@ jobs:
|
|||||||
- name: Summary
|
- name: Summary
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
echo "### OBS Package Upload Summary" >> $GITHUB_STEP_SUMMARY
|
echo "### OBS Package Upload Summary (${{ matrix.owner }})" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
PACKAGES="${{ steps.packages.outputs.packages }}"
|
PACKAGES="${{ steps.packages.outputs.packages }}"
|
||||||
@@ -457,10 +455,10 @@ jobs:
|
|||||||
|
|
||||||
case "$PKG" in
|
case "$PKG" in
|
||||||
dms)
|
dms)
|
||||||
echo "- $STATUS_ICON **dms** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms/dms)" >> $GITHUB_STEP_SUMMARY
|
echo "- $STATUS_ICON **dms** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/${OBS_BASE_PROJECT}:dms/dms)" >> $GITHUB_STEP_SUMMARY
|
||||||
;;
|
;;
|
||||||
dms-git)
|
dms-git)
|
||||||
echo "- $STATUS_ICON **dms-git** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/home:AvengeMedia:dms-git/dms-git)" >> $GITHUB_STEP_SUMMARY
|
echo "- $STATUS_ICON **dms-git** ($STATUS_TEXT) → [View builds](https://build.opensuse.org/package/show/${OBS_BASE_PROJECT}:dms-git/dms-git)" >> $GITHUB_STEP_SUMMARY
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@@ -476,5 +474,5 @@ jobs:
|
|||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Monitor build progress on [OBS project page](https://build.opensuse.org/project/show/home:AvengeMedia)." >> $GITHUB_STEP_SUMMARY
|
echo "Monitor build progress on [OBS project page](https://build.opensuse.org/project/show/${OBS_BASE_PROJECT})." >> $GITHUB_STEP_SUMMARY
|
||||||
fi
|
fi
|
||||||
@@ -16,6 +16,15 @@ on:
|
|||||||
description: "Release number for rebuilds (e.g., 2, 3, 4 for ppa2, ppa3, ppa4)"
|
description: "Release number for rebuilds (e.g., 2, 3, 4 for ppa2, ppa3, ppa4)"
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
owners:
|
||||||
|
description: "PPA owners to publish to"
|
||||||
|
required: false
|
||||||
|
default: "all"
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- all
|
||||||
|
- duxlabs
|
||||||
|
- avengemedia
|
||||||
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)
|
||||||
|
|
||||||
@@ -44,6 +53,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Check for updates
|
- name: Check for updates
|
||||||
id: check
|
id: check
|
||||||
|
env:
|
||||||
|
OWNERS: ${{ inputs.owners || 'all' }}
|
||||||
run: |
|
run: |
|
||||||
chmod +x distro/scripts/ppa-sync-plan.sh
|
chmod +x distro/scripts/ppa-sync-plan.sh
|
||||||
|
|
||||||
@@ -59,10 +70,20 @@ jobs:
|
|||||||
ARGS+=(--rebuild "$REBUILD_RELEASE")
|
ARGS+=(--rebuild "$REBUILD_RELEASE")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TARGETS_JSON=$(distro/scripts/ppa-sync-plan.sh "${ARGS[@]}" 2> ppa-audit.log)
|
# Planned per owner: an empty new PPA needs everything, the old one may need nothing.
|
||||||
|
TARGETS_JSON='[]'
|
||||||
|
: > ppa-audit.log
|
||||||
|
for T in duxlabs: avengemedia:_LEGACY; do
|
||||||
|
OWNER="${T%%:*}" SUFFIX="${T#*:}"
|
||||||
|
[[ "$OWNERS" == all || "$OWNERS" == "$OWNER" ]] || continue
|
||||||
|
echo "~${OWNER}:" >> ppa-audit.log
|
||||||
|
PLAN=$(PPA_OWNER="$OWNER" distro/scripts/ppa-sync-plan.sh "${ARGS[@]}" 2>> ppa-audit.log)
|
||||||
|
TARGETS_JSON=$(jq -c --arg o "$OWNER" --arg s "$SUFFIX" --argjson p "$PLAN" \
|
||||||
|
'. + [$p[] | {owner: $o, suffix: $s, target: .}]' <<< "$TARGETS_JSON")
|
||||||
|
done
|
||||||
cat ppa-audit.log
|
cat ppa-audit.log
|
||||||
|
|
||||||
TARGETS=$(echo "$TARGETS_JSON" | jq -r 'join(" ")')
|
TARGETS=$(jq -r 'map(.owner + ":" + .target) | join(" ")' <<< "$TARGETS_JSON")
|
||||||
if [[ "$TARGETS_JSON" != "[]" ]]; then
|
if [[ "$TARGETS_JSON" != "[]" ]]; then
|
||||||
echo "has_updates=true" >> "$GITHUB_OUTPUT"
|
echo "has_updates=true" >> "$GITHUB_OUTPUT"
|
||||||
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
|
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
|
||||||
@@ -76,7 +97,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
upload-ppa:
|
upload-ppa:
|
||||||
name: Upload ${{ matrix.target }}
|
name: Upload ${{ matrix.owner }} ${{ matrix.target }}
|
||||||
needs: check-updates
|
needs: check-updates
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: needs.check-updates.outputs.has_updates == 'true'
|
if: needs.check-updates.outputs.has_updates == 'true'
|
||||||
@@ -84,9 +105,9 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
target: ${{ fromJson(needs.check-updates.outputs.targets_json) }}
|
include: ${{ fromJson(needs.check-updates.outputs.targets_json) }}
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ppa-dms-${{ matrix.target }}
|
group: ppa-dms-${{ matrix.owner }}-${{ matrix.target }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -100,6 +121,7 @@ jobs:
|
|||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: ./core/go.mod
|
go-version-file: ./core/go.mod
|
||||||
|
cache-dependency-path: core/go.sum
|
||||||
cache: false
|
cache: false
|
||||||
|
|
||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
@@ -117,8 +139,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Configure GPG
|
- name: Configure GPG
|
||||||
env:
|
env:
|
||||||
GPG_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
GPG_KEY: ${{ secrets[format('GPG_PRIVATE_KEY{0}', matrix.suffix)] }}
|
||||||
run: |
|
run: |
|
||||||
|
: "${GPG_KEY:?GPG_PRIVATE_KEY${{ matrix.suffix }} is not set}"
|
||||||
echo "$GPG_KEY" | gpg --import
|
echo "$GPG_KEY" | gpg --import
|
||||||
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2)
|
GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format LONG | grep sec | awk '{print $2}' | cut -d'/' -f2)
|
||||||
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> "$GITHUB_ENV"
|
echo "DEBSIGN_KEYID=$GPG_KEY_ID" >> "$GITHUB_ENV"
|
||||||
@@ -126,9 +149,11 @@ jobs:
|
|||||||
- name: Upload target
|
- name: Upload target
|
||||||
env:
|
env:
|
||||||
TARGET: ${{ matrix.target }}
|
TARGET: ${{ matrix.target }}
|
||||||
LAUNCHPAD_SSH_PRIVATE_KEY: ${{ secrets.LAUNCHPAD_SSH_PRIVATE_KEY }}
|
PPA_OWNER: ${{ matrix.owner }}
|
||||||
LAUNCHPAD_SSH_LOGIN: ${{ secrets.LAUNCHPAD_SSH_LOGIN }}
|
LAUNCHPAD_SSH_PRIVATE_KEY: ${{ secrets[format('LAUNCHPAD_SSH_PRIVATE_KEY{0}', matrix.suffix)] }}
|
||||||
|
LAUNCHPAD_SSH_LOGIN: ${{ secrets[format('LAUNCHPAD_SSH_LOGIN{0}', matrix.suffix)] }}
|
||||||
run: |
|
run: |
|
||||||
|
: "${LAUNCHPAD_SSH_PRIVATE_KEY:?LAUNCHPAD_SSH_PRIVATE_KEY${{ matrix.suffix }} is not set}" "${LAUNCHPAD_SSH_LOGIN:?LAUNCHPAD_SSH_LOGIN${{ matrix.suffix }} is not set}"
|
||||||
IFS=':' read -r PACKAGE UBUNTU_SERIES PPA_NUM <<< "$TARGET"
|
IFS=':' read -r PACKAGE UBUNTU_SERIES PPA_NUM <<< "$TARGET"
|
||||||
|
|
||||||
case "$PACKAGE" in
|
case "$PACKAGE" in
|
||||||
@@ -137,7 +162,7 @@ jobs:
|
|||||||
*) echo "::error::Unknown package $PACKAGE"; exit 1 ;;
|
*) echo "::error::Unknown package $PACKAGE"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
echo "Uploading $PACKAGE to $PPA_NAME/$UBUNTU_SERIES with ppa$PPA_NUM"
|
echo "Uploading $PACKAGE to ~$PPA_OWNER/$PPA_NAME/$UBUNTU_SERIES with ppa$PPA_NUM"
|
||||||
bash distro/scripts/ppa-upload.sh "$PACKAGE" "$PPA_NAME" "$UBUNTU_SERIES" "$PPA_NUM"
|
bash distro/scripts/ppa-upload.sh "$PACKAGE" "$PPA_NAME" "$UBUNTU_SERIES" "$PPA_NUM"
|
||||||
|
|
||||||
- name: Summary
|
- name: Summary
|
||||||
@@ -145,6 +170,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "### PPA Package Upload" >> "$GITHUB_STEP_SUMMARY"
|
echo "### PPA Package Upload" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- **Owner:** ${{ matrix.owner }}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "- **Target:** ${{ matrix.target }}" >> "$GITHUB_STEP_SUMMARY"
|
echo "- **Target:** ${{ matrix.target }}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "- **DMS PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms/+packages" >> "$GITHUB_STEP_SUMMARY"
|
echo "- **DMS PPA:** https://launchpad.net/~${{ matrix.owner }}/+archive/ubuntu/dms/+packages" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "- **DMS-Git PPA:** https://launchpad.net/~avengemedia/+archive/ubuntu/dms-git/+packages" >> "$GITHUB_STEP_SUMMARY"
|
echo "- **DMS-Git PPA:** https://launchpad.net/~${{ matrix.owner }}/+archive/ubuntu/dms-git/+packages" >> "$GITHUB_STEP_SUMMARY"
|
||||||
@@ -3,9 +3,7 @@ name: Void Linux XBPS Repository
|
|||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
|
- cron: "0 2,5,14,17,20,23 * * *" # 9am, 12pm, 3pm, 6pm, 9pm, 12am EST (UTC times shown)
|
||||||
# release trigger disabled; dispatch manually after a release
|
# Stable builds are dispatched by release.yml after the release assets exist.
|
||||||
# release:
|
|
||||||
# types: [published]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
@@ -45,11 +43,13 @@ jobs:
|
|||||||
- name: Check published commit
|
- name: Check published commit
|
||||||
id: check
|
id: check
|
||||||
env:
|
env:
|
||||||
MANIFEST_URL: https://void.danklinux.com/dms/current/manifest.json
|
MANIFEST_URL: https://void.duxlabs.dev/dms/current/manifest.json
|
||||||
run: |
|
run: |
|
||||||
SHOULD_BUILD=true
|
SHOULD_BUILD=true
|
||||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||||
PUBLISHED_COMMIT="$(curl -fsSL --retry 3 "$MANIFEST_URL" | jq -r '.source_commit // empty' 2>/dev/null || true)"
|
# dux-repo's legacy greeter mirror also writes dms/current; read this repo's own entry
|
||||||
|
PUBLISHED_COMMIT="$(curl -fsSL --retry 3 "$MANIFEST_URL" \
|
||||||
|
| jq -r --arg repo "${{ github.repository }}" '.sources[$repo] // .source_commit // empty' 2>/dev/null || true)"
|
||||||
if [ "$PUBLISHED_COMMIT" = "${{ github.sha }}" ]; then
|
if [ "$PUBLISHED_COMMIT" = "${{ github.sha }}" ]; then
|
||||||
SHOULD_BUILD=false
|
SHOULD_BUILD=false
|
||||||
echo "${{ github.sha }} is already published; skipping the scheduled build."
|
echo "${{ github.sha }} is already published; skipping the scheduled build."
|
||||||
@@ -159,8 +159,8 @@ jobs:
|
|||||||
- name: Configure repositories
|
- name: Configure repositories
|
||||||
run: |
|
run: |
|
||||||
# Append the repository to repos-remote templates so xbps-src translates it automatically
|
# Append the repository to repos-remote templates so xbps-src translates it automatically
|
||||||
echo "repository=https://void.danklinux.com/danklinux/current" >> void-packages/etc/xbps.d/repos-remote.conf
|
echo "repository=https://void.duxlabs.dev/dux-repo/current" >> void-packages/etc/xbps.d/repos-remote.conf
|
||||||
echo "repository=https://void.danklinux.com/danklinux/current" >> void-packages/etc/xbps.d/repos-remote-x86_64-multilib.conf
|
echo "repository=https://void.duxlabs.dev/dux-repo/current" >> void-packages/etc/xbps.d/repos-remote-x86_64-multilib.conf
|
||||||
|
|
||||||
# Add any existing compiled packages to the build cache directory to avoid rebuilds
|
# Add any existing compiled packages to the build cache directory to avoid rebuilds
|
||||||
if [ -d "r2-repo/current" ]; then
|
if [ -d "r2-repo/current" ]; then
|
||||||
@@ -278,36 +278,9 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Index and sign repository
|
- name: Index and sign repository
|
||||||
run: |
|
env:
|
||||||
cd r2-repo/current
|
XBPS_PRIVATE_KEY: ${{ secrets.XBPS_PRIVATE_KEY }}
|
||||||
|
run: bash .github/scripts/publish-void-r2.sh index
|
||||||
rm -f *.sig
|
|
||||||
|
|
||||||
# Guard: nothing to index if no .xbps files exist
|
|
||||||
if ! ls *.xbps 1>/dev/null 2>&1; then
|
|
||||||
echo "⚠️ No .xbps files found to index, skipping."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Regenerate repo index
|
|
||||||
xbps-rindex -a $(pwd)/*.xbps
|
|
||||||
xbps-rindex --remove-obsoletes $(pwd)
|
|
||||||
|
|
||||||
# Remove signatures left behind by obsolete packages. Signatures for
|
|
||||||
# unchanged immutable packages are retained byte-for-byte.
|
|
||||||
for sig in *.sig2; do
|
|
||||||
[ -e "$sig" ] || break
|
|
||||||
[ -f "${sig%.sig2}" ] || rm -f "$sig"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Sign repository
|
|
||||||
echo "${{ secrets.XBPS_PRIVATE_KEY }}" > /tmp/xbps_privkey.pem
|
|
||||||
chmod 600 /tmp/xbps_privkey.pem
|
|
||||||
|
|
||||||
xbps-rindex --sign --signedby "AvengeMedia <[email protected]>" --privkey /tmp/xbps_privkey.pem $(pwd)
|
|
||||||
xbps-rindex --sign-pkg --privkey /tmp/xbps_privkey.pem $(pwd)/*.xbps
|
|
||||||
|
|
||||||
rm -f /tmp/xbps_privkey.pem
|
|
||||||
|
|
||||||
- name: Publish repository to R2
|
- name: Publish repository to R2
|
||||||
run: bash .github/scripts/publish-void-r2.sh publish
|
run: bash .github/scripts/publish-void-r2.sh publish
|
||||||
+1
-1
@@ -130,6 +130,6 @@ distro/void/masterdir*/
|
|||||||
|
|
||||||
# Often gets built
|
# Often gets built
|
||||||
core/dms
|
core/dms
|
||||||
core/dankinstall
|
core/duxinstall
|
||||||
|
|
||||||
core/internal/shellembed/dist
|
core/internal/shellembed/dist
|
||||||
@@ -4,7 +4,7 @@ DMS is an open-source desktop shell for Wayland compositors on linux, built to w
|
|||||||
|
|
||||||
## Repo Structure
|
## Repo Structure
|
||||||
|
|
||||||
- core/ - Go. `cmd/dms` runs quickshell, CLI utilities, and the unix socket server the UI depends on. `cmd/dankinstall` is a separate installer TUI.
|
- core/ - Go. `cmd/dms` runs quickshell, CLI utilities, and the unix socket server the UI depends on. `cmd/duxinstall` is a separate installer TUI.
|
||||||
- quickshell/ - the entire UI: Common/ (singletons, Theme, settings), Services/ (system and core access), Modules/ (bar, dock, lock, ...), Modals/, Widgets/ (shell-specific widgets and deprecated Dank* shims), PLUGINS/, translations/
|
- quickshell/ - the entire UI: Common/ (singletons, Theme, settings), Services/ (system and core access), Modules/ (bar, dock, lock, ...), Modals/, Widgets/ (shell-specific widgets and deprecated Dank* shims), PLUGINS/, translations/
|
||||||
- dux-qml-common/ - submodule of shared QML widgets
|
- dux-qml-common/ - submodule of shared QML widgets
|
||||||
- docs/, distro/ - IPC and theme docs, packaging
|
- docs/, distro/ - IPC and theme docs, packaging
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
|
|||||||
[](https://github.com/duxlabs-dev/dux-shell/stargazers)
|
[](https://github.com/duxlabs-dev/dux-shell/stargazers)
|
||||||
[](https://github.com/duxlabs-dev/dux-shell/blob/master/LICENSE)
|
[](https://github.com/duxlabs-dev/dux-shell/blob/master/LICENSE)
|
||||||
[](https://github.com/duxlabs-dev/dux-shell/releases)
|
[](https://github.com/duxlabs-dev/dux-shell/releases)
|
||||||
[](https://archlinux.org/packages/extra/x86_64/dms-shell/)
|
[](https://archlinux.org/packages/extra/x86_64/dux-shell/)
|
||||||
[>)](https://aur.archlinux.org/packages/dms-shell-git)
|
[>)](https://aur.archlinux.org/packages/dux-shell-git)
|
||||||
[](https://ko-fi.com/duxlabs)
|
[](https://ko-fi.com/duxlabs)
|
||||||
|
[](https://discord.gg/ppWTpKmPgT)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ dux-shell/
|
|||||||
│ ├── Widgets/ # Reusable UI controls
|
│ ├── Widgets/ # Reusable UI controls
|
||||||
│ └── Common/ # Shared resources and themes
|
│ └── Common/ # Shared resources and themes
|
||||||
├── core/ # Go backend and CLI
|
├── core/ # Go backend and CLI
|
||||||
│ ├── cmd/ # dms CLI and dankinstall binaries
|
│ ├── cmd/ # dms CLI and duxinstall binaries
|
||||||
│ ├── internal/ # System integration, IPC, distro support
|
│ ├── internal/ # System integration, IPC, distro support
|
||||||
│ └── pkg/ # Shared packages
|
│ └── pkg/ # Shared packages
|
||||||
├── distro/ # Distribution packaging
|
├── distro/ # Distribution packaging
|
||||||
@@ -100,7 +101,7 @@ Notification center with grouping, rich text support, and keyboard navigation.
|
|||||||
MPRIS player controls, calendar sync, weather widgets, and clipboard history with image previews.
|
MPRIS player controls, calendar sync, weather widgets, and clipboard history with image previews.
|
||||||
|
|
||||||
**Session Management**
|
**Session Management**
|
||||||
Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and a settings front-end for [dank-greeter](https://github.com/duxlabs-dev/dux-greeter).
|
Lock screen, idle detection, auto-lock/suspend with separate AC/battery settings, and a settings front-end for [dux-greeter](https://github.com/duxlabs-dev/dux-greeter).
|
||||||
|
|
||||||
**Plugin System**
|
**Plugin System**
|
||||||
Extend functionality with the [plugin registry](https://plugins.duxlabs.dev). DMS keeps
|
Extend functionality with the [plugin registry](https://plugins.duxlabs.dev). DMS keeps
|
||||||
@@ -138,16 +139,16 @@ dms plugins restore ~/plugins.lock.json
|
|||||||
- **Plugins:** [Development guide](https://duxlabs.dev/docs/dms/plugins-overview)
|
- **Plugins:** [Development guide](https://duxlabs.dev/docs/dms/plugins-overview)
|
||||||
- **Support:** [Ko-fi](https://ko-fi.com/duxlabs)
|
- **Support:** [Ko-fi](https://ko-fi.com/duxlabs)
|
||||||
|
|
||||||
## Dank Projects
|
## Dux Projects
|
||||||
|
|
||||||
DMS is one piece of the suite. The rest lives in its own repos:
|
DMS is one piece of the suite. The rest lives in its own repos:
|
||||||
|
|
||||||
- [dank-greeter](https://github.com/duxlabs-dev/dux-greeter) - greetd login screen with the Dux Shell aesthetic. The Greeter tab in DMS settings is the front-end for it.
|
- [dux-greeter](https://github.com/duxlabs-dev/dux-greeter) - greetd login screen with the Dux Shell aesthetic. The Greeter tab in DMS settings is the front-end for it.
|
||||||
- [dankcalendar](https://github.com/duxlabs-dev/dux-calendar) - Local, Google, Microsoft, and CalDAV calendars for the Dux desktop.
|
- [dux-calendar](https://github.com/duxlabs-dev/dux-calendar) - Local, Google, Microsoft, and CalDAV calendars for the Dux desktop.
|
||||||
- [dgop](https://github.com/duxlabs-dev/dux-gop) - System monitoring TUI and Go library; its library powers the process list and dashboard widgets inside the dms daemon.
|
- [dgop](https://github.com/duxlabs-dev/dux-gop) - System monitoring TUI and Go library; its library powers the process list and dashboard widgets inside the dms daemon.
|
||||||
- [dsearch](https://github.com/duxlabs-dev/dux-search) - Fast file search that powers file results in the launcher.
|
- [dsearch](https://github.com/duxlabs-dev/dux-search) - Fast file search that powers file results in the launcher.
|
||||||
- [dux-qml-common](https://github.com/duxlabs-dev/dux-qml-common) - Shared QML widgets and components used by DMS, dank-greeter, and dankcalendar.
|
- [dux-qml-common](https://github.com/duxlabs-dev/dux-qml-common) - Shared QML widgets and components used by DMS, dux-greeter, and dux-calendar.
|
||||||
- [dankgo](https://github.com/duxlabs-dev/dux-go) - Common Go modules behind the single binary apps.
|
- [dux-go](https://github.com/duxlabs-dev/dux-go) - Common Go modules behind the single binary apps.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -157,14 +158,16 @@ See component-specific documentation:
|
|||||||
- **[core/](core/)** - Go backend, CLI tools, and system integration
|
- **[core/](core/)** - Go backend, CLI tools, and system integration
|
||||||
- **[distro/](distro/)** - Distribution packaging (Fedora, Debian, NixOS)
|
- **[distro/](distro/)** - Distribution packaging (Fedora, Debian, NixOS)
|
||||||
|
|
||||||
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for editor setup, the i18n workflow, and Go style guidelines.
|
||||||
|
|
||||||
### Building from Source
|
### Building from Source
|
||||||
|
|
||||||
**Core + Dankinstall:**
|
**Core + Duxinstall:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd core
|
cd core
|
||||||
make # Build dms CLI
|
make # Build dms CLI
|
||||||
make dankinstall # Build installer
|
make duxinstall # Build installer
|
||||||
```
|
```
|
||||||
|
|
||||||
**Shell:**
|
**Shell:**
|
||||||
@@ -186,14 +189,12 @@ quickshell -p quickshell/
|
|||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions welcome. Bug fixes, widgets, features, documentation, and plugins all help.
|
Contributions welcome. Bug fixes, widgets, features, translation, documentation and plugins all help.
|
||||||
|
|
||||||
1. Fork the repository
|
- For code contributions, see [Development](#development) and [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
2. Make your changes
|
- Translations are managed through POEditor: [DMS on POEditor](https://poeditor.com/join/project/kyeCPTpmJM)
|
||||||
3. Test thoroughly
|
- For documentation contributions, see [dux-docs](https://github.com/duxlabs-dev/dux-docs)
|
||||||
4. Open a pull request
|
- If you want to discuss designs or need help with contributing, join the [Discord](https://discord.gg/ppWTpKmPgT)
|
||||||
|
|
||||||
For documentation contributions, see [DankLinux-Docs](https://github.com/duxlabs-dev/dux-docs).
|
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Requisite=graphical-session.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=notify
|
Type=notify
|
||||||
|
BusName=org.freedesktop.Notifications
|
||||||
NotifyAccess=main
|
NotifyAccess=main
|
||||||
Environment=QS_DISABLE_CRASH_HANDLER=1
|
Environment=QS_DISABLE_CRASH_HANDLER=1
|
||||||
ExecStart=/usr/bin/dms run --session
|
ExecStart=/usr/bin/dms run --session
|
||||||
|
|||||||
+11
-11
@@ -1,7 +1,7 @@
|
|||||||
BINARY_NAME=dms
|
BINARY_NAME=dms
|
||||||
BINARY_NAME_INSTALL=dankinstall
|
BINARY_NAME_INSTALL=duxinstall
|
||||||
SOURCE_DIR=cmd/dms
|
SOURCE_DIR=cmd/dms
|
||||||
SOURCE_DIR_INSTALL=cmd/dankinstall
|
SOURCE_DIR_INSTALL=cmd/duxinstall
|
||||||
BUILD_DIR=bin
|
BUILD_DIR=bin
|
||||||
SHELL_SRC=../quickshell
|
SHELL_SRC=../quickshell
|
||||||
EMBED_DIR=internal/shellembed/dist
|
EMBED_DIR=internal/shellembed/dist
|
||||||
@@ -32,7 +32,7 @@ else
|
|||||||
DIST_ARCHS = $(ARCH)
|
DIST_ARCHS = $(ARCH)
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: all build sync-shell dankinstall dist clean install install-all install-dankinstall uninstall uninstall-all uninstall-dankinstall install-config uninstall-config test fmt vet deps print-version help
|
.PHONY: all build sync-shell duxinstall dist clean install install-all install-duxinstall uninstall uninstall-all uninstall-duxinstall install-config uninstall-config test fmt vet deps print-version help
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
all: build
|
all: build
|
||||||
@@ -58,7 +58,7 @@ build: sync-shell
|
|||||||
CGO_ENABLED=0 $(GO) build -tags withshell $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./$(SOURCE_DIR)
|
CGO_ENABLED=0 $(GO) build -tags withshell $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./$(SOURCE_DIR)
|
||||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)"
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)"
|
||||||
|
|
||||||
dankinstall:
|
duxinstall:
|
||||||
@echo "Building $(BINARY_NAME_INSTALL)..."
|
@echo "Building $(BINARY_NAME_INSTALL)..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
CGO_ENABLED=0 $(GO) build $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME_INSTALL) ./$(SOURCE_DIR_INSTALL)
|
CGO_ENABLED=0 $(GO) build $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME_INSTALL) ./$(SOURCE_DIR_INSTALL)
|
||||||
@@ -77,7 +77,7 @@ dist: sync-shell
|
|||||||
done
|
done
|
||||||
@echo "Distribution builds complete"
|
@echo "Distribution builds complete"
|
||||||
|
|
||||||
build-all: build dankinstall
|
build-all: build duxinstall
|
||||||
|
|
||||||
install:
|
install:
|
||||||
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
|
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
|
||||||
@@ -91,7 +91,7 @@ install-all:
|
|||||||
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
|
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
|
||||||
@echo "Installation complete"
|
@echo "Installation complete"
|
||||||
|
|
||||||
install-dankinstall:
|
install-duxinstall:
|
||||||
@echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..."
|
@echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..."
|
||||||
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
|
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
|
||||||
@echo "Installation complete"
|
@echo "Installation complete"
|
||||||
@@ -108,7 +108,7 @@ uninstall-all:
|
|||||||
@rm -f $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
|
@rm -f $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
|
||||||
@echo "Uninstall complete"
|
@echo "Uninstall complete"
|
||||||
|
|
||||||
uninstall-dankinstall:
|
uninstall-duxinstall:
|
||||||
@echo "Uninstalling $(BINARY_NAME_INSTALL) from $(INSTALL_DIR)..."
|
@echo "Uninstalling $(BINARY_NAME_INSTALL) from $(INSTALL_DIR)..."
|
||||||
@rm -f $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
|
@rm -f $(INSTALL_DIR)/$(BINARY_NAME_INSTALL)
|
||||||
@echo "Uninstall complete"
|
@echo "Uninstall complete"
|
||||||
@@ -160,16 +160,16 @@ help:
|
|||||||
@echo " all - Build the main binary (dms) (default)"
|
@echo " all - Build the main binary (dms) (default)"
|
||||||
@echo " build - Build the main binary (dms) with the embedded UI"
|
@echo " build - Build the main binary (dms) with the embedded UI"
|
||||||
@echo " sync-shell - Copy quickshell/ into the embed dir (runs before tagged builds)"
|
@echo " sync-shell - Copy quickshell/ into the embed dir (runs before tagged builds)"
|
||||||
@echo " dankinstall - Build dankinstall binary"
|
@echo " duxinstall - Build duxinstall binary"
|
||||||
@echo " dist - Build dms for linux/freebsd amd64/arm64 (no update/greeter)"
|
@echo " dist - Build dms for linux/freebsd amd64/arm64 (no update/greeter)"
|
||||||
@echo " Use ARCH=amd64 or ARCH=arm64 and/or DIST_OSES=linux to narrow"
|
@echo " Use ARCH=amd64 or ARCH=arm64 and/or DIST_OSES=linux to narrow"
|
||||||
@echo " build-all - Build both binaries"
|
@echo " build-all - Build both binaries"
|
||||||
@echo " install - Install dms to $(INSTALL_DIR)"
|
@echo " install - Install dms to $(INSTALL_DIR)"
|
||||||
@echo " install-all - Install both dms and dankinstall to $(INSTALL_DIR)"
|
@echo " install-all - Install both dms and duxinstall to $(INSTALL_DIR)"
|
||||||
@echo " install-dankinstall - Install only dankinstall to $(INSTALL_DIR)"
|
@echo " install-duxinstall - Install only duxinstall to $(INSTALL_DIR)"
|
||||||
@echo " uninstall - Remove dms from $(INSTALL_DIR)"
|
@echo " uninstall - Remove dms from $(INSTALL_DIR)"
|
||||||
@echo " uninstall-all - Remove both binaries from $(INSTALL_DIR)"
|
@echo " uninstall-all - Remove both binaries from $(INSTALL_DIR)"
|
||||||
@echo " uninstall-dankinstall - Remove only dankinstall from $(INSTALL_DIR)"
|
@echo " uninstall-duxinstall - Remove only duxinstall from $(INSTALL_DIR)"
|
||||||
@echo " clean - Clean build artifacts"
|
@echo " clean - Clean build artifacts"
|
||||||
@echo " test - Run tests"
|
@echo " test - Run tests"
|
||||||
@echo " fmt - Format Go code"
|
@echo " fmt - Format Go code"
|
||||||
|
|||||||
+7
-7
@@ -9,7 +9,7 @@ Go-based backend for Dux Shell providing system integration, IPC, and installati
|
|||||||
**dms CLI**
|
**dms CLI**
|
||||||
Command-line interface and daemon for shell management and system control.
|
Command-line interface and daemon for shell management and system control.
|
||||||
|
|
||||||
**dankinstall**
|
**duxinstall**
|
||||||
Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo, and Void. Supports both an interactive TUI and a headless (unattended) mode via CLI flags.
|
Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo, and Void. Supports both an interactive TUI and a headless (unattended) mode via CLI flags.
|
||||||
|
|
||||||
## System Integration
|
## System Integration
|
||||||
@@ -102,7 +102,7 @@ Requires Go 1.25+
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
make # Build dms CLI
|
make # Build dms CLI
|
||||||
make dankinstall # Build installer
|
make duxinstall # Build installer
|
||||||
make test # Run tests
|
make test # Run tests
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -142,12 +142,12 @@ go run github.com/duxlabs-dev/dux-go/cmd/go-wayland-scanner@latest \
|
|||||||
|
|
||||||
**Module Structure:**
|
**Module Structure:**
|
||||||
|
|
||||||
- `cmd/` - Binary entrypoints (dms, dankinstall)
|
- `cmd/` - Binary entrypoints (dms, duxinstall)
|
||||||
- `internal/distros/` - Distribution-specific installation logic
|
- `internal/distros/` - Distribution-specific installation logic
|
||||||
- `internal/proto/` - Wayland protocol bindings
|
- `internal/proto/` - Wayland protocol bindings
|
||||||
- `pkg/` - Shared packages
|
- `pkg/` - Shared packages
|
||||||
|
|
||||||
## Installation via dankinstall
|
## Installation via duxinstall
|
||||||
|
|
||||||
**Interactive (TUI):**
|
**Interactive (TUI):**
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@ Headless mode requires `--yes` to proceed; without it, the installer exits with
|
|||||||
Configuration files are not replaced by default unless `--replace-configs` or `--replace-configs-all` is specified.
|
Configuration files are not replaced by default unless `--replace-configs` or `--replace-configs-all` is specified.
|
||||||
Optional packages (`dms-greeter`, `danksearch`, `dankcalendar`) are disabled by default; enable them with their dedicated flags, `--include-deps`, or `--all-features`.
|
Optional packages (`dms-greeter`, `danksearch`, `dankcalendar`) are disabled by default; enable them with their dedicated flags, `--include-deps`, or `--all-features`.
|
||||||
|
|
||||||
When no flags are provided, `dankinstall` launches the interactive TUI.
|
When no flags are provided, `duxinstall` launches the interactive TUI.
|
||||||
|
|
||||||
### Headless mode validation rules
|
### Headless mode validation rules
|
||||||
|
|
||||||
@@ -198,8 +198,8 @@ Headless mode activates when `--compositor` or `--term` is provided.
|
|||||||
|
|
||||||
### Log file location
|
### Log file location
|
||||||
|
|
||||||
`dankinstall` writes logs to `/tmp` by default.
|
`duxinstall` writes logs to `/tmp` by default.
|
||||||
Set the `DANKINSTALL_LOG_DIR` environment variable to override the log directory.
|
Set the `DUXINSTALL_LOG_DIR` environment variable to override the log directory.
|
||||||
|
|
||||||
## Supported Distributions
|
## Supported Distributions
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ NC='\033[0m' # No Color
|
|||||||
# Get latest version tag
|
# Get latest version tag
|
||||||
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
||||||
|
|
||||||
echo -e "${GREEN}Building dankinstall ${VERSION}${NC}"
|
echo -e "${GREEN}Building duxinstall ${VERSION}${NC}"
|
||||||
|
|
||||||
# Create bin directory if it doesn't exist
|
# Create bin directory if it doesn't exist
|
||||||
mkdir -p bin
|
mkdir -p bin
|
||||||
@@ -19,20 +19,20 @@ mkdir -p bin
|
|||||||
for ARCH in amd64 arm64; do
|
for ARCH in amd64 arm64; do
|
||||||
echo -e "${BLUE}Building for ${ARCH}...${NC}"
|
echo -e "${BLUE}Building for ${ARCH}...${NC}"
|
||||||
|
|
||||||
cd cmd/dankinstall
|
cd cmd/duxinstall
|
||||||
GOOS=linux CGO_ENABLED=0 GOARCH=${ARCH} \
|
GOOS=linux CGO_ENABLED=0 GOARCH=${ARCH} \
|
||||||
go build -trimpath -ldflags "-s -w -X main.Version=${VERSION}" \
|
go build -trimpath -ldflags "-s -w -X main.Version=${VERSION}" \
|
||||||
-o ../../bin/dankinstall-${ARCH}
|
-o ../../bin/duxinstall-${ARCH}
|
||||||
cd ../..
|
cd ../..
|
||||||
|
|
||||||
# Compress
|
# Compress
|
||||||
gzip -9 -k -f bin/dankinstall-${ARCH}
|
gzip -9 -k -f bin/duxinstall-${ARCH}
|
||||||
|
|
||||||
# Generate checksum
|
# Generate checksum
|
||||||
(cd bin && sha256sum dankinstall-${ARCH}.gz > dankinstall-${ARCH}.gz.sha256)
|
(cd bin && sha256sum duxinstall-${ARCH}.gz > duxinstall-${ARCH}.gz.sha256)
|
||||||
|
|
||||||
echo -e "${GREEN}✓ Built bin/dankinstall-${ARCH}.gz${NC}"
|
echo -e "${GREEN}✓ Built bin/duxinstall-${ARCH}.gz${NC}"
|
||||||
done
|
done
|
||||||
|
|
||||||
echo -e "${GREEN}Done! Files ready in bin/:${NC}"
|
echo -e "${GREEN}Done! Files ready in bin/:${NC}"
|
||||||
ls -lh bin/dankinstall-*
|
ls -lh bin/duxinstall-*
|
||||||
@@ -1,10 +1 @@
|
|||||||
{
|
{"policy_version":1,"blocked_commands":[],"message":"This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes."}
|
||||||
"policy_version": 1,
|
|
||||||
"blocked_commands": [
|
|
||||||
"greeter install",
|
|
||||||
"greeter enable",
|
|
||||||
"greeter uninstall",
|
|
||||||
"setup"
|
|
||||||
],
|
|
||||||
"message": "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes."
|
|
||||||
}
|
|
||||||
@@ -795,6 +795,7 @@ func getCommonCommands() []*cobra.Command {
|
|||||||
systemdCmd,
|
systemdCmd,
|
||||||
switchUserCmd,
|
switchUserCmd,
|
||||||
switchCmd,
|
switchCmd,
|
||||||
|
migrateCmd,
|
||||||
dmenuCmd,
|
dmenuCmd,
|
||||||
}...)
|
}...)
|
||||||
}
|
}
|
||||||
@@ -55,32 +55,8 @@ var resolveIncludeCmd = &cobra.Command{
|
|||||||
Run: runResolveInclude,
|
Run: runResolveInclude,
|
||||||
}
|
}
|
||||||
|
|
||||||
var mangoMigrateMain bool
|
|
||||||
|
|
||||||
var mangoMigrateCmd = &cobra.Command{
|
|
||||||
Use: "mango-migrate",
|
|
||||||
Short: "Respell Mango config keys for the installed Mango",
|
|
||||||
Long: "Detects whether the installed Mango uses legacy or snake_case config keys, rewrites the DMS fragments in mango/dms to match, and prints the dialect on the last line. --main also rewrites config.conf after a backup.",
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
dialect := mangoconf.Detect()
|
|
||||||
changed, err := dialect.Migrate(mangoconf.Dir(), mangoMigrateMain)
|
|
||||||
for _, path := range changed {
|
|
||||||
fmt.Fprintf(os.Stderr, "migrated %s\n", path)
|
|
||||||
}
|
|
||||||
// Printed before any error: the shell needs the dialect even when a fragment failed.
|
|
||||||
fmt.Println(dialect)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
configCmd.AddCommand(resolveIncludeCmd)
|
configCmd.AddCommand(resolveIncludeCmd)
|
||||||
mangoMigrateCmd.Flags().BoolVar(&mangoMigrateMain, "main", false, "also rewrite config.conf (a backup is kept)")
|
|
||||||
configCmd.AddCommand(mangoMigrateCmd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type IncludeResult struct {
|
type IncludeResult struct {
|
||||||
|
|||||||
+152
-44
@@ -219,6 +219,7 @@ func runDoctor(cmd *cobra.Command, args []string) {
|
|||||||
checkSystemInfo(),
|
checkSystemInfo(),
|
||||||
checkVersions(qsMissingFeatures),
|
checkVersions(qsMissingFeatures),
|
||||||
checkDMSInstallation(),
|
checkDMSInstallation(),
|
||||||
|
checkPackageRepos(),
|
||||||
checkWindowManagers(),
|
checkWindowManagers(),
|
||||||
qsFeatures,
|
qsFeatures,
|
||||||
checkOptionalDependencies(),
|
checkOptionalDependencies(),
|
||||||
@@ -285,15 +286,15 @@ func checkSystemInfo() []checkResult {
|
|||||||
if message == "" {
|
if message == "" {
|
||||||
message = fmt.Sprintf("%s %s", osInfo.Distribution.ID, osInfo.VersionID)
|
message = fmt.Sprintf("%s %s", osInfo.Distribution.ID, osInfo.VersionID)
|
||||||
}
|
}
|
||||||
if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) {
|
details := fmt.Sprintf("ID: %s, Version: %s, Arch: %s", osInfo.ID, osInfo.VersionID, osInfo.Architecture)
|
||||||
|
switch {
|
||||||
|
case osInfo.Immutable:
|
||||||
|
details += "; image-based (" + osInfo.ImmutableReason + "), duxinstall deploys configuration only"
|
||||||
|
case distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID):
|
||||||
status = statusWarn
|
status = statusWarn
|
||||||
message += " (version may not be fully supported)"
|
message += " (version may not be fully supported)"
|
||||||
}
|
}
|
||||||
results = append(results, checkResult{
|
results = append(results, checkResult{catSystem, "Operating System", status, message, details, doctorDocsURL + "#operating-system"})
|
||||||
catSystem, "Operating System", status, message,
|
|
||||||
fmt.Sprintf("ID: %s, Version: %s, Arch: %s", osInfo.Distribution.ID, osInfo.VersionID, osInfo.Architecture),
|
|
||||||
doctorDocsURL + "#operating-system",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
arch := runtime.GOARCH
|
arch := runtime.GOARCH
|
||||||
@@ -804,6 +805,36 @@ func checkDMSInstallation() []checkResult {
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkPackageRepos() []checkResult {
|
||||||
|
osInfo, err := distros.GetOSInfo()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
distro, err := distros.NewDistribution(osInfo.Distribution.ID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return packageReposResult(distro)
|
||||||
|
}
|
||||||
|
|
||||||
|
func packageReposResult(distro distros.Distribution) []checkResult {
|
||||||
|
moves, ok := distros.PlanRepoMoves(distro)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
url := doctorDocsURL + "#package-repos"
|
||||||
|
legacy := distros.LegacyRepoPaths(moves)
|
||||||
|
if len(legacy) > 0 {
|
||||||
|
return []checkResult{{catInstallation, "Package Repos", statusWarn, "Legacy repositories, run: " + migrateDuxCommand, strings.Join(legacy, ", "), url}}
|
||||||
|
}
|
||||||
|
for _, move := range moves {
|
||||||
|
if move.NewPresent {
|
||||||
|
return []checkResult{{catInstallation, "Package Repos", statusOK, "Current", "", url}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []checkResult{{catInstallation, "Package Repos", statusInfo, "No DMS package repositories", "", url}}
|
||||||
|
}
|
||||||
|
|
||||||
func checkWindowManagers() []checkResult {
|
func checkWindowManagers() []checkResult {
|
||||||
compositors := []struct {
|
compositors := []struct {
|
||||||
name, versionCmd, versionArg string
|
name, versionCmd, versionArg string
|
||||||
@@ -858,6 +889,14 @@ func checkWindowManagers() []checkResult {
|
|||||||
if c.name == "Hyprland" {
|
if c.name == "Hyprland" {
|
||||||
results = append(results, checkHyprlandConfigFormat(filepath.Join(utils.XDGConfigHome(), "hypr"))...)
|
results = append(results, checkHyprlandConfigFormat(filepath.Join(utils.XDGConfigHome(), "hypr"))...)
|
||||||
}
|
}
|
||||||
|
if c.name == "mangowc" && mangoLacksSnakeCase(ver) {
|
||||||
|
results = append(results, checkResult{
|
||||||
|
catCompositor, "Mango config format", statusError,
|
||||||
|
fmt.Sprintf("Mango %s is older than 0.18", ver),
|
||||||
|
"DMS writes Mango config with the snake_case keys of Mango 0.18 and later, which this release rejects; upgrade Mango",
|
||||||
|
doctorDocsURL + "#compositor-checks",
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !foundAny {
|
if !foundAny {
|
||||||
@@ -878,7 +917,7 @@ func checkWindowManagers() []checkResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if os.Getenv("MANGO_INSTANCE_SIGNATURE") != "" {
|
if os.Getenv("MANGO_INSTANCE_SIGNATURE") != "" {
|
||||||
results = append(results, checkMangoConfig(mangoconf.Dir(), mangoconf.Detect())...)
|
results = append(results, checkMangoConfig(mangoconf.Dir())...)
|
||||||
}
|
}
|
||||||
|
|
||||||
results = append(results, checkCompositorBlurSupport())
|
results = append(results, checkCompositorBlurSupport())
|
||||||
@@ -886,33 +925,70 @@ func checkWindowManagers() []checkResult {
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
var mangoLegacyOverviewBinds = regexp.MustCompile(`(?im)^\s*mousebind\s*=\s*none\s*,\s*(btn_left\s*,\s*toggleoverview\s*,\s*1|btn_right\s*,\s*killclient\s*,\s*0)\s*$`)
|
var (
|
||||||
|
mangoLegacyOverviewBinds = regexp.MustCompile(`(?im)^\s*mousebind\s*=\s*none\s*,\s*(btn_left\s*,\s*toggleoverview\s*,\s*1|btn_right\s*,\s*killclient\s*,\s*0)\s*$`)
|
||||||
|
mangoUnknownKeyword = regexp.MustCompile(`Unknown keyword:\s*(\S+)`)
|
||||||
|
ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||||
|
)
|
||||||
|
|
||||||
func checkMangoConfig(mangoDir string, dialect mangoconf.Dialect) []checkResult {
|
// Mango's parser follows source= and always exits 0; only its output tells.
|
||||||
|
var mangoParseConfig = func(path string) (string, error) {
|
||||||
|
if _, err := exec.LookPath("mango"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out, _ := exec.Command("mango", "-p", "-c", path).CombinedOutput()
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each "Unknown keyword" line is followed by a location line naming the file.
|
||||||
|
func mangoUnknownKeywords(output string) []string {
|
||||||
|
var found []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
key := ""
|
||||||
|
for line := range strings.SplitSeq(ansiEscape.ReplaceAllString(output, ""), "\n") {
|
||||||
|
if m := mangoUnknownKeyword.FindStringSubmatch(line); m != nil {
|
||||||
|
key = m[1]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slash := strings.LastIndex(line, "/")
|
||||||
|
if key == "" || slash < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
file, _, _ := strings.Cut(line[slash+1:], ":")
|
||||||
|
entry := key + " (" + file + ")"
|
||||||
|
if !seen[entry] {
|
||||||
|
seen[entry] = true
|
||||||
|
found = append(found, entry)
|
||||||
|
}
|
||||||
|
key = ""
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkMangoConfig(mangoDir string) []checkResult {
|
||||||
url := doctorDocsURL + "#compositor-checks"
|
url := doctorDocsURL + "#compositor-checks"
|
||||||
paths, _ := filepath.Glob(filepath.Join(mangoDir, "dms", "*.conf"))
|
mainPath := filepath.Join(mangoDir, "config.conf")
|
||||||
paths = append([]string{filepath.Join(mangoDir, "config.conf")}, paths...)
|
var results []checkResult
|
||||||
|
if output, err := mangoParseConfig(mainPath); err == nil {
|
||||||
|
if unknown := mangoUnknownKeywords(output); len(unknown) > 0 {
|
||||||
|
results = append(results, checkResult{catCompositor, "Mango config keys", statusError,
|
||||||
|
"Unknown keys: " + strings.Join(unknown, ", "),
|
||||||
|
"Mango 0.18 renamed its config keys to snake_case. Run migrate-config-keys.sh from Mango's 0.18.0 release over ~/.config/mango, or rename the keys by hand.", url})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var stale, overviewBinds []string
|
paths, _ := filepath.Glob(filepath.Join(mangoDir, "dms", "*.conf"))
|
||||||
|
paths = append([]string{mainPath}, paths...)
|
||||||
|
var overviewBinds []string
|
||||||
for _, path := range paths {
|
for _, path := range paths {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if dialect.Translate(string(data)) != string(data) {
|
|
||||||
stale = append(stale, filepath.Base(path))
|
|
||||||
}
|
|
||||||
if mangoLegacyOverviewBinds.Match(data) {
|
if mangoLegacyOverviewBinds.Match(data) {
|
||||||
overviewBinds = append(overviewBinds, filepath.Base(path))
|
overviewBinds = append(overviewBinds, filepath.Base(path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var results []checkResult
|
|
||||||
if len(stale) > 0 {
|
|
||||||
results = append(results, checkResult{catCompositor, "Mango config keys", statusError,
|
|
||||||
fmt.Sprintf("Not %s keys: %s", dialect, strings.Join(stale, ", ")),
|
|
||||||
"This Mango rejects those lines as unknown keywords. Run `dms config mango-migrate --main` (config.conf is backed up).", url})
|
|
||||||
}
|
|
||||||
if len(overviewBinds) > 0 {
|
if len(overviewBinds) > 0 {
|
||||||
results = append(results, checkResult{catCompositor, "Mango mouse binds", statusWarn,
|
results = append(results, checkResult{catCompositor, "Mango mouse binds", statusWarn,
|
||||||
"Obsolete overview binds in " + strings.Join(overviewBinds, ", "),
|
"Obsolete overview binds in " + strings.Join(overviewBinds, ", "),
|
||||||
@@ -967,6 +1043,10 @@ func hyprlandLacksLua(ver string) bool {
|
|||||||
return ver != "" && version.CompareVersions(ver, "0.55.0") < 0
|
return ver != "" && version.CompareVersions(ver, "0.55.0") < 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mangoLacksSnakeCase(ver string) bool {
|
||||||
|
return ver != "" && version.CompareVersions(ver, "0.18.0") < 0
|
||||||
|
}
|
||||||
|
|
||||||
func firstSubmatch(re *regexp.Regexp) func(string) string {
|
func firstSubmatch(re *regexp.Regexp) func(string) string {
|
||||||
return func(s string) string {
|
return func(s string) string {
|
||||||
if m := re.FindStringSubmatch(s); len(m) > 1 {
|
if m := re.FindStringSubmatch(s); len(m) > 1 {
|
||||||
@@ -1712,6 +1792,9 @@ var bundledFontRelPaths = map[string][]string{
|
|||||||
"notable": {
|
"notable": {
|
||||||
"DCommon/assets/fonts/notable/Notable-Regular.ttf",
|
"DCommon/assets/fonts/notable/Notable-Regular.ttf",
|
||||||
},
|
},
|
||||||
|
"gochi hand": {
|
||||||
|
"DCommon/assets/fonts/gochi-hand/GochiHand-Regular.ttf",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveDoctorShellPath() string {
|
func resolveDoctorShellPath() string {
|
||||||
@@ -1746,22 +1829,47 @@ func isBundledDefaultFont(family string) bool {
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func fontInFcList(name, cacheLower string) bool {
|
const fcListQtFormat = "%{family}\t%{style}\t%{foundry}\n"
|
||||||
target := strings.ToLower(strings.TrimSpace(name))
|
|
||||||
if target == "" {
|
var qtGenericFamilies = map[string]bool{"sans serif": true, "serif": true, "monospace": true}
|
||||||
return false
|
|
||||||
|
func qtFontFamilies() (map[string]bool, error) {
|
||||||
|
output, err := exec.Command("fc-list", "--format", fcListQtFormat).Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
for line := range strings.SplitSeq(cacheLower, "\n") {
|
return parseQtFontFamilies(string(output)), nil
|
||||||
for fam := range strings.SplitSeq(strings.TrimSpace(line), ",") {
|
|
||||||
if strings.TrimSpace(fam) == target {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkConfiguredFont(label, family, shellPath, fcCache string, fcListAvailable bool, url string) checkResult {
|
// parseQtFontFamilies retrieves, from the provided fcOutput list, a new map in the style of Qt.fontFamilies()
|
||||||
|
func parseQtFontFamilies(fcOutput string) map[string]bool {
|
||||||
|
families := map[string]bool{}
|
||||||
|
for line := range strings.SplitSeq(fcOutput, "\n") {
|
||||||
|
fields := strings.Split(line, "\t")
|
||||||
|
if len(fields) != 3 || fields[0] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
styleCount := len(strings.Split(fields[1], ","))
|
||||||
|
for i, fam := range strings.Split(fields[0], ",") {
|
||||||
|
if i > 0 && i >= styleCount {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
families[strings.ToLower(fam)] = true
|
||||||
|
// Qt only lists "Family [Foundry]" for multi-foundry families but resolves it either way
|
||||||
|
if fields[2] != "" {
|
||||||
|
families[strings.ToLower(fam+" ["+fields[2]+"]")] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return families
|
||||||
|
}
|
||||||
|
|
||||||
|
func fontInFcList(name string, families map[string]bool) bool {
|
||||||
|
target := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
return qtGenericFamilies[target] || families[target]
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkConfiguredFont(label, family, shellPath string, fcFamilies map[string]bool, fcListAvailable bool, url string) checkResult {
|
||||||
if bundled := findBundledFontFile(shellPath, family); bundled != "" {
|
if bundled := findBundledFontFile(shellPath, family); bundled != "" {
|
||||||
details := "Bundled (Qt FontLoader)"
|
details := "Bundled (Qt FontLoader)"
|
||||||
if doctorVerbose {
|
if doctorVerbose {
|
||||||
@@ -1796,7 +1904,7 @@ func checkConfiguredFont(label, family, shellPath, fcCache string, fcListAvailab
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if fcCache == "" {
|
if len(fcFamilies) == 0 {
|
||||||
return checkResult{
|
return checkResult{
|
||||||
catFonts, label, statusWarn,
|
catFonts, label, statusWarn,
|
||||||
fmt.Sprintf("'%s' not found", family),
|
fmt.Sprintf("'%s' not found", family),
|
||||||
@@ -1805,7 +1913,7 @@ func checkConfiguredFont(label, family, shellPath, fcCache string, fcListAvailab
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if fontInFcList(family, fcCache) {
|
if fontInFcList(family, fcFamilies) {
|
||||||
return checkResult{catFonts, label, statusOK, family, "Available via fontconfig", url}
|
return checkResult{catFonts, label, statusOK, family, "Available via fontconfig", url}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1851,18 +1959,18 @@ func checkFonts() []checkResult {
|
|||||||
needFontconfig := !isBundledDefaultFont(fontFamily) || !isBundledDefaultFont(monoFontFamily) || !isBundledDefaultFont(displayFontFamily)
|
needFontconfig := !isBundledDefaultFont(fontFamily) || !isBundledDefaultFont(monoFontFamily) || !isBundledDefaultFont(displayFontFamily)
|
||||||
|
|
||||||
fcListAvailable := utils.CommandExists("fc-list")
|
fcListAvailable := utils.CommandExists("fc-list")
|
||||||
fcCache := ""
|
var fcFamilies map[string]bool
|
||||||
|
|
||||||
if needFontconfig {
|
if needFontconfig {
|
||||||
if !fcListAvailable {
|
if !fcListAvailable {
|
||||||
results = append(results, checkResult{catFonts, "Fontconfig Tools", statusWarn, "fc-list not installed", "Cannot verify custom fonts in fontconfig cache.", url})
|
results = append(results, checkResult{catFonts, "Fontconfig Tools", statusWarn, "fc-list not installed", "Cannot verify custom fonts in fontconfig cache.", url})
|
||||||
} else {
|
} else {
|
||||||
output, err := exec.Command("fc-list", ":", "family").Output()
|
families, err := qtFontFamilies()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Failed to query font list", "Fontconfig cache query failed. Try running 'fc-cache -fv'.", url})
|
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Failed to query font list", "Fontconfig cache query failed. Try running 'fc-cache -fv'.", url})
|
||||||
} else {
|
} else {
|
||||||
fcCache = strings.ToLower(string(output))
|
fcFamilies = families
|
||||||
if len(strings.TrimSpace(fcCache)) == 0 {
|
if len(fcFamilies) == 0 {
|
||||||
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Cache is empty", "No fonts found in fontconfig cache. Try running 'fc-cache -fv'.", url})
|
results = append(results, checkResult{catFonts, "Fontconfig Cache", statusError, "Cache is empty", "No fonts found in fontconfig cache. Try running 'fc-cache -fv'.", url})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1870,9 +1978,9 @@ func checkFonts() []checkResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
results = append(results,
|
results = append(results,
|
||||||
checkConfiguredFont("Normal Font", fontFamily, shellPath, fcCache, fcListAvailable, url),
|
checkConfiguredFont("Normal Font", fontFamily, shellPath, fcFamilies, fcListAvailable, url),
|
||||||
checkConfiguredFont("Monospace Font", monoFontFamily, shellPath, fcCache, fcListAvailable, url),
|
checkConfiguredFont("Monospace Font", monoFontFamily, shellPath, fcFamilies, fcListAvailable, url),
|
||||||
checkConfiguredFont("Display Font", displayFontFamily, shellPath, fcCache, fcListAvailable, url),
|
checkConfiguredFont("Display Font", displayFontFamily, shellPath, fcFamilies, fcListAvailable, url),
|
||||||
)
|
)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|||||||
@@ -3,26 +3,57 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCheckBundledGoogleSans(t *testing.T) {
|
func TestCheckBundledFonts(t *testing.T) {
|
||||||
|
for family, relPaths := range bundledFontRelPaths {
|
||||||
|
t.Run(family, func(t *testing.T) {
|
||||||
shellPath := t.TempDir()
|
shellPath := t.TempDir()
|
||||||
fontPath := filepath.Join(shellPath, "DCommon", "assets", "fonts", "google-sans-flex", "GoogleSansFlex.ttf")
|
if missing := checkConfiguredFont("Font", family, shellPath, nil, false, ""); missing.status != statusWarn {
|
||||||
missing := checkConfiguredFont("UI font", "Google Sans Flex", shellPath, "", false, "")
|
|
||||||
if missing.status != statusWarn {
|
|
||||||
t.Fatalf("missing bundled font status = %v, want %v", missing.status, statusWarn)
|
t.Fatalf("missing bundled font status = %v, want %v", missing.status, statusWarn)
|
||||||
}
|
}
|
||||||
|
fontPath := filepath.Join(shellPath, relPaths[0])
|
||||||
if err := os.MkdirAll(filepath.Dir(fontPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(fontPath), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(fontPath, []byte("font"), 0o644); err != nil {
|
if err := os.WriteFile(fontPath, []byte("font"), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, family := range []string{"Google Sans Flex", " google sans flex "} {
|
for _, name := range []string{family, " " + strings.ToUpper(family) + " "} {
|
||||||
result := checkConfiguredFont("UI font", family, shellPath, "", false, "")
|
if result := checkConfiguredFont("Font", name, shellPath, nil, false, ""); result.status != statusOK {
|
||||||
if result.status != statusOK {
|
t.Fatalf("bundled font %q status = %v, want %v", name, result.status, statusOK)
|
||||||
t.Fatalf("bundled font %q status = %v, want %v", family, result.status, statusOK)
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQtFontFamilies(t *testing.T) {
|
||||||
|
fcOutput := "RobotoMono Nerd Font\tBold\tGOOG\n" +
|
||||||
|
"RobotoMono Nerd Font\tBold Italic\tpyrs\n" +
|
||||||
|
"Nimbus Sans\tRegular\tURW \n" +
|
||||||
|
"Nimbus Sans\tBold\tUKWN\n" +
|
||||||
|
"Plex,Plex Lt\tLight,Regular\tIBM\n" +
|
||||||
|
"Mono,Mono NF,Mono NF Light\tLight,Regular\tNONE\n"
|
||||||
|
families := parseQtFontFamilies(fcOutput)
|
||||||
|
|
||||||
|
cases := map[string]bool{
|
||||||
|
"RobotoMono Nerd Font [GOOG]": true,
|
||||||
|
"robotomono nerd font [pyrs]": true,
|
||||||
|
"RobotoMono Nerd Font": true,
|
||||||
|
"Nimbus Sans [URW ]": true,
|
||||||
|
"Plex Lt": true,
|
||||||
|
"Plex [IBM]": true,
|
||||||
|
"Mono NF": true,
|
||||||
|
"Mono NF Light": false,
|
||||||
|
"Monospace": true,
|
||||||
|
"Missing Font [GOOG]": false,
|
||||||
|
}
|
||||||
|
for name, want := range cases {
|
||||||
|
if got := fontInFcList(name, families); got != want {
|
||||||
|
t.Errorf("fontInFcList(%q) = %v, want %v", name, got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,8 +8,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestQuickshellVersionFailureDetails(t *testing.T) {
|
func TestQuickshellVersionFailureDetails(t *testing.T) {
|
||||||
@@ -385,19 +383,41 @@ func TestCheckXDGMenuFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckMangoConfigFlagsWrongDialectAndOverviewBinds(t *testing.T) {
|
func TestCheckMangoConfigReportsUnknownKeysAndOverviewBinds(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
if err := os.MkdirAll(filepath.Join(dir, "dms"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(dir, "dms"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_ = os.WriteFile(filepath.Join(dir, "config.conf"), []byte("exec-once=dms run\nmousebind=NONE,btn_left,toggleoverview,1\n"), 0o644)
|
_ = os.WriteFile(filepath.Join(dir, "config.conf"), []byte("exec-once=dms run\nmousebind=NONE,btn_left,toggleoverview,1\n"), 0o644)
|
||||||
_ = os.WriteFile(filepath.Join(dir, "dms", "layout.conf"), []byte("border_px=2\n"), 0o644)
|
// Shape of `mango -p` 0.18.2 output, colours included; the same key twice collapses.
|
||||||
|
parsed := "\x1b[1m\x1b[31m[ERROR]:\x1b[0m Unknown keyword: \x1b[1m\x1b[31mexec-once\x1b[0m\n" +
|
||||||
|
"\x1b[1;31m╰─\x1b[1;33m[Index]\x1b[0m \x1b[1;36m" + dir + "/config.conf\x1b[0m:\x1b[1;35m1\x1b[0m\n ╰─exec-once=dms run\n\n" +
|
||||||
|
"[ERROR]: Unknown keyword: overviewgappo\n╰─[Index] " + dir + "/dms/layout.conf:10\n ╰─overviewgappo=30\n" +
|
||||||
|
"[ERROR]: Unknown keyword: overviewgappo\n╰─[Index] " + dir + "/dms/layout.conf:12\n"
|
||||||
|
prev := mangoParseConfig
|
||||||
|
mangoParseConfig = func(string) (string, error) { return parsed, nil }
|
||||||
|
t.Cleanup(func() { mangoParseConfig = prev })
|
||||||
|
|
||||||
if got := checkMangoConfig(dir, mangoconf.Legacy); len(got) != 2 || got[0].message != "Not legacy keys: layout.conf" {
|
got := checkMangoConfig(dir)
|
||||||
t.Fatalf("legacy: %+v", got)
|
if len(got) != 2 || got[0].message != "Unknown keys: exec-once (config.conf), overviewgappo (layout.conf)" || got[1].name != "Mango mouse binds" {
|
||||||
|
t.Fatalf("got %+v", got)
|
||||||
|
}
|
||||||
|
mangoParseConfig = func(string) (string, error) { return "", os.ErrNotExist }
|
||||||
|
if got := checkMangoConfig(dir); len(got) != 1 {
|
||||||
|
t.Fatalf("without mango on PATH only the bind check should run, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMangoLacksSnakeCase(t *testing.T) {
|
||||||
|
for ver, want := range map[string]bool{
|
||||||
|
"0.17.5": true,
|
||||||
|
"0.18.0": false,
|
||||||
|
"0.18.2": false,
|
||||||
|
"": false,
|
||||||
|
} {
|
||||||
|
if got := mangoLacksSnakeCase(ver); got != want {
|
||||||
|
t.Errorf("mangoLacksSnakeCase(%q) = %v, want %v", ver, got, want)
|
||||||
}
|
}
|
||||||
if got := checkMangoConfig(dir, mangoconf.Snake); len(got) != 2 || got[0].message != "Not snake keys: config.conf" {
|
|
||||||
t.Fatalf("snake: %+v", got)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ func runUpdate() {
|
|||||||
log.Fatalf("Error detecting OS: %v", err)
|
log.Fatalf("Error detecting OS: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if osInfo.Immutable {
|
||||||
|
log.Fatalf("%s is an image-based system (%s). dms lives in the read-only image; update it with 'dms system update' or your image tooling (rpm-ostree, bootc, transactional-update).", osInfo.PrettyName, osInfo.ImmutableReason)
|
||||||
|
}
|
||||||
|
|
||||||
config, exists := distros.Registry[osInfo.Distribution.ID]
|
config, exists := distros.Registry[osInfo.Distribution.ID]
|
||||||
if !exists {
|
if !exists {
|
||||||
log.Fatalf("Unsupported distribution: %s", osInfo.Distribution.ID)
|
log.Fatalf("Unsupported distribution: %s", osInfo.Distribution.ID)
|
||||||
@@ -124,16 +128,14 @@ func updateArchLinux() error {
|
|||||||
|
|
||||||
var packageName string
|
var packageName string
|
||||||
var isAUR bool
|
var isAUR bool
|
||||||
if isArchPackageInstalled("dms-shell") {
|
// AUR name first: pacman -Q matches provides, and dux-shell-git provides dux-shell.
|
||||||
packageName = "dms-shell"
|
if isArchPackageInstalled("dux-shell-git") {
|
||||||
} else if isArchPackageInstalled("dms-shell-git") {
|
packageName = "dux-shell-git"
|
||||||
packageName = "dms-shell-git"
|
|
||||||
isAUR = true
|
|
||||||
} else if isArchPackageInstalled("dms-shell-bin") {
|
|
||||||
packageName = "dms-shell-bin"
|
|
||||||
isAUR = true
|
isAUR = true
|
||||||
|
} else if isArchPackageInstalled("dux-shell") {
|
||||||
|
packageName = "dux-shell"
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("Info: No dms-shell package found.")
|
fmt.Println("Info: No dux-shell package found.")
|
||||||
fmt.Println("Info: Falling back to git-based update method...")
|
fmt.Println("Info: Falling back to git-based update method...")
|
||||||
return updateOtherDistros()
|
return updateOtherDistros()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ var greeterCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func greeterRepoURL(config distros.DistroConfig) string {
|
||||||
|
return config.Constructor(config, nil).GetPackageMapping(0)["dms-greeter"].RepoURL
|
||||||
|
}
|
||||||
|
|
||||||
func isLegacyWrapperScript(path string) bool {
|
func isLegacyWrapperScript(path string) bool {
|
||||||
file, err := os.Open(path)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,13 +67,14 @@ func greeterPackageInstallHint() string {
|
|||||||
case distros.FamilySUSE:
|
case distros.FamilySUSE:
|
||||||
return "Install with 'sudo zypper install dms-greeter' (requires Dux Labs OBS repo — see " + site.Docs + "/dms-greeter/installation#opensuse--derivatives)"
|
return "Install with 'sudo zypper install dms-greeter' (requires Dux Labs OBS repo — see " + site.Docs + "/dms-greeter/installation#opensuse--derivatives)"
|
||||||
case distros.FamilyUbuntu:
|
case distros.FamilyUbuntu:
|
||||||
return "Install with 'sudo apt install dms-greeter' (requires ppa:avengemedia/danklinux: sudo add-apt-repository ppa:avengemedia/danklinux)"
|
repo := greeterRepoURL(config)
|
||||||
|
return fmt.Sprintf("Install with 'sudo apt install dms-greeter' (requires %s: sudo add-apt-repository %s)", repo, repo)
|
||||||
case distros.FamilyFedora:
|
case distros.FamilyFedora:
|
||||||
return "Install with 'sudo dnf install dms-greeter' (requires COPR: sudo dnf copr enable avengemedia/danklinux)"
|
return fmt.Sprintf("Install with 'sudo dnf install dms-greeter' (requires COPR: sudo dnf copr enable %s)", greeterRepoURL(config))
|
||||||
case distros.FamilyArch:
|
case distros.FamilyArch:
|
||||||
return "Install from AUR with 'paru -S greetd-dms-greeter-bin' or 'yay -S greetd-dms-greeter-bin'"
|
return "Install from AUR with 'paru -S dux-greeter-bin' or 'yay -S dux-greeter-bin'"
|
||||||
case distros.FamilyVoid:
|
case distros.FamilyVoid:
|
||||||
return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=" + distros.VoidDMSRepo + "' | sudo tee /etc/xbps.d/dms.conf)"
|
return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=" + distros.VoidDepsRepo + "' | sudo tee /etc/xbps.d/dux.conf)"
|
||||||
default:
|
default:
|
||||||
return "Install the dms-greeter package for your distribution"
|
return "Install the dms-greeter package for your distribution"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/distros"
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
const migrateDuxCommand = "dms migrate dux"
|
||||||
|
|
||||||
|
var (
|
||||||
|
migrateDryRun bool
|
||||||
|
migrateAssumeYes bool
|
||||||
|
)
|
||||||
|
|
||||||
|
var migrateCmd = &cobra.Command{
|
||||||
|
Use: "migrate",
|
||||||
|
Short: "One-time migrations for existing installs",
|
||||||
|
}
|
||||||
|
|
||||||
|
var migrateDuxCmd = &cobra.Command{
|
||||||
|
Use: "dux",
|
||||||
|
Short: "Move DMS package repositories to the Dux Labs owner",
|
||||||
|
Long: `Move the DMS package repositories from the legacy AvengeMedia owner to Dux Labs.
|
||||||
|
|
||||||
|
Adds the Dux Labs repository for every legacy one found, then removes the legacy
|
||||||
|
one. Installed packages are not changed; the next update comes from the new repo.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if err := runMigrateDux(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
migrateDuxCmd.Flags().BoolVar(&migrateDryRun, "dry-run", false, "Print the commands without running them")
|
||||||
|
migrateDuxCmd.Flags().BoolVarP(&migrateAssumeYes, "yes", "y", false, "Do not ask for confirmation")
|
||||||
|
migrateCmd.AddCommand(migrateDuxCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMigrateDux() error {
|
||||||
|
osInfo, err := distros.GetOSInfo()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("detecting distribution: %w", err)
|
||||||
|
}
|
||||||
|
distro, err := distros.NewDistribution(osInfo.Distribution.ID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
moves, ok := distros.PlanRepoMoves(distro)
|
||||||
|
if !ok {
|
||||||
|
fmt.Printf("Nothing to migrate on %s.\n", osInfo.PrettyName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
mover := distro.(distros.RepoMover)
|
||||||
|
commands := mover.RepoMoveCommands(moves)
|
||||||
|
if len(commands) == 0 {
|
||||||
|
fmt.Println("Already migrated.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if osInfo.Immutable {
|
||||||
|
imageMover, ok := distro.(distros.ImageRepoMover)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%s is an image-based system; move the repositories with your image tooling", osInfo.PrettyName)
|
||||||
|
}
|
||||||
|
printRepoMoves(moves, imageMover.ImageRepoMoveCommands(moves))
|
||||||
|
fmt.Printf("\n%s is image-based, so run these yourself.\n", osInfo.PrettyName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
printRepoMoves(moves, commands)
|
||||||
|
if migrateDryRun {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !migrateAssumeYes && !askYes("\nContinue? [y/N] ") {
|
||||||
|
return errors.New("cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if privesc.CheckCached(ctx) != nil {
|
||||||
|
if err := privesc.Run(ctx, "", "true"); err != nil {
|
||||||
|
return fmt.Errorf("authentication failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = runWithProgressOutput(func(progressChan chan<- distros.InstallProgressMsg) error {
|
||||||
|
return mover.ApplyRepoMoves(ctx, moves, "", progressChan)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("Done. 'dms doctor' shows the result; the shell notice clears on its next start.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printRepoMoves(moves []distros.RepoMove, commands []string) {
|
||||||
|
fmt.Println("Repositories to move:")
|
||||||
|
for _, move := range moves {
|
||||||
|
switch {
|
||||||
|
case move.OldPresent:
|
||||||
|
fmt.Printf(" %s → %s\n", move.Old, move.New)
|
||||||
|
case !move.NewPresent && move.NewPath != "":
|
||||||
|
fmt.Printf(" add %s\n", move.New)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println("\nCommands:")
|
||||||
|
for _, command := range commands {
|
||||||
|
fmt.Printf(" %s\n", command)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/distros"
|
||||||
|
)
|
||||||
|
|
||||||
|
type repoMoverFake struct {
|
||||||
|
distros.Distribution
|
||||||
|
moves []distros.RepoMove
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *repoMoverFake) RepoMoves() []distros.RepoMove { return f.moves }
|
||||||
|
func (f *repoMoverFake) ApplyRepoMoves(context.Context, []distros.RepoMove, string, chan<- distros.InstallProgressMsg) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *repoMoverFake) RepoMoveCommands([]distros.RepoMove) []string { return nil }
|
||||||
|
|
||||||
|
type plainDistroFake struct{ distros.Distribution }
|
||||||
|
|
||||||
|
func TestRunWithProgressOutputPrintsRepeatedLinesOnce(t *testing.T) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
err := runWithProgressOutputTo(&out, func(ch chan<- distros.InstallProgressMsg) error {
|
||||||
|
ch <- distros.InstallProgressMsg{Step: "Removing COPR repo...", LogOutput: "Repository successfully removed."}
|
||||||
|
ch <- distros.InstallProgressMsg{Step: "Removing COPR repo...", LogOutput: "Repository successfully removed."}
|
||||||
|
ch <- distros.InstallProgressMsg{Step: "Removing COPR repo...", LogOutput: "Repository successfully removed."}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := "Removing COPR repo...\n Repository successfully removed.\n"
|
||||||
|
if out.String() != want {
|
||||||
|
t.Fatalf("got %q, want %q", out.String(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckPackageRepos(t *testing.T) {
|
||||||
|
legacy := distros.RepoMove{Kind: distros.RepoTypeCOPR, Old: "avengemedia/dms", New: "duxlabs/dms", OldPath: "/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:avengemedia:dms.repo", OldPresent: true}
|
||||||
|
legacyDeps := distros.RepoMove{Kind: distros.RepoTypeCOPR, Old: "avengemedia/danklinux", New: "duxlabs/dux-repo", OldPath: "/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:avengemedia:danklinux.repo", OldPresent: true}
|
||||||
|
current := distros.RepoMove{Kind: distros.RepoTypeCOPR, Old: "avengemedia/dms", New: "duxlabs/dms", NewPresent: true}
|
||||||
|
absent := distros.RepoMove{Kind: distros.RepoTypeCOPR, Old: "avengemedia/dms", New: "duxlabs/dms"}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
moves []distros.RepoMove
|
||||||
|
status status
|
||||||
|
message string
|
||||||
|
}{
|
||||||
|
{"legacy", []distros.RepoMove{legacy, legacyDeps, current}, statusWarn, "Legacy repositories, run: dms migrate dux"},
|
||||||
|
{"current", []distros.RepoMove{current, absent}, statusOK, "Current"},
|
||||||
|
{"none", []distros.RepoMove{absent}, statusInfo, "No DMS package repositories"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
results := packageReposResult(&repoMoverFake{moves: tc.moves})
|
||||||
|
if len(results) != 1 || results[0].status != tc.status || results[0].message != tc.message {
|
||||||
|
t.Fatalf("got %+v, want %v %q", results, tc.status, tc.message)
|
||||||
|
}
|
||||||
|
if tc.status == statusWarn && results[0].details != legacy.OldPath+", "+legacyDeps.OldPath {
|
||||||
|
t.Fatalf("details = %q, want the legacy paths on one line", results[0].details)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
voidConf := "/etc/xbps.d/custom.conf"
|
||||||
|
voidMoves := []distros.RepoMove{
|
||||||
|
{Kind: distros.RepoTypeXBPS, Old: "https://void.danklinux.com/dms/current", New: "https://void.duxlabs.dev/dms/current", OldPath: voidConf, NewPath: voidConf, OldPresent: true},
|
||||||
|
{Kind: distros.RepoTypeXBPS, Old: "https://void.danklinux.com/danklinux/current", New: "https://void.duxlabs.dev/dux-repo/current", OldPath: voidConf, NewPath: voidConf, OldPresent: true},
|
||||||
|
}
|
||||||
|
if results := packageReposResult(&repoMoverFake{moves: voidMoves}); len(results) != 1 || results[0].status != statusWarn || results[0].details != voidConf {
|
||||||
|
t.Fatalf("got %+v, want one warning naming %s once", results, voidConf)
|
||||||
|
}
|
||||||
|
|
||||||
|
if results := packageReposResult(&plainDistroFake{}); results != nil {
|
||||||
|
t.Fatalf("printed %+v on a family without repo moves", results)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,7 +130,7 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
|
|||||||
mangoFile: "layout.conf",
|
mangoFile: "layout.conf",
|
||||||
niriContent: func(_ string) string { return config.NiriLayoutConfig },
|
niriContent: func(_ string) string { return config.NiriLayoutConfig },
|
||||||
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
|
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
|
||||||
mangoContent: func(_ string) string { return mangoconf.Detect().Translate(config.MangoLayoutConfig) },
|
mangoContent: func(_ string) string { return config.MangoLayoutConfig },
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
niriFile: "colors.kdl",
|
niriFile: "colors.kdl",
|
||||||
@@ -138,7 +138,7 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
|
|||||||
mangoFile: "colors.conf",
|
mangoFile: "colors.conf",
|
||||||
niriContent: func(_ string) string { return config.NiriColorsConfig },
|
niriContent: func(_ string) string { return config.NiriColorsConfig },
|
||||||
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
|
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
|
||||||
mangoContent: func(_ string) string { return mangoconf.Detect().Translate(config.MangoColorsConfig) },
|
mangoContent: func(_ string) string { return config.MangoColorsConfig },
|
||||||
},
|
},
|
||||||
"alttab": {
|
"alttab": {
|
||||||
niriFile: "alttab.kdl",
|
niriFile: "alttab.kdl",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -142,7 +143,7 @@ func runSwitch(channel string, packages []string) error {
|
|||||||
if shellChanges {
|
if shellChanges {
|
||||||
fmt.Println("The shell restarts when the switch is done.")
|
fmt.Println("The shell restarts when the switch is done.")
|
||||||
}
|
}
|
||||||
if !switchAssumeYes && !askSwitch("\nContinue? [y/N] ") {
|
if !switchAssumeYes && !askYes("\nContinue? [y/N] ") {
|
||||||
return errors.New("cancelled")
|
return errors.New("cancelled")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +172,7 @@ func runSwitch(channel string, packages []string) error {
|
|||||||
if snapshotLabel == "beta" && !switchAssumeYes {
|
if snapshotLabel == "beta" && !switchAssumeYes {
|
||||||
if path, ok := backup.LatestSnapshot("stable"); ok {
|
if path, ok := backup.LatestSnapshot("stable"); ok {
|
||||||
fmt.Printf("\nSettings saved by beta may lose options on stable. Restore the backup from your last\nstable run (%s)? Your beta settings stay in the beta backup. [y/N] ", path)
|
fmt.Printf("\nSettings saved by beta may lose options on stable. Restore the backup from your last\nstable run (%s)? Your beta settings stay in the beta backup. [y/N] ", path)
|
||||||
if askSwitch("") {
|
if askYes("") {
|
||||||
restore = path
|
restore = path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,28 +186,39 @@ func runSwitch(channel string, packages []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runSwaps(ctx context.Context, swapper distros.VariantSwapper, swaps []distros.VariantSwap) error {
|
func runSwaps(ctx context.Context, swapper distros.VariantSwapper, swaps []distros.VariantSwap) error {
|
||||||
|
return runWithProgressOutput(func(progressChan chan<- distros.InstallProgressMsg) error {
|
||||||
|
return swapper.SwapVariants(ctx, swaps, "", progressChan)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWithProgressOutput(run func(chan<- distros.InstallProgressMsg) error) error {
|
||||||
|
return runWithProgressOutputTo(os.Stdout, run)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWithProgressOutputTo(out io.Writer, run func(chan<- distros.InstallProgressMsg) error) error {
|
||||||
progressChan := make(chan distros.InstallProgressMsg, 100)
|
progressChan := make(chan distros.InstallProgressMsg, 100)
|
||||||
swapErr := make(chan error, 1)
|
runErr := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
defer close(progressChan)
|
defer close(progressChan)
|
||||||
swapErr <- swapper.SwapVariants(ctx, swaps, "", progressChan)
|
runErr <- run(progressChan)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// runWithProgress repeats the step on every output line, so only print it when it changes.
|
// runWithProgress repeats the step and the last output line on every tick, so only print changes.
|
||||||
lastStep := ""
|
lastStep, lastLog := "", ""
|
||||||
for msg := range progressChan {
|
for msg := range progressChan {
|
||||||
switch {
|
switch {
|
||||||
case msg.Error != nil:
|
case msg.Error != nil:
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error)
|
fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error)
|
||||||
case msg.Step != "" && msg.Step != lastStep:
|
case msg.Step != "" && msg.Step != lastStep:
|
||||||
fmt.Println(msg.Step)
|
fmt.Fprintln(out, msg.Step)
|
||||||
lastStep = msg.Step
|
lastStep = msg.Step
|
||||||
}
|
}
|
||||||
if msg.LogOutput != "" {
|
if msg.LogOutput != "" && msg.LogOutput != lastLog {
|
||||||
fmt.Printf(" %s\n", msg.LogOutput)
|
fmt.Fprintf(out, " %s\n", msg.LogOutput)
|
||||||
|
lastLog = msg.LogOutput
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return <-swapErr
|
return <-runErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// restartShellAfterSwitch stops the shell before restoring, or the running one would migrate the
|
// restartShellAfterSwitch stops the shell before restoring, or the running one would migrate the
|
||||||
@@ -268,7 +280,7 @@ func switchLabel(m distros.PackageMapping) string {
|
|||||||
return m.RepoURL + "/" + m.Name
|
return m.RepoURL + "/" + m.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
func askSwitch(prompt string) bool {
|
func askYes(prompt string) bool {
|
||||||
if !stdinIsTTY() {
|
if !stdinIsTTY() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -10,6 +9,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
|
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/distros"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
|
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -97,97 +97,13 @@ func mergePolicyFile(base *immutableCommandPolicy, path string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readOSReleaseMap(path string) map[string]string {
|
|
||||||
values := make(map[string]string)
|
|
||||||
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts := strings.SplitN(line, "=", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := strings.ToUpper(strings.TrimSpace(parts[0]))
|
|
||||||
value := strings.Trim(strings.TrimSpace(parts[1]), "\"")
|
|
||||||
values[key] = strings.ToLower(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasAnyToken(text string, tokens ...string) bool {
|
|
||||||
if text == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, token := range tokens {
|
|
||||||
if strings.Contains(text, token) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func detectImmutableSystem() (bool, string) {
|
|
||||||
if _, err := os.Stat("/run/ostree-booted"); err == nil {
|
|
||||||
return true, "/run/ostree-booted is present"
|
|
||||||
}
|
|
||||||
|
|
||||||
osRelease := readOSReleaseMap("/etc/os-release")
|
|
||||||
if len(osRelease) == 0 {
|
|
||||||
return false, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
id := osRelease["ID"]
|
|
||||||
idLike := osRelease["ID_LIKE"]
|
|
||||||
variantID := osRelease["VARIANT_ID"]
|
|
||||||
name := osRelease["NAME"]
|
|
||||||
prettyName := osRelease["PRETTY_NAME"]
|
|
||||||
|
|
||||||
immutableIDs := map[string]bool{
|
|
||||||
"bluefin": true,
|
|
||||||
"bazzite": true,
|
|
||||||
"silverblue": true,
|
|
||||||
"kinoite": true,
|
|
||||||
"sericea": true,
|
|
||||||
"onyx": true,
|
|
||||||
"aurora": true,
|
|
||||||
"fedora-iot": true,
|
|
||||||
"fedora-coreos": true,
|
|
||||||
}
|
|
||||||
if immutableIDs[id] {
|
|
||||||
return true, "os-release ID=" + id
|
|
||||||
}
|
|
||||||
|
|
||||||
markers := []string{"silverblue", "kinoite", "sericea", "onyx", "bazzite", "bluefin", "aurora", "ostree", "atomic"}
|
|
||||||
if hasAnyToken(variantID, markers...) {
|
|
||||||
return true, "os-release VARIANT_ID=" + variantID
|
|
||||||
}
|
|
||||||
if hasAnyToken(idLike, "ostree", "rpm-ostree") {
|
|
||||||
return true, "os-release ID_LIKE=" + idLike
|
|
||||||
}
|
|
||||||
if hasAnyToken(name, markers...) || hasAnyToken(prettyName, markers...) {
|
|
||||||
return true, "os-release identifies an atomic/ostree variant"
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func getImmutablePolicy() (*immutableCommandPolicy, error) {
|
func getImmutablePolicy() (*immutableCommandPolicy, error) {
|
||||||
immutablePolicyOnce.Do(func() {
|
immutablePolicyOnce.Do(func() {
|
||||||
detectedImmutable, reason := detectImmutableSystem()
|
detectedImmutable, reason := distros.DetectImmutable()
|
||||||
immutablePolicy = immutableCommandPolicy{
|
immutablePolicy = immutableCommandPolicy{
|
||||||
ImmutableSystem: detectedImmutable,
|
ImmutableSystem: detectedImmutable,
|
||||||
ImmutableReason: reason,
|
ImmutableReason: reason,
|
||||||
BlockedCommands: []string{"greeter install", "greeter enable", "setup"},
|
BlockedCommands: nil,
|
||||||
Message: "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes.",
|
Message: "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
|
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDefaultImmutablePolicyAllowsSyncButBlocksEnable(t *testing.T) {
|
// The greeter commands forward to dms-greeter, which applies its own policy;
|
||||||
|
// a packaged /usr/share/dms/cli-policy.json can still block them here.
|
||||||
|
func TestDefaultImmutablePolicyBlocksNothing(t *testing.T) {
|
||||||
var policyFile clipolicy.File
|
var policyFile clipolicy.File
|
||||||
if err := json.Unmarshal(defaultCLIPolicyJSON, &policyFile); err != nil {
|
if err := json.Unmarshal(defaultCLIPolicyJSON, &policyFile); err != nil {
|
||||||
t.Fatalf("failed to parse embedded CLI policy: %v", err)
|
t.Fatalf("failed to parse embedded CLI policy: %v", err)
|
||||||
@@ -17,10 +19,12 @@ func TestDefaultImmutablePolicyAllowsSyncButBlocksEnable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
blocked := normalizeBlockedCommands(*policyFile.BlockedCommands)
|
blocked := normalizeBlockedCommands(*policyFile.BlockedCommands)
|
||||||
if !commandBlockedByPolicy("greeter enable", blocked) {
|
for _, command := range []string{"greeter install", "greeter enable", "greeter uninstall", "greeter sync"} {
|
||||||
t.Fatal("expected greeter enable to remain blocked on immutable/image-based systems")
|
if commandBlockedByPolicy(command, blocked) {
|
||||||
|
t.Fatalf("%s must be available by default on image-based systems", command)
|
||||||
}
|
}
|
||||||
if commandBlockedByPolicy("greeter sync", blocked) {
|
}
|
||||||
t.Fatal("expected greeter sync to remain available on immutable/image-based systems")
|
if commandBlockedByPolicy("setup", blocked) {
|
||||||
|
t.Fatal("setup only writes to the home directory and must stay available on image-based systems")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,17 +2,21 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/duxlabs-dev/dux-go/shellapp"
|
"github.com/duxlabs-dev/dux-go/shellapp"
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/config"
|
"github.com/duxlabs-dev/dux-shell/core/internal/config"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/gpu"
|
"github.com/duxlabs-dev/dux-shell/core/internal/gpu"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/server"
|
"github.com/duxlabs-dev/dux-shell/core/internal/server"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/shellembed"
|
"github.com/duxlabs-dev/dux-shell/core/internal/shellembed"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
||||||
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
var shellApp = shellapp.New(shellapp.Config{
|
var shellApp = shellapp.New(shellapp.Config{
|
||||||
@@ -83,18 +87,47 @@ func preLaunch() {
|
|||||||
ensureFontCache()
|
ensureFontCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The elapsed-timer driver keeps the scrolling wallpaper spring in step above 60 Hz,
|
||||||
|
// but it makes every other animation jump after a dropped frame (#3837).
|
||||||
|
func scrollingWallpaperConfigured() bool {
|
||||||
|
configDir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(configDir, "dms", "settings.json"))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return wantsSimpleAnimationDriver(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func wantsSimpleAnimationDriver(settings []byte) bool {
|
||||||
|
var parsed struct {
|
||||||
|
WallpaperFillMode string `json:"wallpaperFillMode"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(settings, &parsed) != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return parsed.WallpaperFillMode == "Scrolling"
|
||||||
|
}
|
||||||
|
|
||||||
func dmsExtraEnv(string) []string {
|
func dmsExtraEnv(string) []string {
|
||||||
var env []string
|
var env []string
|
||||||
if selfPath, err := os.Executable(); err == nil {
|
if selfPath, err := os.Executable(); err == nil {
|
||||||
env = append(env, "DMS_EXECUTABLE="+selfPath)
|
env = append(env, "DMS_EXECUTABLE="+selfPath)
|
||||||
}
|
}
|
||||||
if os.Getenv("QSG_USE_SIMPLE_ANIMATION_DRIVER") == "" {
|
if os.Getenv("QSG_USE_SIMPLE_ANIMATION_DRIVER") == "" && scrollingWallpaperConfigured() {
|
||||||
env = append(env, "QSG_USE_SIMPLE_ANIMATION_DRIVER=1")
|
env = append(env, "QSG_USE_SIMPLE_ANIMATION_DRIVER=1")
|
||||||
}
|
}
|
||||||
if _, set := os.LookupEnv("MALLOC_CONF"); !set {
|
if _, set := os.LookupEnv("MALLOC_CONF"); !set {
|
||||||
env = append(env, "MALLOC_CONF=thp:never,narenas:4,dirty_decay_ms:3000")
|
env = append(env, "MALLOC_CONF=thp:never,narenas:4,dirty_decay_ms:3000")
|
||||||
}
|
}
|
||||||
env = append(env, gpu.EGLVendorEnv()...)
|
env = append(env, gpu.EGLVendorEnv()...)
|
||||||
|
policyEnv, err := clipolicy.ShellEnv(afero.NewOsFs())
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("CLI policy unreadable, enforcing every shell switch and ignoring its lock settings: %v", err)
|
||||||
|
}
|
||||||
|
env = append(env, policyEnv...)
|
||||||
// systemd user services miss the login shell PATH, so tools in ~/.local/bin
|
// systemd user services miss the login shell PATH, so tools in ~/.local/bin
|
||||||
// (herdr, pip/cargo installs) would be invisible to the shell and its children.
|
// (herdr, pip/cargo installs) would be invisible to the shell and its children.
|
||||||
for _, entry := range utils.EnvWithUserBinPath(nil) {
|
for _, entry := range utils.EnvWithUserBinPath(nil) {
|
||||||
|
|||||||
@@ -20,9 +20,16 @@ var runSystemctl = func(args ...string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Assigned in init: shellApp's config refers back to trySystemdRestart.
|
||||||
|
var shellRunning func() bool
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
shellRunning = func() bool { return len(shellApp.PIDs()) > 0 }
|
||||||
|
}
|
||||||
|
|
||||||
func trySystemdRestart() (bool, error) {
|
func trySystemdRestart() (bool, error) {
|
||||||
if err := runSystemctl("--user", "is-active", "--quiet", dmsSystemdUnit); err != nil {
|
if err := runSystemctl("--user", "is-active", "--quiet", dmsSystemdUnit); err != nil {
|
||||||
return false, nil
|
return trySystemdStart()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := runSystemctl("--user", "restart", dmsSystemdUnit); err != nil {
|
if err := runSystemctl("--user", "restart", dmsSystemdUnit); err != nil {
|
||||||
@@ -30,3 +37,23 @@ func trySystemdRestart() (bool, error) {
|
|||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// With nothing running, restart would spawn an unmanaged daemon that blocks
|
||||||
|
// the unit once the session target starts it. A running hand-started
|
||||||
|
// instance is left to the plain restart path.
|
||||||
|
func trySystemdStart() (bool, error) {
|
||||||
|
if shellRunning() {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := runSystemctl("--user", "is-enabled", "--quiet", dmsSystemdUnit); err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := runSystemctl("--user", "is-active", "--quiet", "graphical-session.target"); err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runSystemctl("--user", "start", dmsSystemdUnit); err != nil {
|
||||||
|
return true, fmt.Errorf("starting %s: %w", dmsSystemdUnit, err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTrySystemdRestart(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
running bool
|
||||||
|
ok []string
|
||||||
|
wantHandled bool
|
||||||
|
wantCall string
|
||||||
|
}{
|
||||||
|
{"active unit restarts", false, []string{"is-active dms.service", "restart"}, true, "restart dms.service"},
|
||||||
|
{"enabled inactive unit starts", false, []string{"is-enabled", "is-active graphical-session.target", "start"}, true, "start dms.service"},
|
||||||
|
{"hand-started instance keeps plain restart", true, []string{"is-enabled", "is-active graphical-session.target", "start"}, false, ""},
|
||||||
|
{"session target not up", false, []string{"is-enabled"}, false, ""},
|
||||||
|
{"unit disabled", false, []string{"is-active graphical-session.target"}, false, ""},
|
||||||
|
{"no systemd", false, nil, false, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
origSystemctl, origRunning := runSystemctl, shellRunning
|
||||||
|
t.Cleanup(func() { runSystemctl, shellRunning = origSystemctl, origRunning })
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
shellRunning = func() bool { return tt.running }
|
||||||
|
var calls []string
|
||||||
|
runSystemctl = func(args ...string) error {
|
||||||
|
call := strings.Join(slices.DeleteFunc(slices.Clone(args), func(a string) bool {
|
||||||
|
return strings.HasPrefix(a, "--")
|
||||||
|
}), " ")
|
||||||
|
calls = append(calls, call)
|
||||||
|
for _, prefix := range tt.ok {
|
||||||
|
if strings.HasPrefix(call, prefix) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.New("exit status 3")
|
||||||
|
}
|
||||||
|
|
||||||
|
handled, err := trySystemdRestart()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if handled != tt.wantHandled {
|
||||||
|
t.Fatalf("handled = %v, want %v (calls %q)", handled, tt.wantHandled, calls)
|
||||||
|
}
|
||||||
|
if tt.wantCall != "" && !slices.Contains(calls, tt.wantCall) {
|
||||||
|
t.Fatalf("missing %q in calls %q", tt.wantCall, calls)
|
||||||
|
}
|
||||||
|
if !tt.wantHandled && slices.ContainsFunc(calls, func(c string) bool { return strings.HasPrefix(c, "start") }) {
|
||||||
|
t.Fatalf("unexpected start in calls %q", calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,9 +32,9 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "dankinstall",
|
Use: "duxinstall",
|
||||||
Short: "Install Dux Shell and its dependencies",
|
Short: "Install Dux Shell and its dependencies",
|
||||||
Long: `dankinstall sets up Dux Shell with your chosen compositor and terminal.
|
Long: `duxinstall sets up Dux Shell with your chosen compositor and terminal.
|
||||||
|
|
||||||
Without flags, it launches an interactive TUI. Providing either --compositor
|
Without flags, it launches an interactive TUI. Providing either --compositor
|
||||||
or --term activates headless (unattended) mode, which requires both flags.
|
or --term activates headless (unattended) mode, which requires both flags.
|
||||||
@@ -42,7 +42,7 @@ or --term activates headless (unattended) mode, which requires both flags.
|
|||||||
Headless mode requires cached credentials or a passwordless rule for your
|
Headless mode requires cached credentials or a passwordless rule for your
|
||||||
privilege escalation tool (sudo, doas, or run0).`,
|
privilege escalation tool (sudo, doas, or run0).`,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: runDankinstall,
|
RunE: runDuxinstall,
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ func init() {
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if os.Getuid() == 0 {
|
if os.Getuid() == 0 {
|
||||||
fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root")
|
fmt.Fprintln(os.Stderr, "Error: duxinstall must not be run as root")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runDankinstall(cmd *cobra.Command, args []string) error {
|
func runDuxinstall(cmd *cobra.Command, args []string) error {
|
||||||
headlessMode := compositor != "" || term != ""
|
headlessMode := compositor != "" || term != ""
|
||||||
|
|
||||||
if !headlessMode {
|
if !headlessMode {
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
module github.com/duxlabs-dev/dux-shell/core
|
module github.com/duxlabs-dev/dux-shell/core
|
||||||
|
|
||||||
go 1.27.1
|
go 1.27.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
||||||
@@ -9,7 +9,7 @@ require (
|
|||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/dlclark/regexp2/v2 v2.9.1
|
github.com/dlclark/regexp2/v2 v2.9.1
|
||||||
github.com/duxlabs-dev/dux-go v1.6.3-0.20261020163124-e2ca52bdf500
|
github.com/duxlabs-dev/dux-go v1.6.3-0.20261024072205-827c879f557a
|
||||||
github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea
|
github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea
|
||||||
github.com/fsnotify/fsnotify v1.10.1
|
github.com/fsnotify/fsnotify v1.10.1
|
||||||
github.com/go-git/go-git/v6 v6.0.0-beta.1
|
github.com/go-git/go-git/v6 v6.0.0-beta.1
|
||||||
|
|||||||
+2
-2
@@ -97,8 +97,8 @@ github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yez
|
|||||||
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
|
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
|
||||||
github.com/dlclark/regexp2/v2 v2.9.1 h1:q6bZW/NN9VodqMjYXpuic2lZxdHPfFTzGS+MJxIJmKQ=
|
github.com/dlclark/regexp2/v2 v2.9.1 h1:q6bZW/NN9VodqMjYXpuic2lZxdHPfFTzGS+MJxIJmKQ=
|
||||||
github.com/dlclark/regexp2/v2 v2.9.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
github.com/dlclark/regexp2/v2 v2.9.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||||
github.com/duxlabs-dev/dux-go v1.6.3-0.20261020163124-e2ca52bdf500 h1:y02m7QgoX4rwauY9HePIPGCRDJ/fMirBmgj78IkeUsc=
|
github.com/duxlabs-dev/dux-go v1.6.3-0.20261024072205-827c879f557a h1:AgR1FUYWhq5JgptZy0dwk1vsuHCXmOjxLFkB6iS/uTc=
|
||||||
github.com/duxlabs-dev/dux-go v1.6.3-0.20261020163124-e2ca52bdf500/go.mod h1:b+hBqIgOVAmlFKJ+33SY2pH4kJjXpifNeioPMxwC4Nk=
|
github.com/duxlabs-dev/dux-go v1.6.3-0.20261024072205-827c879f557a/go.mod h1:b+hBqIgOVAmlFKJ+33SY2pH4kJjXpifNeioPMxwC4Nk=
|
||||||
github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea h1:+z9uPtBmpXXBPupB6++/8avPA0weOkzAz7jBoE+yIok=
|
github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea h1:+z9uPtBmpXXBPupB6++/8avPA0weOkzAz7jBoE+yIok=
|
||||||
github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea/go.mod h1:IYu4GO7zR61w3+tDmjg71dQDInXe5TcP3lHQqJpZYR0=
|
github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea/go.mod h1:IYu4GO7zR61w3+tDmjg71dQDInXe5TcP3lHQqJpZYR0=
|
||||||
github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU=
|
github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU=
|
||||||
|
|||||||
+9
-3
@@ -43,7 +43,7 @@ if [ -z "$LATEST_VERSION" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf "%bInstalling Dankinstall %s for %s...%b\n" "$GREEN" "$LATEST_VERSION" "$ARCH" "$NC"
|
printf "%bInstalling Duxinstall %s for %s...%b\n" "$GREEN" "$LATEST_VERSION" "$ARCH" "$NC"
|
||||||
|
|
||||||
# Download and install
|
# Download and install
|
||||||
TEMP_DIR=$(mktemp -d)
|
TEMP_DIR=$(mktemp -d)
|
||||||
@@ -52,8 +52,14 @@ cd "$TEMP_DIR" || exit 1
|
|||||||
|
|
||||||
# Download the gzipped binary and its checksum
|
# Download the gzipped binary and its checksum
|
||||||
printf "%bDownloading installer...%b\n" "$GREEN" "$NC"
|
printf "%bDownloading installer...%b\n" "$GREEN" "$NC"
|
||||||
curl -L "https://github.com/duxlabs-dev/dux-shell/releases/download/$LATEST_VERSION/dankinstall-$ARCH.gz" -o "installer.gz"
|
RELEASE_URL="https://github.com/duxlabs-dev/dux-shell/releases/download/$LATEST_VERSION"
|
||||||
curl -L "https://github.com/duxlabs-dev/dux-shell/releases/download/$LATEST_VERSION/dankinstall-$ARCH.gz.sha256" -o "expected.sha256"
|
# install.duxlabs.dev serves this file from master; releases before 2.0.1 only ship dankinstall-*.gz
|
||||||
|
ASSET="duxinstall-$ARCH.gz"
|
||||||
|
if ! curl -fsLI "$RELEASE_URL/$ASSET" >/dev/null; then
|
||||||
|
ASSET="dankinstall-$ARCH.gz"
|
||||||
|
fi
|
||||||
|
curl -fL "$RELEASE_URL/$ASSET" -o "installer.gz"
|
||||||
|
curl -fL "$RELEASE_URL/$ASSET.sha256" -o "expected.sha256"
|
||||||
|
|
||||||
# Get the expected checksum
|
# Get the expected checksum
|
||||||
EXPECTED_CHECKSUM=$(awk '{print $1}' expected.sha256)
|
EXPECTED_CHECKSUM=$(awk '{print $1}' expected.sha256)
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,6 +22,41 @@ type File struct {
|
|||||||
BlockedCommands *[]string `json:"blocked_commands"`
|
BlockedCommands *[]string `json:"blocked_commands"`
|
||||||
Message *string `json:"message"`
|
Message *string `json:"message"`
|
||||||
DisableRegistries *bool `json:"disable_registries"`
|
DisableRegistries *bool `json:"disable_registries"`
|
||||||
|
DisableLockIPCUnlock *bool `json:"disable_lock_ipc_unlock"`
|
||||||
|
DisableLogindUnlock *bool `json:"disable_logind_unlock"`
|
||||||
|
DisableCustomLockPAM *bool `json:"disable_custom_lock_pam"`
|
||||||
|
DisableUserPlugins *bool `json:"disable_user_plugins"`
|
||||||
|
DisableSettingsIPCSet *bool `json:"disable_settings_ipc_set"`
|
||||||
|
|
||||||
|
LockSettings map[string]json.RawMessage `json:"lock_settings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellSwitches reach the shell as DMS_POLICY_<KEY> in its launch environment.
|
||||||
|
var shellSwitches = []struct {
|
||||||
|
key string
|
||||||
|
value func(*File) *bool
|
||||||
|
}{
|
||||||
|
{"disable_lock_ipc_unlock", func(p *File) *bool { return p.DisableLockIPCUnlock }},
|
||||||
|
{"disable_logind_unlock", func(p *File) *bool { return p.DisableLogindUnlock }},
|
||||||
|
{"disable_custom_lock_pam", func(p *File) *bool { return p.DisableCustomLockPAM }},
|
||||||
|
{"disable_user_plugins", func(p *File) *bool { return p.DisableUserPlugins }},
|
||||||
|
{"disable_settings_ipc_set", func(p *File) *bool { return p.DisableSettingsIPCSet }},
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockSettingKeys are the shell settings lock_settings may manage.
|
||||||
|
var lockSettingKeys = []string{
|
||||||
|
"customPowerActionLock",
|
||||||
|
"lockBeforeSuspend",
|
||||||
|
"loginctlLockIntegration",
|
||||||
|
"lockAtStartup",
|
||||||
|
"acLockTimeout",
|
||||||
|
"batteryLockTimeout",
|
||||||
|
"fadeToLockEnabled",
|
||||||
|
"fadeToLockGracePeriod",
|
||||||
|
"enableFprint",
|
||||||
|
"maxFprintTries",
|
||||||
|
"enableU2f",
|
||||||
|
"u2fMode",
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadFile returns nil without an error when the file does not exist.
|
// LoadFile returns nil without an error when the file does not exist.
|
||||||
@@ -39,18 +77,106 @@ func LoadFile(fs afero.Fs, path string) (*File, error) {
|
|||||||
return &policy, nil
|
return &policy, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegistriesDisabled reports whether the policy turns off the plugin and theme
|
// load returns the policy files that exist, PackagedPath first.
|
||||||
// registries. An explicit value in AdminPath overrides PackagedPath.
|
func load(fs afero.Fs) ([]*File, error) {
|
||||||
func RegistriesDisabled(fs afero.Fs) (bool, error) {
|
var policies []*File
|
||||||
disabled := false
|
|
||||||
for _, path := range []string{PackagedPath, AdminPath} {
|
for _, path := range []string{PackagedPath, AdminPath} {
|
||||||
policy, err := LoadFile(fs, path)
|
policy, err := LoadFile(fs, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if policy != nil {
|
||||||
|
policies = append(policies, policy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return policies, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// flag reports a boolean key, false unless set. An explicit value in AdminPath
|
||||||
|
// overrides PackagedPath.
|
||||||
|
func flag(policies []*File, value func(*File) *bool) bool {
|
||||||
|
set := false
|
||||||
|
for _, policy := range policies {
|
||||||
|
if v := value(policy); v != nil {
|
||||||
|
set = *v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistriesDisabled reports whether the policy turns off the plugin and theme
|
||||||
|
// registries. An explicit value in AdminPath overrides PackagedPath.
|
||||||
|
func RegistriesDisabled(fs afero.Fs) (bool, error) {
|
||||||
|
policies, err := load(fs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if policy != nil && policy.DisableRegistries != nil {
|
return flag(policies, func(p *File) *bool { return p.DisableRegistries }), nil
|
||||||
disabled = *policy.DisableRegistries
|
}
|
||||||
|
|
||||||
|
// LogindUnlockDisabled reports whether a logind Unlock must leave a lock in
|
||||||
|
// place. An explicit value in AdminPath overrides PackagedPath.
|
||||||
|
func LogindUnlockDisabled(fs afero.Fs) (bool, error) {
|
||||||
|
policies, err := load(fs)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
|
return flag(policies, func(p *File) *bool { return p.DisableLogindUnlock }), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockSettings merges lock_settings per key, AdminPath over PackagedPath,
|
||||||
|
// without null values and keys outside lockSettingKeys.
|
||||||
|
func lockSettings(policies []*File) map[string]json.RawMessage {
|
||||||
|
settings := make(map[string]json.RawMessage)
|
||||||
|
for _, policy := range policies {
|
||||||
|
for key, value := range policy.LockSettings {
|
||||||
|
if !slices.Contains(lockSettingKeys, key) {
|
||||||
|
log.Warnf("CLI policy: ignoring lock_settings key %q, which is not a managed lock setting", key)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
return disabled, nil
|
if string(value) == "null" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
settings[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
// LockSettings returns the managed lock settings as raw JSON by key. A policy
|
||||||
|
// file that fails to load manages none, and its error is returned.
|
||||||
|
func LockSettings(fs afero.Fs) (map[string]json.RawMessage, error) {
|
||||||
|
policies, err := load(fs)
|
||||||
|
return lockSettings(policies), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LockSettingBool returns a managed boolean lock setting, or nil when it is
|
||||||
|
// not managed or not a boolean, which the shell ignores too.
|
||||||
|
func LockSettingBool(settings map[string]json.RawMessage, key string) *bool {
|
||||||
|
var value *bool
|
||||||
|
if err := json.Unmarshal(settings[key], &value); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShellEnv returns every shell switch as DMS_POLICY_<KEY>=1 or 0 and the
|
||||||
|
// managed lock settings as DMS_POLICY_LOCK_SETTINGS=<JSON object>, so a value
|
||||||
|
// inherited by the shell never counts. A policy file that fails to load turns
|
||||||
|
// every switch on and manages no lock setting, and its error is returned with
|
||||||
|
// them.
|
||||||
|
func ShellEnv(fs afero.Fs) ([]string, error) {
|
||||||
|
policies, err := load(fs)
|
||||||
|
env := make([]string, 0, len(shellSwitches)+1)
|
||||||
|
for _, s := range shellSwitches {
|
||||||
|
value := "0"
|
||||||
|
if err != nil || flag(policies, s.value) {
|
||||||
|
value = "1"
|
||||||
|
}
|
||||||
|
env = append(env, "DMS_POLICY_"+strings.ToUpper(s.key)+"="+value)
|
||||||
|
}
|
||||||
|
// The values come from json.Unmarshal, so they marshal.
|
||||||
|
settings, _ := json.Marshal(lockSettings(policies))
|
||||||
|
env = append(env, "DMS_POLICY_LOCK_SETTINGS="+string(settings))
|
||||||
|
return env, err
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package clipolicy
|
package clipolicy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@@ -45,3 +47,185 @@ func TestRegistriesDisabled(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLogindUnlockDisabled(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
packaged string
|
||||||
|
admin string
|
||||||
|
want bool
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "no policy files"},
|
||||||
|
{name: "admin policy", admin: `{"disable_logind_unlock": true}`, want: true},
|
||||||
|
{name: "admin policy overrides packaged", packaged: `{"disable_logind_unlock": true}`, admin: `{"disable_logind_unlock": false}`},
|
||||||
|
{name: "another key", admin: `{"disable_lock_ipc_unlock": true}`},
|
||||||
|
{name: "malformed admin policy", admin: "{not json", wantErr: "failed to parse " + AdminPath},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
if tt.packaged != "" {
|
||||||
|
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(tt.packaged), 0o644))
|
||||||
|
}
|
||||||
|
if tt.admin != "" {
|
||||||
|
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(tt.admin), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
disabled, err := LogindUnlockDisabled(fs)
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
require.ErrorContains(t, err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, disabled)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var shellSwitchEnv = map[string]string{
|
||||||
|
"disable_lock_ipc_unlock": "DMS_POLICY_DISABLE_LOCK_IPC_UNLOCK",
|
||||||
|
"disable_logind_unlock": "DMS_POLICY_DISABLE_LOGIND_UNLOCK",
|
||||||
|
"disable_custom_lock_pam": "DMS_POLICY_DISABLE_CUSTOM_LOCK_PAM",
|
||||||
|
"disable_user_plugins": "DMS_POLICY_DISABLE_USER_PLUGINS",
|
||||||
|
"disable_settings_ipc_set": "DMS_POLICY_DISABLE_SETTINGS_IPC_SET",
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellEnv(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
packaged string
|
||||||
|
admin string
|
||||||
|
on bool
|
||||||
|
}{
|
||||||
|
{name: "no policy files"},
|
||||||
|
{name: "packaged policy", packaged: `{"KEY": true}`, on: true},
|
||||||
|
{name: "admin policy", admin: `{"KEY": true}`, on: true},
|
||||||
|
{name: "admin policy overrides packaged", packaged: `{"KEY": true}`, admin: `{"KEY": false}`},
|
||||||
|
{name: "admin policy without the key keeps packaged", packaged: `{"KEY": true}`, admin: `{"immutable_system": true}`, on: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, name := range shellSwitchEnv {
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(key+"/"+tt.name, func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
if tt.packaged != "" {
|
||||||
|
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(strings.ReplaceAll(tt.packaged, "KEY", key)), 0o644))
|
||||||
|
}
|
||||||
|
if tt.admin != "" {
|
||||||
|
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(strings.ReplaceAll(tt.admin, "KEY", key)), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
var want []string
|
||||||
|
for _, other := range shellSwitchEnv {
|
||||||
|
if other == name && tt.on {
|
||||||
|
want = append(want, other+"=1")
|
||||||
|
} else {
|
||||||
|
want = append(want, other+"=0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want = append(want, "DMS_POLICY_LOCK_SETTINGS={}")
|
||||||
|
|
||||||
|
env, err := ShellEnv(fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.ElementsMatch(t, want, env)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellEnvMalformedPolicyTurnsEverySwitchOnAndIgnoresLockSettings(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(`{"lock_settings": {"lockBeforeSuspend": true}}`), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte("{not json"), 0o644))
|
||||||
|
|
||||||
|
want := []string{"DMS_POLICY_LOCK_SETTINGS={}"}
|
||||||
|
for _, name := range shellSwitchEnv {
|
||||||
|
want = append(want, name+"=1")
|
||||||
|
}
|
||||||
|
|
||||||
|
env, err := ShellEnv(fs)
|
||||||
|
require.ErrorContains(t, err, "failed to parse "+AdminPath)
|
||||||
|
assert.ElementsMatch(t, want, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLockSettings(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
packaged string
|
||||||
|
admin string
|
||||||
|
want string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "no policy files", want: `{}`},
|
||||||
|
{name: "no lock settings", admin: `{"disable_registries": true}`, want: `{}`},
|
||||||
|
{name: "packaged policy", packaged: `{"lock_settings": {"lockBeforeSuspend": true, "customPowerActionLock": ""}}`, want: `{"lockBeforeSuspend": true, "customPowerActionLock": ""}`},
|
||||||
|
{name: "admin policy overrides packaged per key", packaged: `{"lock_settings": {"lockBeforeSuspend": true, "acLockTimeout": 300}}`, admin: `{"lock_settings": {"lockBeforeSuspend": false}}`, want: `{"lockBeforeSuspend": false, "acLockTimeout": 300}`},
|
||||||
|
{name: "admin policy without lock settings keeps packaged", packaged: `{"lock_settings": {"enableFprint": false}}`, admin: `{"disable_registries": true}`, want: `{"enableFprint": false}`},
|
||||||
|
{name: "null leaves a key unset", packaged: `{"lock_settings": {"u2fMode": "and"}}`, admin: `{"lock_settings": {"u2fMode": null, "lockAtStartup": null}}`, want: `{"u2fMode": "and"}`},
|
||||||
|
{
|
||||||
|
name: "only lock settings are managed",
|
||||||
|
admin: `{"lock_settings": {"customPowerActionLock": "", "lockBeforeSuspend": true, "loginctlLockIntegration": true, "lockAtStartup": true, "acLockTimeout": 300, "batteryLockTimeout": 120, "fadeToLockEnabled": true, "fadeToLockGracePeriod": 5, "enableFprint": true, "maxFprintTries": 3, "enableU2f": true, "u2fMode": "and", "lockScreenShowPowerActions": false, "lockPamPath": "/tmp/pam", "unknownSetting": 1}}`,
|
||||||
|
want: `{"customPowerActionLock": "", "lockBeforeSuspend": true, "loginctlLockIntegration": true, "lockAtStartup": true, "acLockTimeout": 300, "batteryLockTimeout": 120, "fadeToLockEnabled": true, "fadeToLockGracePeriod": 5, "enableFprint": true, "maxFprintTries": 3, "enableU2f": true, "u2fMode": "and"}`,
|
||||||
|
},
|
||||||
|
{name: "values are passed as written", admin: `{"lock_settings": {"lockBeforeSuspend": "yes", "acLockTimeout": [1]}}`, want: `{"lockBeforeSuspend": "yes", "acLockTimeout": [1]}`},
|
||||||
|
{name: "malformed admin policy manages nothing", packaged: `{"lock_settings": {"lockBeforeSuspend": true}}`, admin: "{not json", want: `{}`, wantErr: "failed to parse " + AdminPath},
|
||||||
|
{name: "lock settings not an object", packaged: `{"lock_settings": {"lockBeforeSuspend": true}}`, admin: `{"lock_settings": ["lockBeforeSuspend"]}`, want: `{}`, wantErr: "failed to parse " + AdminPath},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
if tt.packaged != "" {
|
||||||
|
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(tt.packaged), 0o644))
|
||||||
|
}
|
||||||
|
if tt.admin != "" {
|
||||||
|
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(tt.admin), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := LockSettings(fs)
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
require.ErrorContains(t, err, tt.wantErr)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
got, err := json.Marshal(settings)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.JSONEq(t, tt.want, string(got))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellEnvLockSettings(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(`{"lock_settings": {"lockBeforeSuspend": false, "acLockTimeout": 300}}`), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(`{"lock_settings": {"lockBeforeSuspend": true, "customPowerActionLock": "", "lockScreenShowWeather": false}}`), 0o644))
|
||||||
|
|
||||||
|
env, err := ShellEnv(fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, env, `DMS_POLICY_LOCK_SETTINGS={"acLockTimeout":300,"customPowerActionLock":"","lockBeforeSuspend":true}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLockSettingBool(t *testing.T) {
|
||||||
|
on, off := true, false
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
settings string
|
||||||
|
want *bool
|
||||||
|
}{
|
||||||
|
{name: "not managed", settings: `{"lockAtStartup": true}`},
|
||||||
|
{name: "managed on", settings: `{"lockBeforeSuspend": true}`, want: &on},
|
||||||
|
{name: "managed off", settings: `{"lockBeforeSuspend": false}`, want: &off},
|
||||||
|
{name: "null", settings: `{"lockBeforeSuspend": null}`},
|
||||||
|
{name: "not a boolean", settings: `{"lockBeforeSuspend": "true"}`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var settings map[string]json.RawMessage
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(tt.settings), &settings))
|
||||||
|
assert.Equal(t, tt.want, LockSettingBool(settings, "lockBeforeSuspend"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,7 +182,7 @@ layout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigDeploymentFlow(t *testing.T) {
|
func TestConfigDeploymentFlow(t *testing.T) {
|
||||||
tempDir, err := os.MkdirTemp("", "dankinstall-test")
|
tempDir, err := os.MkdirTemp("", "duxinstall-test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(tempDir)
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
@@ -312,7 +312,7 @@ func TestHyprlangMonitorLineToLuaPreservesOptions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHyprlandConfigDeployment(t *testing.T) {
|
func TestHyprlandConfigDeployment(t *testing.T) {
|
||||||
tempDir, err := os.MkdirTemp("", "dankinstall-hyprland-test")
|
tempDir, err := os.MkdirTemp("", "duxinstall-hyprland-test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(tempDir)
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
@@ -324,7 +324,7 @@ func TestHyprlandConfigDeployment(t *testing.T) {
|
|||||||
cd := NewConfigDeployer(logChan)
|
cd := NewConfigDeployer(logChan)
|
||||||
|
|
||||||
t.Run("deploy hyprland config to empty directory", func(t *testing.T) {
|
t.Run("deploy hyprland config to empty directory", func(t *testing.T) {
|
||||||
td, err := os.MkdirTemp("", "dankinstall-hyprland-empty")
|
td, err := os.MkdirTemp("", "duxinstall-hyprland-empty")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(td)
|
defer os.RemoveAll(td)
|
||||||
os.Setenv("HOME", td)
|
os.Setenv("HOME", td)
|
||||||
@@ -344,7 +344,7 @@ func TestHyprlandConfigDeployment(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("deploy hyprland config with existing monitors", func(t *testing.T) {
|
t.Run("deploy hyprland config with existing monitors", func(t *testing.T) {
|
||||||
td, err := os.MkdirTemp("", "dankinstall-hyprland-merge")
|
td, err := os.MkdirTemp("", "duxinstall-hyprland-merge")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(td)
|
defer os.RemoveAll(td)
|
||||||
os.Setenv("HOME", td)
|
os.Setenv("HOME", td)
|
||||||
@@ -408,7 +408,7 @@ general {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("deploy hyprland config removes root legacy symlink when lua exists", func(t *testing.T) {
|
t.Run("deploy hyprland config removes root legacy symlink when lua exists", func(t *testing.T) {
|
||||||
td, err := os.MkdirTemp("", "dankinstall-hyprland-lua-conf-symlink")
|
td, err := os.MkdirTemp("", "duxinstall-hyprland-lua-conf-symlink")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(td)
|
defer os.RemoveAll(td)
|
||||||
os.Setenv("HOME", td)
|
os.Setenv("HOME", td)
|
||||||
@@ -431,7 +431,7 @@ general {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("deploy hyprland config refreshes managed binds but preserves user binds", func(t *testing.T) {
|
t.Run("deploy hyprland config refreshes managed binds but preserves user binds", func(t *testing.T) {
|
||||||
td, err := os.MkdirTemp("", "dankinstall-hyprland-refresh-binds")
|
td, err := os.MkdirTemp("", "duxinstall-hyprland-refresh-binds")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(td)
|
defer os.RemoveAll(td)
|
||||||
os.Setenv("HOME", td)
|
os.Setenv("HOME", td)
|
||||||
@@ -457,7 +457,7 @@ general {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestKittyConfigDeployment(t *testing.T) {
|
func TestKittyConfigDeployment(t *testing.T) {
|
||||||
tempDir, err := os.MkdirTemp("", "dankinstall-kitty-test")
|
tempDir, err := os.MkdirTemp("", "duxinstall-kitty-test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(tempDir)
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
@@ -495,7 +495,7 @@ func TestKittyConfigDeployment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlacrittyConfigDeployment(t *testing.T) {
|
func TestAlacrittyConfigDeployment(t *testing.T) {
|
||||||
tempDir, err := os.MkdirTemp("", "dankinstall-alacritty-test")
|
tempDir, err := os.MkdirTemp("", "duxinstall-alacritty-test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(tempDir)
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
@@ -570,7 +570,7 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.Run("replaceConfigs nil deploys config", func(t *testing.T) {
|
t.Run("replaceConfigs nil deploys config", func(t *testing.T) {
|
||||||
tempDir, err := os.MkdirTemp("", "dankinstall-replace-nil-test")
|
tempDir, err := os.MkdirTemp("", "duxinstall-replace-nil-test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(tempDir)
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
@@ -603,7 +603,7 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("replaceConfigs all false and config missing deploys config", func(t *testing.T) {
|
t.Run("replaceConfigs all false and config missing deploys config", func(t *testing.T) {
|
||||||
tempDir, err := os.MkdirTemp("", "dankinstall-replace-missing-test")
|
tempDir, err := os.MkdirTemp("", "duxinstall-replace-missing-test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(tempDir)
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
@@ -636,7 +636,7 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("replaceConfigs false and config exists skips config", func(t *testing.T) {
|
t.Run("replaceConfigs false and config exists skips config", func(t *testing.T) {
|
||||||
tempDir, err := os.MkdirTemp("", "dankinstall-replace-exists-test")
|
tempDir, err := os.MkdirTemp("", "duxinstall-replace-exists-test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(tempDir)
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
@@ -678,7 +678,7 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("replaceConfigs true and config exists deploys config", func(t *testing.T) {
|
t.Run("replaceConfigs true and config exists deploys config", func(t *testing.T) {
|
||||||
tempDir, err := os.MkdirTemp("", "dankinstall-replace-true-test")
|
tempDir, err := os.MkdirTemp("", "duxinstall-replace-true-test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(tempDir)
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
@@ -725,7 +725,7 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("hyprland legacy config exists skips when replace false", func(t *testing.T) {
|
t.Run("hyprland legacy config exists skips when replace false", func(t *testing.T) {
|
||||||
tempDir, err := os.MkdirTemp("", "dankinstall-hyprland-legacy-skip-test")
|
tempDir, err := os.MkdirTemp("", "duxinstall-hyprland-legacy-skip-test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(tempDir)
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Auto-generated by DMS. Overwritten by matugen (dms/colors.conf).
|
# Auto-generated by DMS. Overwritten by matugen (dms/colors.conf).
|
||||||
# Remove `source=./dms/colors.conf` from config.conf to override manually.
|
# Remove `source=./dms/colors.conf` from config.conf to override manually.
|
||||||
|
|
||||||
bordercolor = 0x595959ff
|
border_color = 0x595959ff
|
||||||
focuscolor = 0x8ab4f8ff
|
focus_color = 0x8ab4f8ff
|
||||||
urgentcolor = 0xff5555ff
|
urgent_color = 0xff5555ff
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
# Auto-generated by DMS. Regenerated from DMS settings (dms/layout.conf).
|
# Auto-generated by DMS. Regenerated from DMS settings (dms/layout.conf).
|
||||||
|
|
||||||
border_radius=12
|
border_radius=12
|
||||||
gappih=5
|
gap_inner_horizontal=5
|
||||||
gappiv=5
|
gap_inner_vertical=5
|
||||||
gappoh=5
|
gap_outer_horizontal=5
|
||||||
gappov=5
|
gap_outer_vertical=5
|
||||||
borderpx=2
|
border_px=2
|
||||||
|
|
||||||
|
# layer_animations would tween every DMS layer move; override with no_animation:0 rules after the source lines.
|
||||||
|
layer_rule=no_animation:1,layer_name:^(quickshell)$
|
||||||
|
layer_rule=no_animation:1,layer_name:^dms:.*
|
||||||
@@ -7,7 +7,7 @@ env=XDG_SESSION_TYPE,wayland
|
|||||||
|
|
||||||
# Starts the shell when DMS is not a systemd service. Never exec=: Mango re-runs
|
# Starts the shell when DMS is not a systemd service. Never exec=: Mango re-runs
|
||||||
# exec= on every config reload, and DMS reloads the config.
|
# exec= on every config reload, and DMS reloads the config.
|
||||||
exec-once=dms run
|
exec_once=dms run
|
||||||
|
|
||||||
source=./dms/colors.conf
|
source=./dms/colors.conf
|
||||||
source=./dms/layout.conf
|
source=./dms/layout.conf
|
||||||
|
|||||||
@@ -41,14 +41,13 @@ func nonEmptyFile(path string) (string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
mangoBindLine = regexp.MustCompile(`^\s*(bind[a-z]*|mousebind|axisbind|gesturebind|switchbind|keymode|key_mode)\s*=`)
|
mangoBindLine = regexp.MustCompile(`^\s*(bind[a-z]*|mousebind|axisbind|gesturebind|switchbind|key_mode)\s*=`)
|
||||||
mangoMonitorLine = regexp.MustCompile(`^\s*(monitorrule|monitor_rule)\s*=`)
|
mangoMonitorLine = regexp.MustCompile(`^\s*monitor_rule\s*=`)
|
||||||
mangoWindowLine = regexp.MustCompile(`^\s*(windowrule|window_rule|windowrule-once|window_rule_once)\s*=`)
|
mangoWindowLine = regexp.MustCompile(`^\s*window_rule(_once)?\s*=`)
|
||||||
mangoExecOnceDMS = regexp.MustCompile(`(?m)^# Starts the shell when DMS.*\n#.*\n(exec-once|exec_once)=dms run\n`)
|
mangoExecOnceDMS = regexp.MustCompile(`(?m)^# Starts the shell when DMS.*\n#.*\nexec_once=dms run\n`)
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
mangoDialect = mangoconf.Detect
|
|
||||||
mangoSessionTargetInstalled = mangoconf.SessionTargetInstalled
|
mangoSessionTargetInstalled = mangoconf.SessionTargetInstalled
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -110,17 +109,16 @@ func (cd *ConfigDeployer) deployMangoConfig(terminalCommand string, useSystemd b
|
|||||||
cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath))
|
cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
dialect := mangoDialect()
|
|
||||||
newConfig := strings.ReplaceAll(MangoConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
|
newConfig := strings.ReplaceAll(MangoConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
|
||||||
if cd.systemdAutostart(deps.WindowManagerMango, useSystemd) {
|
if cd.systemdAutostart(deps.WindowManagerMango, useSystemd) {
|
||||||
newConfig = mangoExecOnceDMS.ReplaceAllString(newConfig, "")
|
newConfig = mangoExecOnceDMS.ReplaceAllString(newConfig, "")
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(result.Path, []byte(dialect.Translate(newConfig)), 0o644); err != nil {
|
if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil {
|
||||||
result.Error = fmt.Errorf("failed to write config: %w", err)
|
result.Error = fmt.Errorf("failed to write config: %w", err)
|
||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cd.deployMangoDmsConfigs(dmsDir, terminalCommand, dialect, existing, timestamp); err != nil {
|
if err := cd.deployMangoDmsConfigs(dmsDir, terminalCommand, existing, timestamp); err != nil {
|
||||||
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
|
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
|
||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
@@ -132,7 +130,7 @@ func (cd *ConfigDeployer) deployMangoConfig(terminalCommand string, useSystemd b
|
|||||||
|
|
||||||
// Like niri, fragments the user or the shell already filled are left alone; the
|
// Like niri, fragments the user or the shell already filled are left alone; the
|
||||||
// replaced config.conf hands its binds, monitor and window rules to empty ones.
|
// replaced config.conf hands its binds, monitor and window rules to empty ones.
|
||||||
func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string, dialect mangoconf.Dialect, oldMain, timestamp string) error {
|
func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand, oldMain, timestamp string) error {
|
||||||
stockBinds := strings.ReplaceAll(MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
|
stockBinds := strings.ReplaceAll(MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
|
||||||
bindsPath := filepath.Join(dmsDir, "binds.conf")
|
bindsPath := filepath.Join(dmsDir, "binds.conf")
|
||||||
if data, err := os.ReadFile(bindsPath); err == nil && len(data) > 0 {
|
if data, err := os.ReadFile(bindsPath); err == nil && len(data) > 0 {
|
||||||
@@ -142,7 +140,7 @@ func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string,
|
|||||||
}
|
}
|
||||||
cd.log(fmt.Sprintf("Backed up existing binds to %s", backup))
|
cd.log(fmt.Sprintf("Backed up existing binds to %s", backup))
|
||||||
if cd.replaceBinds {
|
if cd.replaceBinds {
|
||||||
if err := os.WriteFile(bindsPath, []byte(dialect.Translate(stockBinds)), 0o644); err != nil {
|
if err := os.WriteFile(bindsPath, []byte(stockBinds), 0o644); err != nil {
|
||||||
return fmt.Errorf("failed to write binds.conf: %w", err)
|
return fmt.Errorf("failed to write binds.conf: %w", err)
|
||||||
}
|
}
|
||||||
cd.log("Deployed DMS stock binds.conf")
|
cd.log("Deployed DMS stock binds.conf")
|
||||||
@@ -160,7 +158,7 @@ func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string,
|
|||||||
binds = mangoconf.BindsMovedHeader + "\n" + old
|
binds = mangoconf.BindsMovedHeader + "\n" + old
|
||||||
cd.log("Moved existing binds from config.conf to binds.conf")
|
cd.log("Moved existing binds from config.conf to binds.conf")
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(bindsPath, []byte(dialect.Translate(binds)), 0o644); err != nil {
|
if err := os.WriteFile(bindsPath, []byte(binds), 0o644); err != nil {
|
||||||
return fmt.Errorf("failed to write binds.conf: %w", err)
|
return fmt.Errorf("failed to write binds.conf: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,7 +180,7 @@ func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string,
|
|||||||
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
|
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, []byte(dialect.Translate(cfg.content)), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil {
|
||||||
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
|
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
|
||||||
}
|
}
|
||||||
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
|
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
|
||||||
|
|||||||
@@ -13,12 +13,11 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func stubMango(t *testing.T, dialect mangoconf.Dialect, target bool, addWants error) *int {
|
func stubMango(t *testing.T, target bool, addWants error) *int {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
prevDialect, prevTarget := mangoDialect, mangoSessionTargetInstalled
|
prevTarget := mangoSessionTargetInstalled
|
||||||
mangoDialect = func() mangoconf.Dialect { return dialect }
|
|
||||||
mangoSessionTargetInstalled = func() bool { return target }
|
mangoSessionTargetInstalled = func() bool { return target }
|
||||||
t.Cleanup(func() { mangoDialect, mangoSessionTargetInstalled = prevDialect, prevTarget })
|
t.Cleanup(func() { mangoSessionTargetInstalled = prevTarget })
|
||||||
return stubAddWants(t, addWants)
|
return stubAddWants(t, addWants)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,16 +30,16 @@ func readMango(t *testing.T, home, rel string) string {
|
|||||||
|
|
||||||
const oldMangoConfig = `exec-once=waybar
|
const oldMangoConfig = `exec-once=waybar
|
||||||
bind=SUPER,Return,spawn,foot
|
bind=SUPER,Return,spawn,foot
|
||||||
keymode=resize
|
key_mode=resize
|
||||||
bind=NONE,h,resizewin,-10,0
|
bind=NONE,h,resizewin,-10,0
|
||||||
monitorrule=name:^DP-1$,width:2560,height:1440,refresh:144,x:0,y:0,scale:1,rr:0,vrr:1,hdr:1
|
monitor_rule=name:^DP-1$,width:2560,height:1440,refresh:144,x:0,y:0,scale:1,rr:0,vrr:1,hdr:1
|
||||||
windowrule=appid:pavucontrol,isfloating:1
|
window_rule=app_id:pavucontrol,is_floating:1
|
||||||
`
|
`
|
||||||
|
|
||||||
func TestMangoDeployMovesUserRulesAndKeepsBindsByDefault(t *testing.T) {
|
func TestMangoDeployMovesUserRulesAndKeepsBindsByDefault(t *testing.T) {
|
||||||
home := t.TempDir()
|
home := t.TempDir()
|
||||||
t.Setenv("HOME", home)
|
t.Setenv("HOME", home)
|
||||||
stubMango(t, mangoconf.Legacy, false, nil)
|
stubMango(t, false, nil)
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(home, ".config", "mango"), 0o755))
|
require.NoError(t, os.MkdirAll(filepath.Join(home, ".config", "mango"), 0o755))
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(home, ".config", "mango", "config.conf"), []byte(oldMangoConfig), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(home, ".config", "mango", "config.conf"), []byte(oldMangoConfig), 0o644))
|
||||||
|
|
||||||
@@ -48,25 +47,26 @@ func TestMangoDeployMovesUserRulesAndKeepsBindsByDefault(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotEmpty(t, result.BackupPath)
|
assert.NotEmpty(t, result.BackupPath)
|
||||||
|
|
||||||
assert.Contains(t, readMango(t, home, "config.conf"), "exec-once=dms run")
|
assert.Contains(t, readMango(t, home, "config.conf"), "exec_once=dms run")
|
||||||
assert.Contains(t, readMango(t, home, "config.conf"), "source=./dms/input.conf")
|
assert.Contains(t, readMango(t, home, "config.conf"), "source=./dms/input.conf")
|
||||||
assert.Equal(t, "", readMango(t, home, "dms/input.conf"))
|
assert.Equal(t, "", readMango(t, home, "dms/input.conf"))
|
||||||
binds := readMango(t, home, "dms/binds.conf")
|
binds := readMango(t, home, "dms/binds.conf")
|
||||||
assert.Contains(t, binds, "bind=SUPER,Return,spawn,foot\nkeymode=resize\nbind=NONE,h,resizewin,-10,0")
|
assert.Contains(t, binds, "bind=SUPER,Return,spawn,foot\nkey_mode=resize\nbind=NONE,h,resizewin,-10,0")
|
||||||
assert.NotContains(t, binds, "{{TERMINAL_COMMAND}}")
|
assert.NotContains(t, binds, "{{TERMINAL_COMMAND}}")
|
||||||
|
assert.Contains(t, readMango(t, home, "dms/outputs.conf"), "monitor_rule=name:^DP-1$")
|
||||||
assert.Contains(t, readMango(t, home, "dms/outputs.conf"), "hdr:1")
|
assert.Contains(t, readMango(t, home, "dms/outputs.conf"), "hdr:1")
|
||||||
assert.Contains(t, readMango(t, home, "dms/windowrules.conf"), "windowrule=appid:pavucontrol,isfloating:1")
|
assert.Contains(t, readMango(t, home, "dms/windowrules.conf"), "window_rule=app_id:pavucontrol,is_floating:1")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMangoDeployBindsChoiceAlwaysBacksUp(t *testing.T) {
|
func TestMangoDeployBindsChoiceAlwaysBacksUp(t *testing.T) {
|
||||||
for _, replace := range []bool{false, true} {
|
for _, replace := range []bool{false, true} {
|
||||||
home := t.TempDir()
|
home := t.TempDir()
|
||||||
t.Setenv("HOME", home)
|
t.Setenv("HOME", home)
|
||||||
stubMango(t, mangoconf.Legacy, false, nil)
|
stubMango(t, false, nil)
|
||||||
dms := filepath.Join(home, ".config", "mango", "dms")
|
dms := filepath.Join(home, ".config", "mango", "dms")
|
||||||
require.NoError(t, os.MkdirAll(dms, 0o755))
|
require.NoError(t, os.MkdirAll(dms, 0o755))
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dms, "binds.conf"), []byte("bind=SUPER,x,killclient\n"), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(dms, "binds.conf"), []byte("bind=SUPER,x,killclient\nkey_mode=resize\n"), 0o644))
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dms, "windowrules.conf"), []byte("windowrule=appid:keep,isglobal:1\n"), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(dms, "windowrules.conf"), []byte("window_rule=app_id:keep,is_global:1\n"), 0o644))
|
||||||
|
|
||||||
cd := NewConfigDeployer(nil)
|
cd := NewConfigDeployer(nil)
|
||||||
cd.SetReplaceBinds(replace)
|
cd.SetReplaceBinds(replace)
|
||||||
@@ -76,8 +76,8 @@ func TestMangoDeployBindsChoiceAlwaysBacksUp(t *testing.T) {
|
|||||||
backups, _ := filepath.Glob(filepath.Join(dms, "binds.conf.backup.*"))
|
backups, _ := filepath.Glob(filepath.Join(dms, "binds.conf.backup.*"))
|
||||||
assert.Len(t, backups, 1, "replace=%v", replace)
|
assert.Len(t, backups, 1, "replace=%v", replace)
|
||||||
binds := readMango(t, home, "dms/binds.conf")
|
binds := readMango(t, home, "dms/binds.conf")
|
||||||
assert.Equal(t, !replace, binds == mangoconf.BindsKeptHeader+"\nbind=SUPER,x,killclient\n", "replace=%v", replace)
|
assert.Equal(t, !replace, binds == mangoconf.BindsKeptHeader+"\nbind=SUPER,x,killclient\nkey_mode=resize\n", "replace=%v", replace)
|
||||||
assert.Equal(t, "windowrule=appid:keep,isglobal:1\n", readMango(t, home, "dms/windowrules.conf"))
|
assert.Equal(t, "window_rule=app_id:keep,is_global:1\n", readMango(t, home, "dms/windowrules.conf"), "kept fragments are left alone")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ func TestMangoDeploySystemdAutostart(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
home := t.TempDir()
|
home := t.TempDir()
|
||||||
t.Setenv("HOME", home)
|
t.Setenv("HOME", home)
|
||||||
calls := stubMango(t, mangoconf.Snake, tc.target, tc.addWants)
|
calls := stubMango(t, tc.target, tc.addWants)
|
||||||
_, err := NewConfigDeployer(nil).DeployCompositor(deps.WindowManagerMango, "kitty", true)
|
_, err := NewConfigDeployer(nil).DeployCompositor(deps.WindowManagerMango, "kitty", true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
main := readMango(t, home, "config.conf")
|
main := readMango(t, home, "config.conf")
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func TestDisableSessionRemovesEveryWantsLink(t *testing.T) {
|
|||||||
|
|
||||||
func TestEnableSessionMangoWithoutTarget(t *testing.T) {
|
func TestEnableSessionMangoWithoutTarget(t *testing.T) {
|
||||||
t.Setenv("HOME", t.TempDir())
|
t.Setenv("HOME", t.TempDir())
|
||||||
calls := stubMango(t, mangoconf.Snake, false, nil)
|
calls := stubMango(t, false, nil)
|
||||||
unit, err := EnableSession(deps.WindowManagerMango)
|
unit, err := EnableSession(deps.WindowManagerMango)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Empty(t, unit)
|
assert.Empty(t, unit)
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
|
|||||||
func (a *ArchDistribution) detectDanksearch() deps.Dependency {
|
func (a *ArchDistribution) detectDanksearch() deps.Dependency {
|
||||||
dep := a.BaseDistribution.detectDanksearch()
|
dep := a.BaseDistribution.detectDanksearch()
|
||||||
dep.CanToggle = true
|
dep.CanToggle = true
|
||||||
if a.packageInstalled("dsearch-git") {
|
if a.packageInstalled("dux-search-git") {
|
||||||
dep.Variant = deps.VariantGit
|
dep.Variant = deps.VariantGit
|
||||||
}
|
}
|
||||||
return dep
|
return dep
|
||||||
@@ -136,14 +136,14 @@ func (a *ArchDistribution) detectDanksearch() deps.Dependency {
|
|||||||
|
|
||||||
func (a *ArchDistribution) detectDMS() deps.Dependency {
|
func (a *ArchDistribution) detectDMS() deps.Dependency {
|
||||||
dep := a.BaseDistribution.detectDMS()
|
dep := a.BaseDistribution.detectDMS()
|
||||||
if a.packageInstalled("dms-shell-git") {
|
if a.packageInstalled("dux-shell-git") {
|
||||||
dep.Variant = deps.VariantGit
|
dep.Variant = deps.VariantGit
|
||||||
}
|
}
|
||||||
return dep
|
return dep
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) detectDankCalendar() deps.Dependency {
|
func (a *ArchDistribution) detectDankCalendar() deps.Dependency {
|
||||||
return withGitVariant(a.BaseDistribution.detectDankCalendar(), a.packageInstalled("dankcalendar-git"))
|
return withGitVariant(a.BaseDistribution.detectDankCalendar(), a.packageInstalled("dux-calendar-git"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) detectXDGPortal() deps.Dependency {
|
func (a *ArchDistribution) detectXDGPortal() deps.Dependency {
|
||||||
@@ -155,19 +155,17 @@ func (a *ArchDistribution) detectAccountsService() deps.Dependency {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) detectDMSGreeter() deps.Dependency {
|
func (a *ArchDistribution) detectDMSGreeter() deps.Dependency {
|
||||||
installed := a.packageInstalled("greetd-dms-greeter-git") || a.packageInstalled("greetd-dms-greeter-bin")
|
installed := a.packageInstalled("dux-greeter-git") || a.packageInstalled("dux-greeter-bin")
|
||||||
dep := a.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", installed)
|
dep := a.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", installed)
|
||||||
dep.CanToggle = true
|
dep.CanToggle = true
|
||||||
if a.packageInstalled("greetd-dms-greeter-git") {
|
if a.packageInstalled("dux-greeter-git") {
|
||||||
dep.Variant = deps.VariantGit
|
dep.Variant = deps.VariantGit
|
||||||
}
|
}
|
||||||
return dep
|
return dep
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) packageInstalled(pkg string) bool {
|
func (a *ArchDistribution) packageInstalled(pkg string) bool {
|
||||||
cmd := exec.Command("pacman", "-Q", pkg)
|
return exec.Command("pacman", "-Q", pkg).Run() == nil
|
||||||
err := cmd.Run()
|
|
||||||
return err == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSRCINFODeps reads a .SRCINFO file and returns runtime dep and makedep package
|
// parseSRCINFODeps reads a .SRCINFO file and returns runtime dep and makedep package
|
||||||
@@ -270,7 +268,7 @@ func (a *ArchDistribution) getMangoMapping(variant deps.PackageVariant) PackageM
|
|||||||
if variant == deps.VariantGit {
|
if variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "mangowm-git", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "mangowm-git", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "mangowm", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "mangowm", Repository: RepoTypeSystem}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) PackageMapping {
|
func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
@@ -286,30 +284,30 @@ func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) Packag
|
|||||||
|
|
||||||
func (a *ArchDistribution) getDanksearchMapping(variant deps.PackageVariant) PackageMapping {
|
func (a *ArchDistribution) getDanksearchMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if variant == deps.VariantGit {
|
if variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "dsearch-git", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "dux-search-git", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "dsearch-bin", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "dux-search-bin", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) getDankCalendarMapping(variant deps.PackageVariant) PackageMapping {
|
func (a *ArchDistribution) getDankCalendarMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if variant == deps.VariantGit {
|
if variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "dankcalendar-git", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "dux-calendar-git", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "dankcalendar-bin", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "dux-calendar-bin", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) getDMSGreeterMapping(variant deps.PackageVariant) PackageMapping {
|
func (a *ArchDistribution) getDMSGreeterMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if variant == deps.VariantGit {
|
if variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "greetd-dms-greeter-git", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "dux-greeter-git", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "greetd-dms-greeter-bin", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "dux-greeter-bin", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMapping {
|
func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if forceDMSGit || variant == deps.VariantGit {
|
if forceDMSGit || variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "dux-shell-git", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "dms-shell", Repository: RepoTypeSystem}
|
return PackageMapping{Name: "dux-shell", Repository: RepoTypeSystem}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency {
|
func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency {
|
||||||
@@ -396,7 +394,7 @@ func (a *ArchDistribution) InstallPackages(ctx context.Context, dependencies []d
|
|||||||
|
|
||||||
systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, skipFlags)
|
systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, skipFlags)
|
||||||
|
|
||||||
if slices.Contains(aurPkgs, "quickshell-git") && slices.Contains(systemPkgs, "dms-shell") {
|
if slices.Contains(aurPkgs, "quickshell-git") && slices.Contains(systemPkgs, "dux-shell") {
|
||||||
if err := a.preinstallQuickshellGit(ctx, sudoPassword, progressChan); err != nil {
|
if err := a.preinstallQuickshellGit(ctx, sudoPassword, progressChan); err != nil {
|
||||||
return fmt.Errorf("failed to preinstall quickshell-git: %w", err)
|
return fmt.Errorf("failed to preinstall quickshell-git: %w", err)
|
||||||
}
|
}
|
||||||
@@ -566,7 +564,7 @@ func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPass
|
|||||||
Progress: 0.18,
|
Progress: 0.18,
|
||||||
Step: "Building quickshell-git before system packages...",
|
Step: "Building quickshell-git before system packages...",
|
||||||
IsComplete: false,
|
IsComplete: false,
|
||||||
CommandInfo: "Installing quickshell-git ahead of dms-shell to avoid conflict",
|
CommandInfo: "Installing quickshell-git ahead of dux-shell to avoid conflict",
|
||||||
}
|
}
|
||||||
return a.installSingleAURPackage(ctx, "quickshell-git", sudoPassword, progressChan, 0.18, 0.32)
|
return a.installSingleAURPackage(ctx, "quickshell-git", sudoPassword, progressChan, 0.18, 0.32)
|
||||||
}
|
}
|
||||||
@@ -587,7 +585,8 @@ func (a *ArchDistribution) SwapVariants(ctx context.Context, swaps []VariantSwap
|
|||||||
}
|
}
|
||||||
|
|
||||||
if swap.To.Repository == RepoTypeAUR {
|
if swap.To.Repository == RepoTypeAUR {
|
||||||
if err := a.installSingleAURPackageInternal(ctx, swap.To.Name, swap.From.Name, sudoPassword, progressChan, 0.40, 0.85, make(map[string]bool)); err != nil {
|
from := swap.From.Name
|
||||||
|
if err := a.installSingleAURPackageInternal(ctx, swap.To.Name, from, sudoPassword, progressChan, 0.40, 0.85, make(map[string]bool)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -608,11 +607,7 @@ func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages [
|
|||||||
|
|
||||||
a.log(fmt.Sprintf("Installing system packages: %s", strings.Join(packages, ", ")))
|
a.log(fmt.Sprintf("Installing system packages: %s", strings.Join(packages, ", ")))
|
||||||
|
|
||||||
args := []string{"pacman", "-S", "--needed", "--noconfirm"}
|
args := append([]string{"pacman", "-S", "--needed", "--noconfirm"}, packages...)
|
||||||
if slices.Contains(packages, "dms-shell") {
|
|
||||||
args = append(args, "--assume-installed", "dms-shell-compositor=1")
|
|
||||||
}
|
|
||||||
args = append(args, packages...)
|
|
||||||
|
|
||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
Phase: PhaseSystemPackages,
|
Phase: PhaseSystemPackages,
|
||||||
@@ -658,7 +653,7 @@ func (a *ArchDistribution) installAURPackages(ctx context.Context, packages []st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reorder packages to ensure dms-shell-git dependencies are installed first
|
// Reorder packages to ensure dux-shell-git dependencies are installed first
|
||||||
orderedPackages := a.reorderAURPackages(packages)
|
orderedPackages := a.reorderAURPackages(packages)
|
||||||
|
|
||||||
baseProgress := 0.67
|
baseProgress := 0.67
|
||||||
@@ -699,7 +694,7 @@ func (a *ArchDistribution) reorderAURPackages(packages []string) []string {
|
|||||||
var dmsShell []string
|
var dmsShell []string
|
||||||
|
|
||||||
for _, pkg := range packages {
|
for _, pkg := range packages {
|
||||||
if pkg == "dms-shell-git" {
|
if pkg == "dux-shell-git" {
|
||||||
dmsShell = append(dmsShell, pkg)
|
dmsShell = append(dmsShell, pkg)
|
||||||
} else {
|
} else {
|
||||||
isDep := false
|
isDep := false
|
||||||
@@ -735,7 +730,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
|
|||||||
return fmt.Errorf("failed to get user home directory: %w", err)
|
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
buildDir := filepath.Join(homeDir, ".cache", "dankinstall", "aur-builds", pkg)
|
buildDir := filepath.Join(homeDir, ".cache", "duxinstall", "aur-builds", pkg)
|
||||||
|
|
||||||
// Clean up any existing cache first
|
// Clean up any existing cache first
|
||||||
if err := os.RemoveAll(buildDir); err != nil {
|
if err := os.RemoveAll(buildDir); err != nil {
|
||||||
@@ -781,17 +776,18 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if pkg == "dms-shell-git" {
|
if pkg == "dux-shell-git" {
|
||||||
srcinfoPath := filepath.Join(packageDir, ".SRCINFO")
|
srcinfoPath := filepath.Join(packageDir, ".SRCINFO")
|
||||||
depsToRemove := []string{
|
// extra ships dgop only as dux-gop (provides dgop), which makepkg resolves but pacman -Si does not
|
||||||
"depends = quickshell",
|
srcinfoEdits := []string{
|
||||||
"depends = dgop",
|
"/depends = quickshell/d",
|
||||||
|
`s/^\(\s*depends = \)dgop$/\1dux-gop/`,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, dep := range depsToRemove {
|
for _, edit := range srcinfoEdits {
|
||||||
sedCmd := exec.CommandContext(ctx, "sed", "-i", fmt.Sprintf("/%s/d", dep), srcinfoPath)
|
sedCmd := exec.CommandContext(ctx, "sed", "-i", edit, srcinfoPath)
|
||||||
if err := sedCmd.Run(); err != nil {
|
if err := sedCmd.Run(); err != nil {
|
||||||
return fmt.Errorf("failed to remove dependency %s from .SRCINFO for %s: %w", dep, pkg, err)
|
return fmt.Errorf("failed to patch .SRCINFO for %s: %w", pkg, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -502,7 +502,7 @@ func (b *BaseDistribution) runWithProgressStepTimeout(cmd *exec.Cmd, progressCha
|
|||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
Phase: phase,
|
Phase: phase,
|
||||||
Progress: endProgress,
|
Progress: endProgress,
|
||||||
Step: "Installation step complete",
|
Step: stepMessage,
|
||||||
IsComplete: false,
|
IsComplete: false,
|
||||||
LogOutput: lastOutput,
|
LogOutput: lastOutput,
|
||||||
}
|
}
|
||||||
@@ -541,7 +541,7 @@ func (b *BaseDistribution) runWithProgressStepTimeout(cmd *exec.Cmd, progressCha
|
|||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
Phase: phase,
|
Phase: phase,
|
||||||
Progress: progress,
|
Progress: progress,
|
||||||
Step: "Installing...",
|
Step: stepMessage,
|
||||||
IsComplete: false,
|
IsComplete: false,
|
||||||
LogOutput: lastOutput,
|
LogOutput: lastOutput,
|
||||||
}
|
}
|
||||||
@@ -659,7 +659,7 @@ func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword st
|
|||||||
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)
|
||||||
}
|
}
|
||||||
tmpDir := filepath.Join(homeDir, ".cache", "dankinstall", "manual-builds")
|
tmpDir := filepath.Join(homeDir, ".cache", "duxinstall", "manual-builds")
|
||||||
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
|
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("failed to create temp directory: %w", err)
|
return fmt.Errorf("failed to create temp directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,11 +159,11 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
|||||||
// DMS packages from OBS with variant support
|
// DMS packages from OBS with variant support
|
||||||
"dms (Dux Shell)": d.getDmsMapping(variants["dms (Dux Shell)"]),
|
"dms (Dux Shell)": d.getDmsMapping(variants["dms (Dux Shell)"]),
|
||||||
"quickshell": d.getQuickshellMapping(variants["quickshell"]),
|
"quickshell": d.getQuickshellMapping(variants["quickshell"]),
|
||||||
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeOBS, "home:AvengeMedia:danklinux"),
|
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeOBS, obsDeps),
|
||||||
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: obsDeps},
|
||||||
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: obsDeps},
|
||||||
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: obsDeps},
|
||||||
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeOBS, "home:AvengeMedia:danklinux"),
|
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeOBS, obsDeps),
|
||||||
}
|
}
|
||||||
|
|
||||||
if wm == deps.WindowManagerNiri {
|
if wm == deps.WindowManagerNiri {
|
||||||
@@ -177,14 +177,14 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
|||||||
|
|
||||||
func (d *DebianDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping {
|
func (d *DebianDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if variant == deps.VariantGit {
|
if variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "dms-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:dms-git"}
|
return PackageMapping{Name: "dms-git", Repository: RepoTypeOBS, RepoURL: obsDMSGit}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "dms", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:dms"}
|
return PackageMapping{Name: "dms", Repository: RepoTypeOBS, RepoURL: obsDMS}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DebianDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping {
|
func (d *DebianDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
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: obsDeps}
|
||||||
}
|
}
|
||||||
// Debian 13 ships stable quickshell in trixie-backports only.
|
// Debian 13 ships stable quickshell in trixie-backports only.
|
||||||
if d.debianNeedsQuickshellBackports() {
|
if d.debianNeedsQuickshellBackports() {
|
||||||
@@ -244,16 +244,16 @@ func (d *DebianDistribution) ensureQuickshellBackports(ctx context.Context, syst
|
|||||||
|
|
||||||
func (d *DebianDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
|
func (d *DebianDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if variant == deps.VariantGit {
|
if variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "niri-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}
|
return PackageMapping{Name: "niri-git", Repository: RepoTypeOBS, RepoURL: obsDeps}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "niri", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}
|
return PackageMapping{Name: "niri", Repository: RepoTypeOBS, RepoURL: obsDeps}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DebianDistribution) getXwaylandSatelliteMapping(variant deps.PackageVariant) PackageMapping {
|
func (d *DebianDistribution) getXwaylandSatelliteMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if variant == deps.VariantGit {
|
if variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "xwayland-satellite-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}
|
return PackageMapping{Name: "xwayland-satellite-git", Repository: RepoTypeOBS, RepoURL: obsDeps}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}
|
return PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeOBS, RepoURL: obsDeps}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
@@ -491,6 +491,62 @@ func (d *DebianDistribution) aptInstallArgs(packages []string, minimal bool) []s
|
|||||||
return append(args, packages...)
|
return append(args, packages...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *DebianDistribution) RepoMoves() []RepoMove {
|
||||||
|
if !obsMigrationReady || d.isPika() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return obsAptChannel.moves(repoPathExists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DebianDistribution) ApplyRepoMoves(ctx context.Context, moves []RepoMove, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
|
if err := d.enableOBSRepos(ctx, repoMoveAdds(moves), sudoPassword, progressChan); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
removes := repoMoveRemoves(moves)
|
||||||
|
for _, move := range removes {
|
||||||
|
command := fmt.Sprintf("rm -f %s %s", obsAptListFile(move.Old), obsAptKeyring(move.Old))
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseSystemPackages,
|
||||||
|
Progress: 0.30,
|
||||||
|
Step: fmt.Sprintf("Removing OBS repo %s...", move.Old),
|
||||||
|
NeedsSudo: true,
|
||||||
|
CommandInfo: "sudo " + command,
|
||||||
|
}
|
||||||
|
if err := d.runWithProgress(privescExec(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.30, 0.35); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove OBS repo %s: %w", move.Old, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(removes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return d.aptUpdate(ctx, sudoPassword, progressChan)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DebianDistribution) RepoMoveCommands(moves []RepoMove) []string {
|
||||||
|
osInfo, err := GetOSInfo()
|
||||||
|
if err != nil {
|
||||||
|
osInfo = &OSInfo{}
|
||||||
|
}
|
||||||
|
return debianRepoMoveCommands(moves, debianOBSRepoVersion(osInfo, readDebianAPTSuites()), debianRepoArchitecture(osInfo.Architecture))
|
||||||
|
}
|
||||||
|
|
||||||
|
func debianRepoMoveCommands(moves []RepoMove, dist, arch string) []string {
|
||||||
|
var commands []string
|
||||||
|
for _, add := range repoMoveAdds(moves) {
|
||||||
|
baseURL := obsBaseURL(add.RepoURL, dist)
|
||||||
|
commands = append(commands,
|
||||||
|
fmt.Sprintf("curl -fsSL %s/Release.key | gpg --dearmor | sudo tee %s", baseURL, obsAptKeyring(add.RepoURL)),
|
||||||
|
fmt.Sprintf("echo 'deb [signed-by=%s arch=%s] %s/ /' | sudo tee %s", obsAptKeyring(add.RepoURL), arch, baseURL, obsAptListFile(add.RepoURL)))
|
||||||
|
}
|
||||||
|
for _, move := range repoMoveRemoves(moves) {
|
||||||
|
commands = append(commands, fmt.Sprintf("sudo rm -f %s %s", obsAptListFile(move.Old), obsAptKeyring(move.Old)))
|
||||||
|
}
|
||||||
|
if len(commands) > 0 {
|
||||||
|
commands = append(commands, "sudo apt-get update")
|
||||||
|
}
|
||||||
|
return commands
|
||||||
|
}
|
||||||
|
|
||||||
func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
enabledRepos := make(map[string]bool)
|
enabledRepos := make(map[string]bool)
|
||||||
|
|
||||||
@@ -506,20 +562,17 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa
|
|||||||
if pkg.RepoURL != "" && !enabledRepos[pkg.RepoURL] {
|
if pkg.RepoURL != "" && !enabledRepos[pkg.RepoURL] {
|
||||||
d.log(fmt.Sprintf("Enabling OBS repository: %s", pkg.RepoURL))
|
d.log(fmt.Sprintf("Enabling OBS repository: %s", pkg.RepoURL))
|
||||||
|
|
||||||
// RepoURL format: "home:AvengeMedia:danklinux"
|
baseURL := obsBaseURL(pkg.RepoURL, obsRepo)
|
||||||
repoPath := strings.ReplaceAll(pkg.RepoURL, ":", ":/")
|
|
||||||
repoName := strings.ReplaceAll(pkg.RepoURL, ":", "-")
|
|
||||||
baseURL := fmt.Sprintf("https://download.opensuse.org/repositories/%s/%s", repoPath, obsRepo)
|
|
||||||
|
|
||||||
// Check if repository already exists
|
// Check if repository already exists
|
||||||
listFile := fmt.Sprintf("/etc/apt/sources.list.d/%s.list", repoName)
|
listFile := obsAptListFile(pkg.RepoURL)
|
||||||
if debianOBSListFileMatches(listFile, obsRepo) {
|
if debianOBSListFileMatches(listFile, obsRepo) {
|
||||||
d.log(fmt.Sprintf("OBS repo %s already exists, skipping", pkg.RepoURL))
|
d.log(fmt.Sprintf("OBS repo %s already exists, skipping", pkg.RepoURL))
|
||||||
enabledRepos[pkg.RepoURL] = true
|
enabledRepos[pkg.RepoURL] = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
keyringPath := fmt.Sprintf("/etc/apt/keyrings/%s.gpg", repoName)
|
keyringPath := obsAptKeyring(pkg.RepoURL)
|
||||||
|
|
||||||
// Create keyrings directory if it doesn't exist
|
// Create keyrings directory if it doesn't exist
|
||||||
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings")
|
mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings")
|
||||||
|
|||||||
+174
-29
@@ -3,6 +3,7 @@ package distros
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -23,9 +24,6 @@ func init() {
|
|||||||
Register("fedora-asahi-remix", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
|
Register("fedora-asahi-remix", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
|
||||||
return NewFedoraDistribution(config, logChan)
|
return NewFedoraDistribution(config, logChan)
|
||||||
})
|
})
|
||||||
Register("bluefin", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
|
|
||||||
return NewFedoraDistribution(config, logChan)
|
|
||||||
})
|
|
||||||
Register("ultramarine", "#00078b", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
|
Register("ultramarine", "#00078b", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
|
||||||
return NewFedoraDistribution(config, logChan)
|
return NewFedoraDistribution(config, logChan)
|
||||||
})
|
})
|
||||||
@@ -54,6 +52,8 @@ type FedoraDistribution struct {
|
|||||||
*BaseDistribution
|
*BaseDistribution
|
||||||
*ManualPackageInstaller
|
*ManualPackageInstaller
|
||||||
config DistroConfig
|
config DistroConfig
|
||||||
|
|
||||||
|
rpmInstalled func(pkg string) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFedoraDistribution(config DistroConfig, logChan chan<- string) *FedoraDistribution {
|
func NewFedoraDistribution(config DistroConfig, logChan chan<- string) *FedoraDistribution {
|
||||||
@@ -138,6 +138,9 @@ func (f *FedoraDistribution) detectXDGPortal() deps.Dependency {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *FedoraDistribution) packageInstalled(pkg string) bool {
|
func (f *FedoraDistribution) packageInstalled(pkg string) bool {
|
||||||
|
if f.rpmInstalled != nil {
|
||||||
|
return f.rpmInstalled(pkg)
|
||||||
|
}
|
||||||
cmd := exec.Command("rpm", "-q", pkg)
|
cmd := exec.Command("rpm", "-q", pkg)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
return err == nil
|
return err == nil
|
||||||
@@ -150,19 +153,31 @@ func (f *FedoraDistribution) InstalledVariant(stable, git PackageMapping) (deps.
|
|||||||
if !f.packageInstalled(stable.Name) {
|
if !f.packageInstalled(stable.Name) {
|
||||||
return deps.VariantStable, false
|
return deps.VariantStable, false
|
||||||
}
|
}
|
||||||
if git.Repository == RepoTypeCOPR && fedoraInstalledFromCOPR(stable.Name, git.RepoURL) {
|
if git.Repository == RepoTypeCOPR && installedFromCOPR(stable.Name, git.RepoURL) {
|
||||||
return deps.VariantGit, true
|
return deps.VariantGit, true
|
||||||
}
|
}
|
||||||
return deps.VariantStable, true
|
return deps.VariantStable, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var installedFromCOPR = fedoraInstalledFromCOPR
|
||||||
|
|
||||||
// fedoraInstalledFromCOPR tells same-named variants apart (dms, niri), which only differ by COPR.
|
// fedoraInstalledFromCOPR tells same-named variants apart (dms, niri), which only differ by COPR.
|
||||||
func fedoraInstalledFromCOPR(pkg, coprRepo string) bool {
|
func fedoraInstalledFromCOPR(pkg, coprRepo string) bool {
|
||||||
out, err := exec.Command("dnf", "repoquery", "--installed", "--qf", "%{from_repo}", pkg).Output()
|
out, err := exec.Command("dnf", "repoquery", "--installed", "--qf", "%{from_repo}", pkg).Output()
|
||||||
|
if err != nil {
|
||||||
|
return rpmLooksLikeGitBuild(pkg)
|
||||||
|
}
|
||||||
|
return coprFromRepoMatches(string(out), coprRepo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// niri-git carries "git" in its version; dms-git only differs by its Epoch of 2.
|
||||||
|
func rpmLooksLikeGitBuild(pkg string) bool {
|
||||||
|
out, err := exec.Command("rpm", "-q", "--qf", "%{EPOCH} %{VERSION}-%{RELEASE}", pkg).Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return strings.HasSuffix(strings.TrimSpace(string(out)), ":"+strings.ReplaceAll(coprRepo, "/", ":"))
|
epoch, versionRelease, _ := strings.Cut(strings.TrimSpace(string(out)), " ")
|
||||||
|
return strings.Contains(versionRelease, "git") || (pkg == "dms" && epoch == "2")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *FedoraDistribution) SwapVariants(ctx context.Context, swaps []VariantSwap, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
func (f *FedoraDistribution) SwapVariants(ctx context.Context, swaps []VariantSwap, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
@@ -172,15 +187,20 @@ func (f *FedoraDistribution) SwapVariants(ctx context.Context, swaps []VariantSw
|
|||||||
|
|
||||||
for _, swap := range swaps {
|
for _, swap := range swaps {
|
||||||
command := fmt.Sprintf("dnf swap -y %s %s", swap.From.Name, swap.To.Name)
|
command := fmt.Sprintf("dnf swap -y %s %s", swap.From.Name, swap.To.Name)
|
||||||
disabledRepo := ""
|
var disabledRepos []string
|
||||||
if swap.From.Name == swap.To.Name {
|
if swap.From.Name == swap.To.Name {
|
||||||
// Left enabled, the old COPR wins distro-sync whenever its version sorts higher (dms-git has Epoch 2).
|
// Left enabled, the old COPR wins distro-sync whenever its version sorts higher (dms-git has Epoch 2).
|
||||||
|
// An un-migrated host still has it under the legacy owner, so every alias goes.
|
||||||
if swap.From.Repository == RepoTypeCOPR {
|
if swap.From.Repository == RepoTypeCOPR {
|
||||||
disable := privesc.ExecCommand(ctx, sudoPassword, "dnf copr disable -y "+swap.From.RepoURL)
|
for _, repo := range coprAliases(swap.From.RepoURL) {
|
||||||
if err := f.runWithProgress(disable, progressChan, PhaseSystemPackages, 0.30, 0.35); err != nil {
|
if !repoPathExists(coprRepoFile(repo)) {
|
||||||
return fmt.Errorf("failed to disable COPR repo %s: %w", swap.From.RepoURL, err)
|
continue
|
||||||
|
}
|
||||||
|
if err := f.coprRepoOp(ctx, "disable", repo, sudoPassword, progressChan); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
disabledRepos = append(disabledRepos, repo)
|
||||||
}
|
}
|
||||||
disabledRepo = swap.From.RepoURL
|
|
||||||
}
|
}
|
||||||
command = "dnf distro-sync -y " + swap.To.Name
|
command = "dnf distro-sync -y " + swap.To.Name
|
||||||
}
|
}
|
||||||
@@ -192,11 +212,10 @@ func (f *FedoraDistribution) SwapVariants(ctx context.Context, swaps []VariantSw
|
|||||||
NeedsSudo: true,
|
NeedsSudo: true,
|
||||||
CommandInfo: "sudo " + command,
|
CommandInfo: "sudo " + command,
|
||||||
}
|
}
|
||||||
if err := f.runWithProgress(privesc.ExecCommand(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.40, 0.85); err != nil {
|
if err := f.runWithProgress(privescExec(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.40, 0.85); err != nil {
|
||||||
if disabledRepo != "" {
|
for _, repo := range disabledRepos {
|
||||||
reenable := privesc.ExecCommand(ctx, sudoPassword, "dnf copr enable -y "+disabledRepo)
|
if reErr := privescExec(ctx, sudoPassword, "dnf copr enable -y "+repo).Run(); reErr != nil {
|
||||||
if reErr := reenable.Run(); reErr != nil {
|
f.log(fmt.Sprintf("Warning: could not re-enable COPR repo %s: %v", repo, reErr))
|
||||||
f.log(fmt.Sprintf("Warning: could not re-enable COPR repo %s: %v", disabledRepo, reErr))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fmt.Errorf("failed to switch %s to %s: %w", swap.From.Name, swap.To.Name, err)
|
return fmt.Errorf("failed to switch %s to %s: %w", swap.From.Name, swap.To.Name, err)
|
||||||
@@ -205,6 +224,60 @@ func (f *FedoraDistribution) SwapVariants(ctx context.Context, swaps []VariantSw
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *FedoraDistribution) coprRepoOp(ctx context.Context, verb, repo, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
|
command := fmt.Sprintf("dnf copr %s -y %s", verb, repo)
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseSystemPackages,
|
||||||
|
Progress: 0.30,
|
||||||
|
Step: fmt.Sprintf("Running dnf copr %s %s...", verb, repo),
|
||||||
|
NeedsSudo: true,
|
||||||
|
CommandInfo: "sudo " + command,
|
||||||
|
}
|
||||||
|
if err := f.runWithProgress(privescExec(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.30, 0.35); err != nil {
|
||||||
|
return fmt.Errorf("failed to %s COPR repo %s: %w", verb, repo, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FedoraDistribution) RepoMoves() []RepoMove {
|
||||||
|
return coprChannel.moves(repoPathExists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FedoraDistribution) ApplyRepoMoves(ctx context.Context, moves []RepoMove, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
|
if err := f.enableCOPRRepos(ctx, repoMoveAdds(moves), sudoPassword, progressChan); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, move := range repoMoveRemoves(moves) {
|
||||||
|
if err := f.coprRepoOp(ctx, "remove", move.Old, sudoPassword, progressChan); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FedoraDistribution) RepoMoveCommands(moves []RepoMove) []string {
|
||||||
|
var commands []string
|
||||||
|
for _, add := range repoMoveAdds(moves) {
|
||||||
|
commands = append(commands, "sudo dnf copr enable -y "+add.RepoURL)
|
||||||
|
}
|
||||||
|
for _, move := range repoMoveRemoves(moves) {
|
||||||
|
commands = append(commands, "sudo dnf copr remove -y "+move.Old)
|
||||||
|
}
|
||||||
|
return commands
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shown to the user, never run: image-based Fedora has no dnf copr.
|
||||||
|
func (f *FedoraDistribution) ImageRepoMoveCommands(moves []RepoMove) []string {
|
||||||
|
var commands []string
|
||||||
|
for _, add := range repoMoveAdds(moves) {
|
||||||
|
commands = append(commands, f.coprRepoFileCommand(add.RepoURL))
|
||||||
|
}
|
||||||
|
for _, move := range repoMoveRemoves(moves) {
|
||||||
|
commands = append(commands, "sudo rm -f "+move.OldPath)
|
||||||
|
}
|
||||||
|
return commands
|
||||||
|
}
|
||||||
|
|
||||||
func (f *FedoraDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
|
func (f *FedoraDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
|
||||||
return f.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
|
return f.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
|
||||||
}
|
}
|
||||||
@@ -213,7 +286,7 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
|||||||
packages := map[string]PackageMapping{
|
packages := map[string]PackageMapping{
|
||||||
// Standard DNF packages
|
// Standard DNF packages
|
||||||
"git": {Name: "git", Repository: RepoTypeSystem},
|
"git": {Name: "git", Repository: RepoTypeSystem},
|
||||||
"ghostty": {Name: "ghostty", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
"ghostty": {Name: "ghostty", Repository: RepoTypeCOPR, RepoURL: coprDeps},
|
||||||
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
|
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
|
||||||
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
|
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
|
||||||
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
|
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
|
||||||
@@ -221,11 +294,11 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
|||||||
|
|
||||||
// COPR packages
|
// COPR packages
|
||||||
"quickshell": f.getQuickshellMapping(variants["quickshell"]),
|
"quickshell": f.getQuickshellMapping(variants["quickshell"]),
|
||||||
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeCOPR, "avengemedia/danklinux"),
|
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeCOPR, coprDeps),
|
||||||
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: coprDeps},
|
||||||
"dms (Dux Shell)": f.getDmsMapping(variants["dms (Dux Shell)"]),
|
"dms (Dux Shell)": f.getDmsMapping(variants["dms (Dux Shell)"]),
|
||||||
"danksearch": {Name: "danksearch", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
"danksearch": {Name: "danksearch", Repository: RepoTypeCOPR, RepoURL: coprDeps},
|
||||||
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeCOPR, "avengemedia/danklinux"),
|
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeCOPR, coprDeps),
|
||||||
}
|
}
|
||||||
|
|
||||||
switch wm {
|
switch wm {
|
||||||
@@ -244,10 +317,10 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
if f.isEL() {
|
if f.isEL() {
|
||||||
// EPEL 10 has neither, so the danklinux COPR builds them for the epel-10 chroots.
|
// EPEL 10 has neither, so the deps COPR builds them for the epel-10 chroots.
|
||||||
for _, name := range []string{"alacritty", "xwayland-satellite"} {
|
for _, name := range []string{"alacritty", "xwayland-satellite"} {
|
||||||
if _, ok := packages[name]; ok {
|
if _, ok := packages[name]; ok {
|
||||||
packages[name] = PackageMapping{Name: name, Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}
|
packages[name] = PackageMapping{Name: name, Repository: RepoTypeCOPR, RepoURL: coprDeps}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,16 +330,16 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
|||||||
|
|
||||||
func (f *FedoraDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping {
|
func (f *FedoraDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if forceQuickshellGit || variant == deps.VariantGit {
|
if forceQuickshellGit || variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}
|
return PackageMapping{Name: "quickshell-git", Repository: RepoTypeCOPR, RepoURL: coprDeps}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "quickshell", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}
|
return PackageMapping{Name: "quickshell", Repository: RepoTypeCOPR, RepoURL: coprDeps}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *FedoraDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping {
|
func (f *FedoraDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if variant == deps.VariantGit {
|
if variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "dms", Repository: RepoTypeCOPR, RepoURL: "avengemedia/dms-git"}
|
return PackageMapping{Name: "dms", Repository: RepoTypeCOPR, RepoURL: coprDMSGit}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "dms", Repository: RepoTypeCOPR, RepoURL: "avengemedia/dms"}
|
return PackageMapping{Name: "dms", Repository: RepoTypeCOPR, RepoURL: coprDMS}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
|
func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
|
||||||
@@ -633,7 +706,7 @@ func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []Pac
|
|||||||
CommandInfo: fmt.Sprintf("sudo dnf copr enable -y %s", pkg.RepoURL),
|
CommandInfo: fmt.Sprintf("sudo dnf copr enable -y %s", pkg.RepoURL),
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := privesc.ExecCommand(ctx, sudoPassword,
|
cmd := privescExec(ctx, sudoPassword,
|
||||||
fmt.Sprintf("dnf copr enable -y %s 2>&1", pkg.RepoURL))
|
fmt.Sprintf("dnf copr enable -y %s 2>&1", pkg.RepoURL))
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -647,7 +720,7 @@ func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []Pac
|
|||||||
// Special handling for niri COPR repo - set priority=1
|
// Special handling for niri COPR repo - set priority=1
|
||||||
if pkg.RepoURL == "yalter/niri-git" {
|
if pkg.RepoURL == "yalter/niri-git" {
|
||||||
f.log("Setting priority=1 for niri-git COPR repo...")
|
f.log("Setting priority=1 for niri-git COPR repo...")
|
||||||
repoFile := "/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:yalter:niri-git.repo"
|
repoFile := coprRepoFile("yalter/niri-git")
|
||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
Phase: PhaseSystemPackages,
|
Phase: PhaseSystemPackages,
|
||||||
Progress: 0.22,
|
Progress: 0.22,
|
||||||
@@ -657,7 +730,7 @@ func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []Pac
|
|||||||
CommandInfo: fmt.Sprintf("echo \"priority=1\" | sudo tee -a %s", repoFile),
|
CommandInfo: fmt.Sprintf("echo \"priority=1\" | sudo tee -a %s", repoFile),
|
||||||
}
|
}
|
||||||
|
|
||||||
priorityCmd := privesc.ExecCommand(ctx, sudoPassword,
|
priorityCmd := privescExec(ctx, sudoPassword,
|
||||||
fmt.Sprintf("bash -c 'echo \"priority=1\" | tee -a %s'", repoFile))
|
fmt.Sprintf("bash -c 'echo \"priority=1\" | tee -a %s'", repoFile))
|
||||||
priorityOutput, err := priorityCmd.CombinedOutput()
|
priorityOutput, err := priorityCmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -744,3 +817,75 @@ func (f *FedoraDistribution) installDNFGroups(ctx context.Context, packages []st
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Image-based Fedora has rpm-ostree but no dnf, so the .repo file is fetched directly.
|
||||||
|
func (f *FedoraDistribution) coprRepoFileCommand(repo string) string {
|
||||||
|
owner, project, _ := strings.Cut(repo, "/")
|
||||||
|
chroot := "fedora-$(rpm -E %fedora)"
|
||||||
|
if f.isEL() {
|
||||||
|
chroot = "epel-$(rpm -E %rhel)"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("sudo curl -fsSLo %s https://copr.fedorainfracloud.org/coprs/%s/%s/repo/%s/%s-%s-%s.repo",
|
||||||
|
coprRepoFile(repo), owner, project, chroot, owner, project, chroot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shown to the user, never run. Terra-only packages have no rpm-ostree path.
|
||||||
|
func (f *FedoraDistribution) LayerCommands(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags, disabledFlags map[string]bool) []string {
|
||||||
|
var commands []string
|
||||||
|
var uninstall []string
|
||||||
|
skip := maps.Clone(disabledFlags)
|
||||||
|
if skip == nil {
|
||||||
|
skip = map[string]bool{}
|
||||||
|
}
|
||||||
|
// rpm-ostree refuses to install what is already layered, so a reinstall mark only means a variant switch.
|
||||||
|
for _, dep := range dependencies {
|
||||||
|
if !reinstallFlags[dep.Name] || skip[dep.Name] || dep.Status != deps.StatusInstalled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
swap, status := PlanVariantSwap(f, wm, dep.Name, dep.Variant)
|
||||||
|
if status != SwapNeeded {
|
||||||
|
skip[dep.Name] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uninstall = append(uninstall, swap.From.Name)
|
||||||
|
if swap.From.Name == swap.To.Name && swap.From.Repository == RepoTypeCOPR {
|
||||||
|
var files []string
|
||||||
|
for _, repo := range coprAliases(swap.From.RepoURL) {
|
||||||
|
files = append(files, coprRepoFile(repo))
|
||||||
|
}
|
||||||
|
commands = append(commands, "sudo rm -f "+strings.Join(files, " "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dnfPkgs, coprPkgs, manualPkgs, _ := f.categorizePackages(dependencies, wm, reinstallFlags, skip)
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
pkgs := append([]string{}, dnfPkgs...)
|
||||||
|
for _, pkg := range coprPkgs {
|
||||||
|
pkgs = append(pkgs, pkg.Name)
|
||||||
|
if pkg.RepoURL == "" || seen[pkg.RepoURL] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[pkg.RepoURL] = true
|
||||||
|
commands = append(commands, f.coprRepoFileCommand(pkg.RepoURL))
|
||||||
|
if pkg.RepoURL == "yalter/niri-git" {
|
||||||
|
commands = append(commands, "echo priority=1 | sudo tee -a "+coprRepoFile("yalter/niri-git"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, dep := range dependencies {
|
||||||
|
if dep.Status == deps.StatusNeedsUpdate && !disabledFlags[dep.Name] {
|
||||||
|
commands = append(commands, "sudo rpm-ostree upgrade")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(pkgs) > 0 {
|
||||||
|
install := "sudo rpm-ostree install " + strings.Join(pkgs, " ")
|
||||||
|
if len(uninstall) > 0 {
|
||||||
|
install += " --uninstall " + strings.Join(uninstall, " ")
|
||||||
|
}
|
||||||
|
commands = append(commands, install)
|
||||||
|
}
|
||||||
|
for _, name := range manualPkgs {
|
||||||
|
commands = append(commands, fmt.Sprintf("# %s has no package for image-based Fedora", name))
|
||||||
|
}
|
||||||
|
return append(commands, "systemctl reboot")
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
func TestFedoraPackageMapping_EnterpriseLinuxUsesCOPRForMissingEPELPackages(t *testing.T) {
|
func TestFedoraPackageMapping_EnterpriseLinuxUsesCOPRForMissingEPELPackages(t *testing.T) {
|
||||||
el := NewFedoraDistribution(Registry["rhel"], nil).GetPackageMapping(deps.WindowManagerNiri)
|
el := NewFedoraDistribution(Registry["rhel"], nil).GetPackageMapping(deps.WindowManagerNiri)
|
||||||
for _, name := range []string{"alacritty", "xwayland-satellite"} {
|
for _, name := range []string{"alacritty", "xwayland-satellite"} {
|
||||||
if el[name].Repository != RepoTypeCOPR || el[name].RepoURL != "avengemedia/danklinux" {
|
if el[name].Repository != RepoTypeCOPR || el[name].RepoURL != "duxlabs/dux-repo" {
|
||||||
t.Errorf("rhel %s = %+v, want avengemedia/danklinux COPR", name, el[name])
|
t.Errorf("rhel %s = %+v, want duxlabs/dux-repo COPR", name, el[name])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package distros
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/site"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Markers only reached when /run/ostree-booted is absent, such as a chroot.
|
||||||
|
var immutableIDs = map[string]bool{
|
||||||
|
"bluefin": true,
|
||||||
|
"bazzite": true,
|
||||||
|
"aurora": true,
|
||||||
|
"secureblue": true,
|
||||||
|
"zirconium": true,
|
||||||
|
"fedora-iot": true,
|
||||||
|
"fedora-coreos": true,
|
||||||
|
"opensuse-microos": true,
|
||||||
|
"opensuse-aeon": true,
|
||||||
|
"steamos": true,
|
||||||
|
"org.gnome.os": true,
|
||||||
|
"blendos": true,
|
||||||
|
"arkane": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var immutableVariantMarkers = []string{"silverblue", "kinoite", "sericea", "onyx", "atomic", "coreos", "iot"}
|
||||||
|
|
||||||
|
var immutableMarkerFiles = []struct {
|
||||||
|
path string
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{"/run/ostree-booted", "ostree-based system"},
|
||||||
|
{"/usr/sbin/transactional-update", "transactional-update system"},
|
||||||
|
{"/usr/bin/abroot", "ABRoot system"},
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZirconiumSwitchCommand = "sudo bootc switch ghcr.io/zirconium-dev/zirconium:latest"
|
||||||
|
|
||||||
|
// The commands are shown to the user, never run.
|
||||||
|
type PackageLayerer interface {
|
||||||
|
LayerCommands(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags, disabledFlags map[string]bool) []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func DetectImmutable() (bool, string) {
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
return detectImmutable(fs, readOSRelease(fs))
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectImmutable(fs afero.Fs, osRelease map[string]string) (bool, string) {
|
||||||
|
for _, marker := range immutableMarkerFiles {
|
||||||
|
if exists, _ := afero.Exists(fs, marker.path); exists {
|
||||||
|
return true, fmt.Sprintf("%s (%s)", marker.reason, marker.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strings.ToLower(osRelease["ID"])
|
||||||
|
if immutableIDs[id] {
|
||||||
|
return true, "os-release ID=" + id
|
||||||
|
}
|
||||||
|
|
||||||
|
variantID := strings.ToLower(osRelease["VARIANT_ID"])
|
||||||
|
for _, marker := range immutableVariantMarkers {
|
||||||
|
if strings.Contains(variantID, marker) {
|
||||||
|
return true, "os-release VARIANT_ID=" + variantID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, like := range strings.Fields(strings.ToLower(osRelease["ID_LIKE"])) {
|
||||||
|
if like == "microos" || like == "ostree" {
|
||||||
|
return true, "os-release ID_LIKE=" + like
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func ImmutableGuidance(info *OSInfo) string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "%s is an image-based system: %s.\n\n", info.PrettyName, info.ImmutableReason)
|
||||||
|
b.WriteString("duxinstall has no package mapping for this image, so it cannot tell what is installed or deploy configuration for it. Nothing was changed on this system.\n\n")
|
||||||
|
b.WriteString("See " + site.Docs + "/getting-started for other options.")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func PendingDependencies(dependencies []deps.Dependency, reinstallFlags, disabledFlags map[string]bool) []deps.Dependency {
|
||||||
|
var pending []deps.Dependency
|
||||||
|
for _, dep := range dependencies {
|
||||||
|
if disabledFlags[dep.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pending = append(pending, dep)
|
||||||
|
}
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectDMS keys off the clone directory; packaged hosts only have /usr/bin/dms.
|
||||||
|
func ApplyImmutableDetection(dependencies []deps.Dependency) []deps.Dependency {
|
||||||
|
if _, err := exec.LookPath("dms"); err != nil {
|
||||||
|
return dependencies
|
||||||
|
}
|
||||||
|
for i := range dependencies {
|
||||||
|
if dependencies[i].Name != "dms (Dux Shell)" || dependencies[i].Status != deps.StatusMissing {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dependencies[i].Status = deps.StatusInstalled
|
||||||
|
// "dms (git) v2.0.0-5333" or "dms v2.0.0"
|
||||||
|
out, err := exec.Command("dms", "version").Output()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(string(out))
|
||||||
|
if len(fields) > 0 {
|
||||||
|
dependencies[i].Version = fields[len(fields)-1]
|
||||||
|
}
|
||||||
|
if strings.Contains(string(out), "(git)") {
|
||||||
|
dependencies[i].Variant = deps.VariantGit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dependencies
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package distros
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeOSRelease(t *testing.T, fs afero.Fs, body string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := afero.WriteFile(fs, "/etc/os-release", []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectImmutable(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
files []string
|
||||||
|
osRelease map[string]string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"ostree marker beats plain fedora id", []string{"/run/ostree-booted"}, map[string]string{"ID": "fedora"}, true},
|
||||||
|
{"silverblue variant without marker", nil, map[string]string{"ID": "fedora", "VARIANT_ID": "silverblue"}, true},
|
||||||
|
{"sway atomic variant", nil, map[string]string{"ID": "fedora", "VARIANT_ID": "sway-atomic"}, true},
|
||||||
|
{"workstation fedora", nil, map[string]string{"ID": "fedora", "VARIANT_ID": "workstation"}, false},
|
||||||
|
{"bazzite id", nil, map[string]string{"ID": "bazzite", "ID_LIKE": "fedora"}, true},
|
||||||
|
{"microos", []string{"/usr/sbin/transactional-update"}, map[string]string{"ID": "opensuse-microos"}, true},
|
||||||
|
{"aeon by id_like", nil, map[string]string{"ID": "opensuse-aeon-rc", "ID_LIKE": "suse opensuse microos"}, true},
|
||||||
|
{"tumbleweed", nil, map[string]string{"ID": "opensuse-tumbleweed", "ID_LIKE": "suse opensuse"}, false},
|
||||||
|
{"vanilla by abroot", []string{"/usr/bin/abroot"}, map[string]string{"ID": "ubuntu", "ID_LIKE": "debian"}, true},
|
||||||
|
{"steamos", nil, map[string]string{"ID": "steamos", "ID_LIKE": "arch"}, true},
|
||||||
|
{"arch", nil, map[string]string{"ID": "arch"}, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
for _, f := range c.files {
|
||||||
|
if err := afero.WriteFile(fs, f, nil, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, reason := detectImmutable(fs, c.osRelease)
|
||||||
|
if got != c.want {
|
||||||
|
t.Fatalf("detectImmutable = %v (%q), want %v", got, reason, c.want)
|
||||||
|
}
|
||||||
|
if got && reason == "" {
|
||||||
|
t.Fatal("immutable without a reason")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOSInfoImmutable(t *testing.T) {
|
||||||
|
t.Run("unregistered image id is accepted when immutable", func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
writeOSRelease(t, fs, "ID=\"zirconium\"\nID_LIKE=\"zirconium\"\nPRETTY_NAME=\"Zirconium\"\nVERSION_ID=44\n")
|
||||||
|
if err := afero.WriteFile(fs, "/run/ostree-booted", nil, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info, err := getOSInfo(fs, "amd64")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !info.Immutable || info.Distribution.ID != "zirconium" || info.VersionID != "44" {
|
||||||
|
t.Fatalf("got %+v", info)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fedora derivative resolves through id_like", func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
writeOSRelease(t, fs, "ID=bazzite\nID_LIKE=fedora\nPRETTY_NAME=Bazzite\nVERSION_ID=44\n")
|
||||||
|
info, err := getOSInfo(fs, "amd64")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !info.Immutable || info.ID != "bazzite" || info.Distribution.ID != "fedora" {
|
||||||
|
t.Fatalf("got %+v", info)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unregistered mutable id still errors", func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
writeOSRelease(t, fs, "ID=slackware\nPRETTY_NAME=Slackware\n")
|
||||||
|
if _, err := getOSInfo(fs, "amd64"); err == nil {
|
||||||
|
t.Fatal("expected unsupported distribution error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("silverblue resolves to fedora but is flagged", func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
writeOSRelease(t, fs, "ID=fedora\nVARIANT_ID=silverblue\nPRETTY_NAME=\"Fedora Linux 44 (Silverblue)\"\nVERSION_ID=44\n")
|
||||||
|
info, err := getOSInfo(fs, "amd64")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !info.Immutable || info.Distribution.ID != "fedora" || info.VariantID != "silverblue" {
|
||||||
|
t.Fatalf("got %+v", info)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bluefin is no longer a mutable fedora", func(t *testing.T) {
|
||||||
|
if IsDistroSupported("bluefin") {
|
||||||
|
t.Fatal("bluefin must not be registered as an installable distribution")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingDependencies(t *testing.T) {
|
||||||
|
all := []deps.Dependency{
|
||||||
|
{Name: "dms (Dux Shell)", Status: deps.StatusMissing},
|
||||||
|
{Name: "niri", Status: deps.StatusInstalled},
|
||||||
|
{Name: "quickshell", Status: deps.StatusInstalled},
|
||||||
|
{Name: "danksearch", Status: deps.StatusMissing},
|
||||||
|
}
|
||||||
|
got := PendingDependencies(all, map[string]bool{"quickshell": true}, map[string]bool{"danksearch": true})
|
||||||
|
if len(got) != 2 || got[0].Name != "dms (Dux Shell)" || got[1].Name != "quickshell" {
|
||||||
|
t.Fatalf("got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyImmutableDetection(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "dms"), []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", dir)
|
||||||
|
got := ApplyImmutableDetection([]deps.Dependency{{Name: "dms (Dux Shell)", Status: deps.StatusMissing}, {Name: "niri", Status: deps.StatusMissing}})
|
||||||
|
if got[0].Status != deps.StatusInstalled || got[1].Status != deps.StatusMissing {
|
||||||
|
t.Fatalf("got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFedoraLayerCommands(t *testing.T) {
|
||||||
|
f := NewFedoraDistribution(Registry["fedora"], nil)
|
||||||
|
dependencies := []deps.Dependency{
|
||||||
|
{Name: "dms (Dux Shell)", Status: deps.StatusMissing},
|
||||||
|
{Name: "niri", Status: deps.StatusMissing, Variant: deps.VariantGit},
|
||||||
|
{Name: "ghostty", Status: deps.StatusInstalled},
|
||||||
|
{Name: "matugen", Status: deps.StatusMissing},
|
||||||
|
{Name: "danksearch", Status: deps.StatusMissing},
|
||||||
|
{Name: "xwayland-satellite", Status: deps.StatusMissing},
|
||||||
|
}
|
||||||
|
got := strings.Join(f.LayerCommands(dependencies, deps.WindowManagerNiri, nil, map[string]bool{"danksearch": true}), "\n")
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"_copr:copr.fedorainfracloud.org:duxlabs:dms.repo https://copr.fedorainfracloud.org/coprs/duxlabs/dms/repo/fedora-$(rpm -E %fedora)/duxlabs-dms-fedora-$(rpm -E %fedora).repo",
|
||||||
|
"_copr:copr.fedorainfracloud.org:yalter:niri-git.repo",
|
||||||
|
"echo priority=1 | sudo tee -a",
|
||||||
|
"sudo rpm-ostree install xwayland-satellite dms niri matugen",
|
||||||
|
"systemctl reboot",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("missing %q in:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, unwanted := range []string{"ghostty", "danksearch", "rpm-ostree upgrade", "dnf "} {
|
||||||
|
if strings.Contains(got, unwanted) {
|
||||||
|
t.Fatalf("unexpected %q in:\n%s", unwanted, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Count(got, "duxlabs:dux-repo.repo https") != 1 {
|
||||||
|
t.Fatalf("deps repo must be fetched exactly once:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImmutableGuidance(t *testing.T) {
|
||||||
|
steam := &OSInfo{PrettyName: "SteamOS", Distribution: DistroInfo{ID: "steamos"}, ImmutableReason: "os-release ID=steamos"}
|
||||||
|
got := ImmutableGuidance(steam)
|
||||||
|
if !strings.Contains(got, "SteamOS is an image-based system") || !strings.Contains(got, "Nothing was changed") {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFedoraLayerCommandsSwapVariants(t *testing.T) {
|
||||||
|
f := NewFedoraDistribution(Registry["fedora"], nil)
|
||||||
|
installed := map[string]bool{"dms-greeter": true, "dms": true}
|
||||||
|
f.rpmInstalled = func(pkg string) bool { return installed[pkg] }
|
||||||
|
orig := installedFromCOPR
|
||||||
|
installedFromCOPR = func(pkg, repo string) bool { return pkg == "dms" && repo == "duxlabs/dms-git" }
|
||||||
|
t.Cleanup(func() { installedFromCOPR = orig })
|
||||||
|
|
||||||
|
dependencies := []deps.Dependency{
|
||||||
|
{Name: "dms (Dux Shell)", Status: deps.StatusInstalled, Variant: deps.VariantStable, CanToggle: true},
|
||||||
|
{Name: "dms-greeter", Status: deps.StatusInstalled, Variant: deps.VariantGit, CanToggle: true},
|
||||||
|
{Name: "danksearch", Status: deps.StatusInstalled},
|
||||||
|
}
|
||||||
|
reinstall := map[string]bool{"dms (Dux Shell)": true, "dms-greeter": true, "danksearch": true}
|
||||||
|
got := strings.Join(f.LayerCommands(dependencies, deps.WindowManagerNiri, reinstall, nil), "\n")
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"sudo rm -f /etc/yum.repos.d/_copr:copr.fedorainfracloud.org:duxlabs:dms-git.repo /etc/yum.repos.d/_copr:copr.fedorainfracloud.org:avengemedia:dms-git.repo",
|
||||||
|
"sudo rpm-ostree install dms dms-greeter-git --uninstall dms dms-greeter",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("missing %q in:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "danksearch") {
|
||||||
|
t.Fatalf("reinstall mark without a variant change must not be layered:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyImmutableDetectionReadsVariant(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "dms"), []byte("#!/bin/sh\necho 'dms (git) v2.0.0-5333'\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", dir)
|
||||||
|
got := ApplyImmutableDetection([]deps.Dependency{{Name: "dms (Dux Shell)", Status: deps.StatusMissing}})
|
||||||
|
if got[0].Variant != deps.VariantGit || got[0].Version != "v2.0.0-5333" {
|
||||||
|
t.Fatalf("got %+v", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,8 +95,8 @@ func (m *ManualPackageInstaller) installNiri(ctx context.Context, sudoPassword s
|
|||||||
m.log("Installing niri from source...")
|
m.log("Installing niri from source...")
|
||||||
|
|
||||||
homeDir, _ := os.UserHomeDir()
|
homeDir, _ := os.UserHomeDir()
|
||||||
buildDir := filepath.Join(homeDir, ".cache", "dankinstall", "niri-build")
|
buildDir := filepath.Join(homeDir, ".cache", "duxinstall", "niri-build")
|
||||||
tmpDir := filepath.Join(homeDir, ".cache", "dankinstall", "tmp")
|
tmpDir := filepath.Join(homeDir, ".cache", "duxinstall", "tmp")
|
||||||
if err := os.MkdirAll(buildDir, 0o755); err != nil {
|
if err := os.MkdirAll(buildDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("failed to create build directory: %w", err)
|
return fmt.Errorf("failed to create build directory: %w", err)
|
||||||
}
|
}
|
||||||
@@ -173,7 +173,7 @@ func (m *ManualPackageInstaller) installQuickshell(ctx context.Context, variant
|
|||||||
return fmt.Errorf("HOME environment variable not set")
|
return fmt.Errorf("HOME environment variable not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
|
cacheDir := filepath.Join(homeDir, ".cache", "duxinstall")
|
||||||
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||||
}
|
}
|
||||||
@@ -279,7 +279,7 @@ func (m *ManualPackageInstaller) installHyprland(ctx context.Context, sudoPasswo
|
|||||||
return fmt.Errorf("HOME environment variable not set")
|
return fmt.Errorf("HOME environment variable not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
|
cacheDir := filepath.Join(homeDir, ".cache", "duxinstall")
|
||||||
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||||
}
|
}
|
||||||
@@ -342,7 +342,7 @@ func (m *ManualPackageInstaller) installGhostty(ctx context.Context, sudoPasswor
|
|||||||
return fmt.Errorf("HOME environment variable not set")
|
return fmt.Errorf("HOME environment variable not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
|
cacheDir := filepath.Join(homeDir, ".cache", "duxinstall")
|
||||||
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,70 @@ func (o *OpenSUSEDistribution) SwapVariants(ctx context.Context, swaps []Variant
|
|||||||
return o.runWithProgress(privesc.ExecCommand(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.40, 0.85)
|
return o.runWithProgress(privesc.ExecCommand(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.40, 0.85)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o *OpenSUSEDistribution) RepoMoves() []RepoMove {
|
||||||
|
if !obsMigrationReady {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return obsZypperChannel.moves(repoPathExists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OpenSUSEDistribution) ApplyRepoMoves(ctx context.Context, moves []RepoMove, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
|
if err := o.enableOBSRepos(ctx, repoMoveAdds(moves), sudoPassword, progressChan); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, move := range repoMoveRemoves(moves) {
|
||||||
|
command := "zypper removerepo " + obsZypperAlias(move.Old)
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseSystemPackages,
|
||||||
|
Progress: 0.30,
|
||||||
|
Step: fmt.Sprintf("Removing OBS repo %s...", move.Old),
|
||||||
|
NeedsSudo: true,
|
||||||
|
CommandInfo: "sudo " + command,
|
||||||
|
}
|
||||||
|
if err := o.runWithProgress(privescExec(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.30, 0.35); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove OBS repo %s: %w", move.Old, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OpenSUSEDistribution) RepoMoveCommands(moves []RepoMove) []string {
|
||||||
|
osInfo, err := GetOSInfo()
|
||||||
|
if err != nil {
|
||||||
|
osInfo = &OSInfo{}
|
||||||
|
}
|
||||||
|
return opensuseRepoMoveCommands(moves, opensuseOBSDist(osInfo))
|
||||||
|
}
|
||||||
|
|
||||||
|
func opensuseRepoMoveCommands(moves []RepoMove, dist string) []string {
|
||||||
|
var commands []string
|
||||||
|
adds := repoMoveAdds(moves)
|
||||||
|
for _, add := range adds {
|
||||||
|
commands = append(commands, "sudo zypper addrepo -f "+obsZypperRepoURL(add.RepoURL, dist))
|
||||||
|
}
|
||||||
|
if len(adds) > 0 {
|
||||||
|
commands = append(commands, "sudo zypper --gpg-auto-import-keys refresh")
|
||||||
|
}
|
||||||
|
for _, move := range repoMoveRemoves(moves) {
|
||||||
|
commands = append(commands, "sudo zypper removerepo "+obsZypperAlias(move.Old))
|
||||||
|
}
|
||||||
|
return commands
|
||||||
|
}
|
||||||
|
|
||||||
|
func opensuseOBSDist(osInfo *OSInfo) string {
|
||||||
|
switch osInfo.Distribution.ID {
|
||||||
|
case "opensuse-leap":
|
||||||
|
return "openSUSE_Leap_" + osInfo.VersionID
|
||||||
|
case "opensuse-slowroll":
|
||||||
|
return "openSUSE_Slowroll"
|
||||||
|
}
|
||||||
|
return "openSUSE_Tumbleweed"
|
||||||
|
}
|
||||||
|
|
||||||
|
func obsZypperRepoURL(repo, dist string) string {
|
||||||
|
return obsBaseURL(repo, dist) + "/" + repo + ".repo"
|
||||||
|
}
|
||||||
|
|
||||||
func (o *OpenSUSEDistribution) detectDMSGreeter() deps.Dependency {
|
func (o *OpenSUSEDistribution) detectDMSGreeter() deps.Dependency {
|
||||||
gitInstalled := o.packageInstalled("dms-greeter-git")
|
gitInstalled := o.packageInstalled("dms-greeter-git")
|
||||||
dep := o.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", gitInstalled || o.packageInstalled("dms-greeter"))
|
dep := o.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", gitInstalled || o.packageInstalled("dms-greeter"))
|
||||||
@@ -154,11 +218,11 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
|
|||||||
// DMS packages from OBS
|
// DMS packages from OBS
|
||||||
"dms (Dux Shell)": o.getDmsMapping(variants["dms (Dux Shell)"]),
|
"dms (Dux Shell)": o.getDmsMapping(variants["dms (Dux Shell)"]),
|
||||||
"quickshell": o.getQuickshellMapping(variants["quickshell"]),
|
"quickshell": o.getQuickshellMapping(variants["quickshell"]),
|
||||||
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeOBS, "home:AvengeMedia:danklinux"),
|
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeOBS, obsDeps),
|
||||||
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: obsDeps},
|
||||||
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: obsDeps},
|
||||||
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: obsDeps},
|
||||||
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeOBS, "home:AvengeMedia:danklinux"),
|
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeOBS, obsDeps),
|
||||||
}
|
}
|
||||||
|
|
||||||
switch wm {
|
switch wm {
|
||||||
@@ -179,28 +243,28 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
|
|||||||
|
|
||||||
func (o *OpenSUSEDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping {
|
func (o *OpenSUSEDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if variant == deps.VariantGit {
|
if variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "dms-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:dms-git"}
|
return PackageMapping{Name: "dms-git", Repository: RepoTypeOBS, RepoURL: obsDMSGit}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "dms", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:dms"}
|
return PackageMapping{Name: "dms", Repository: RepoTypeOBS, RepoURL: obsDMS}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *OpenSUSEDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping {
|
func (o *OpenSUSEDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
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: obsDeps}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "quickshell", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}
|
return PackageMapping{Name: "quickshell", Repository: RepoTypeOBS, RepoURL: obsDeps}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *OpenSUSEDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
|
func (o *OpenSUSEDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if variant == deps.VariantGit {
|
if variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "niri-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}
|
return PackageMapping{Name: "niri-git", Repository: RepoTypeOBS, RepoURL: obsDeps}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "niri", Repository: RepoTypeSystem}
|
return PackageMapping{Name: "niri", Repository: RepoTypeSystem}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *OpenSUSEDistribution) getXwaylandSatelliteMapping(variant deps.PackageVariant) PackageMapping {
|
func (o *OpenSUSEDistribution) getXwaylandSatelliteMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if variant == deps.VariantGit {
|
if variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "xwayland-satellite-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}
|
return PackageMapping{Name: "xwayland-satellite-git", Repository: RepoTypeOBS, RepoURL: obsDeps}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
|
return PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
|
||||||
}
|
}
|
||||||
@@ -490,26 +554,16 @@ func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Pac
|
|||||||
return fmt.Errorf("failed to get OS info: %w", err)
|
return fmt.Errorf("failed to get OS info: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
obsDistroVersion := "openSUSE_Tumbleweed"
|
obsDistroVersion := opensuseOBSDist(osInfo)
|
||||||
switch osInfo.Distribution.ID {
|
|
||||||
case "opensuse-leap":
|
|
||||||
obsDistroVersion = fmt.Sprintf("openSUSE_Leap_%s", osInfo.VersionID)
|
|
||||||
case "opensuse-slowroll":
|
|
||||||
obsDistroVersion = "openSUSE_Slowroll"
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, pkg := range obsPkgs {
|
for _, pkg := range obsPkgs {
|
||||||
if pkg.RepoURL != "" && !enabledRepos[pkg.RepoURL] {
|
if pkg.RepoURL != "" && !enabledRepos[pkg.RepoURL] {
|
||||||
o.log(fmt.Sprintf("Enabling OBS repository: %s", pkg.RepoURL))
|
o.log(fmt.Sprintf("Enabling OBS repository: %s", pkg.RepoURL))
|
||||||
|
|
||||||
// RepoURL format: "home:AvengeMedia:danklinux"
|
repoURL := obsZypperRepoURL(pkg.RepoURL, obsDistroVersion)
|
||||||
repoPath := strings.ReplaceAll(pkg.RepoURL, ":", ":/")
|
|
||||||
repoName := strings.ReplaceAll(pkg.RepoURL, ":", "-")
|
|
||||||
repoURL := fmt.Sprintf("https://download.opensuse.org/repositories/%s/%s/%s.repo",
|
|
||||||
repoPath, obsDistroVersion, pkg.RepoURL)
|
|
||||||
|
|
||||||
checkCmd := exec.CommandContext(ctx, "zypper", "repos", repoName)
|
// zypper stores the repo under the alias from the .repo section header, home_<owner>_<project>.
|
||||||
if checkCmd.Run() == nil {
|
if repoPathExists(obsZypperRepoFile(pkg.RepoURL)) {
|
||||||
o.log(fmt.Sprintf("OBS repo %s already exists, skipping", pkg.RepoURL))
|
o.log(fmt.Sprintf("OBS repo %s already exists, skipping", pkg.RepoURL))
|
||||||
enabledRepos[pkg.RepoURL] = true
|
enabledRepos[pkg.RepoURL] = true
|
||||||
continue
|
continue
|
||||||
@@ -703,7 +757,7 @@ func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant de
|
|||||||
return fmt.Errorf("HOME environment variable not set")
|
return fmt.Errorf("HOME environment variable not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
|
cacheDir := filepath.Join(homeDir, ".cache", "duxinstall")
|
||||||
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/errdefs"
|
"github.com/duxlabs-dev/dux-shell/core/internal/errdefs"
|
||||||
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DistroInfo contains basic information about a distribution
|
// DistroInfo contains basic information about a distribution
|
||||||
@@ -19,12 +20,18 @@ type DistroInfo struct {
|
|||||||
|
|
||||||
// OSInfo contains complete OS information
|
// OSInfo contains complete OS information
|
||||||
type OSInfo struct {
|
type OSInfo struct {
|
||||||
|
// Raw os-release ID; Distribution.ID is the registered distro it resolves to via ID_LIKE (bazzite -> fedora).
|
||||||
|
ID string
|
||||||
Distribution DistroInfo
|
Distribution DistroInfo
|
||||||
Version string
|
Version string
|
||||||
VersionID string
|
VersionID string
|
||||||
VersionCodename string
|
VersionCodename string
|
||||||
PrettyName string
|
PrettyName string
|
||||||
Architecture string
|
Architecture string
|
||||||
|
IDLike []string
|
||||||
|
VariantID string
|
||||||
|
Immutable bool
|
||||||
|
ImmutableReason string
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOSInfo detects the current OS and returns information about it
|
// GetOSInfo detects the current OS and returns information about it
|
||||||
@@ -37,50 +44,74 @@ func GetOSInfo() (*OSInfo, error) {
|
|||||||
return nil, errdefs.NewCustomError(errdefs.ErrTypeInvalidArchitecture, fmt.Sprintf("Only amd64 and arm64 are supported, but I found %s", runtime.GOARCH))
|
return nil, errdefs.NewCustomError(errdefs.ErrTypeInvalidArchitecture, fmt.Sprintf("Only amd64 and arm64 are supported, but I found %s", runtime.GOARCH))
|
||||||
}
|
}
|
||||||
|
|
||||||
info := &OSInfo{
|
return getOSInfo(afero.NewOsFs(), runtime.GOARCH)
|
||||||
Architecture: runtime.GOARCH,
|
}
|
||||||
|
|
||||||
|
// An unregistered ID is only an error on a mutable system; immutable hosts
|
||||||
|
// stop at the guidance screen and need no Distribution implementation.
|
||||||
|
func getOSInfo(fs afero.Fs, arch string) (*OSInfo, error) {
|
||||||
|
values := readOSRelease(fs)
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil, fmt.Errorf("cannot read /etc/os-release")
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := os.Open("/etc/os-release")
|
info := &OSInfo{
|
||||||
|
Architecture: arch,
|
||||||
|
Version: values["VERSION"],
|
||||||
|
VersionID: values["VERSION_ID"],
|
||||||
|
VersionCodename: values["VERSION_CODENAME"],
|
||||||
|
PrettyName: values["PRETTY_NAME"],
|
||||||
|
IDLike: strings.Fields(values["ID_LIKE"]),
|
||||||
|
VariantID: values["VARIANT_ID"],
|
||||||
|
}
|
||||||
|
if info.VersionID == "" {
|
||||||
|
info.VersionID = values["BUILD_ID"]
|
||||||
|
}
|
||||||
|
info.Immutable, info.ImmutableReason = detectImmutable(fs, values)
|
||||||
|
|
||||||
|
info.ID = values["ID"]
|
||||||
|
id := info.ID
|
||||||
|
config, exists := Registry[id]
|
||||||
|
if !exists && !info.Immutable {
|
||||||
|
return nil, errdefs.NewCustomError(errdefs.ErrTypeUnsupportedDistribution, fmt.Sprintf("Unsupported distribution: %s", id))
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
for _, like := range info.IDLike {
|
||||||
|
if likeConfig, ok := Registry[like]; ok {
|
||||||
|
id, config = like, likeConfig
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info.Distribution = DistroInfo{ID: id, HexColorCode: config.ColorHex}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readOSRelease(fs afero.Fs) map[string]string {
|
||||||
|
values := make(map[string]string)
|
||||||
|
|
||||||
|
file, err := fs.Open("/etc/os-release")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return values
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
scanner := bufio.NewScanner(file)
|
scanner := bufio.NewScanner(file)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
parts := strings.SplitN(scanner.Text(), "=", 2)
|
||||||
parts := strings.SplitN(line, "=", 2)
|
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
values[strings.TrimSpace(parts[0])] = strings.Trim(strings.TrimSpace(parts[1]), "\"'")
|
||||||
key := parts[0]
|
|
||||||
value := strings.Trim(parts[1], "\"'")
|
|
||||||
|
|
||||||
switch key {
|
|
||||||
case "ID":
|
|
||||||
config, exists := Registry[value]
|
|
||||||
if !exists {
|
|
||||||
return nil, errdefs.NewCustomError(errdefs.ErrTypeUnsupportedDistribution, fmt.Sprintf("Unsupported distribution: %s", value))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
info.Distribution = DistroInfo{
|
return values
|
||||||
ID: value, // Use the actual ID from os-release
|
}
|
||||||
HexColorCode: config.ColorHex,
|
|
||||||
}
|
|
||||||
case "VERSION_ID", "BUILD_ID":
|
|
||||||
info.VersionID = value
|
|
||||||
case "VERSION":
|
|
||||||
info.Version = value
|
|
||||||
case "VERSION_CODENAME":
|
|
||||||
info.VersionCodename = value
|
|
||||||
case "PRETTY_NAME":
|
|
||||||
info.PrettyName = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return info, scanner.Err()
|
func HasSystemd() bool {
|
||||||
|
_, err := os.Stat("/run/systemd/system")
|
||||||
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsUnsupportedDistro checks if a distribution/version combination is supported
|
// IsUnsupportedDistro checks if a distribution/version combination is supported
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package distros
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/site"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
coprOwner = "duxlabs"
|
||||||
|
obsHome = "home:duxlabs"
|
||||||
|
ppaOwner = "duxlabs"
|
||||||
|
|
||||||
|
legacyCoprOwner = "avengemedia"
|
||||||
|
legacyObsHome = "home:AvengeMedia"
|
||||||
|
legacyPpaOwner = "avengemedia"
|
||||||
|
|
||||||
|
projectDMS = "dms"
|
||||||
|
projectDMSGit = "dms-git"
|
||||||
|
projectDeps = "dux-repo"
|
||||||
|
legacyProjectDeps = "danklinux"
|
||||||
|
// The 2.0 docs told users to enable duxlabs/dux before the deps slug became dux-repo.
|
||||||
|
retiredProjectDeps = "dux"
|
||||||
|
|
||||||
|
voidHost = site.VoidRepo
|
||||||
|
legacyVoidHost = "https://void.danklinux.com"
|
||||||
|
voidConfGlob = "/etc/xbps.d/*.conf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The duxlabs OBS projects are not published yet, so Debian and openSUSE stay on the legacy ones.
|
||||||
|
const obsMigrationReady = false
|
||||||
|
|
||||||
|
var dmsProjects = []string{projectDMS, projectDMSGit, projectDeps}
|
||||||
|
|
||||||
|
func legacyProject(project string) string {
|
||||||
|
if project == projectDeps {
|
||||||
|
return legacyProjectDeps
|
||||||
|
}
|
||||||
|
return project
|
||||||
|
}
|
||||||
|
|
||||||
|
func coprRepo(project string) string { return coprOwner + "/" + project }
|
||||||
|
func legacyCoprRepo(project string) string { return legacyCoprOwner + "/" + project }
|
||||||
|
func ppaRepo(project string) string { return "ppa:" + ppaOwner + "/" + project }
|
||||||
|
func legacyPpaRepo(project string) string { return "ppa:" + legacyPpaOwner + "/" + project }
|
||||||
|
func legacyObsRepo(project string) string { return legacyObsHome + ":" + project }
|
||||||
|
func newObsRepo(project string) string { return obsHome + ":" + project }
|
||||||
|
|
||||||
|
// obsRepo is what the mappings install from; the move tables use newObsRepo regardless of the gate.
|
||||||
|
func obsRepo(project string) string {
|
||||||
|
if !obsMigrationReady {
|
||||||
|
return legacyObsRepo(legacyProject(project))
|
||||||
|
}
|
||||||
|
return newObsRepo(project)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
coprDMS, coprDMSGit, coprDeps = coprRepo(projectDMS), coprRepo(projectDMSGit), coprRepo(projectDeps)
|
||||||
|
obsDMS, obsDMSGit, obsDeps = obsRepo(projectDMS), obsRepo(projectDMSGit), obsRepo(projectDeps)
|
||||||
|
ppaDMS, ppaDMSGit, ppaDeps = ppaRepo(projectDMS), ppaRepo(projectDMSGit), ppaRepo(projectDeps)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Each must match what the package manager itself derives from the RepoURL.
|
||||||
|
func coprRepoID(repo string) string {
|
||||||
|
return "copr:copr.fedorainfracloud.org:" + strings.ReplaceAll(repo, "/", ":")
|
||||||
|
}
|
||||||
|
func coprRepoFile(repo string) string { return "/etc/yum.repos.d/_" + coprRepoID(repo) + ".repo" }
|
||||||
|
func obsAptName(repo string) string { return strings.ReplaceAll(repo, ":", "-") }
|
||||||
|
func obsAptListFile(repo string) string {
|
||||||
|
return "/etc/apt/sources.list.d/" + obsAptName(repo) + ".list"
|
||||||
|
}
|
||||||
|
func obsAptKeyring(repo string) string { return "/etc/apt/keyrings/" + obsAptName(repo) + ".gpg" }
|
||||||
|
func obsZypperAlias(repo string) string { return strings.ReplaceAll(repo, ":", "_") }
|
||||||
|
func obsZypperRepoFile(repo string) string {
|
||||||
|
return "/etc/zypp/repos.d/" + obsZypperAlias(repo) + ".repo"
|
||||||
|
}
|
||||||
|
func obsBaseURL(repo, dist string) string {
|
||||||
|
return "https://download.opensuse.org/repositories/" + strings.ReplaceAll(repo, ":", ":/") + "/" + dist
|
||||||
|
}
|
||||||
|
|
||||||
|
// add-apt-repository names the file <owner>-ubuntu-<project>-<series>.sources (24.04+) or .list.
|
||||||
|
func ppaSourcesGlobs(repo string) []string {
|
||||||
|
owner, project, _ := strings.Cut(strings.TrimPrefix(repo, "ppa:"), "/")
|
||||||
|
stem := "/etc/apt/sources.list.d/" + owner + "-ubuntu-" + project + "-*"
|
||||||
|
return []string{stem + ".sources", stem + ".list"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A current COPR mapping may still be installed from its legacy owner.
|
||||||
|
func coprAliases(repo string) []string {
|
||||||
|
owner, project, _ := strings.Cut(repo, "/")
|
||||||
|
if owner != coprOwner {
|
||||||
|
return []string{repo}
|
||||||
|
}
|
||||||
|
return []string{repo, legacyCoprRepo(legacyProject(project))}
|
||||||
|
}
|
||||||
|
|
||||||
|
func coprRepoIDs(repo string) []string {
|
||||||
|
var ids []string
|
||||||
|
for _, alias := range coprAliases(repo) {
|
||||||
|
ids = append(ids, coprRepoID(alias))
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func coprFromRepoMatches(fromRepo, repo string) bool {
|
||||||
|
return slices.Contains(coprRepoIDs(repo), strings.TrimSpace(fromRepo))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test seams.
|
||||||
|
var (
|
||||||
|
repoGlob = func(pattern string) []string {
|
||||||
|
matches, _ := filepath.Glob(pattern)
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
repoPathExists = func(pattern string) bool { return len(repoGlob(pattern)) > 0 }
|
||||||
|
repoReadFile = os.ReadFile
|
||||||
|
privescExec = privesc.ExecCommand
|
||||||
|
)
|
||||||
|
|
||||||
|
// The series is one word, so the retired dux-* glob must not count the dux-repo-* sibling.
|
||||||
|
func ppaSourcesExists(pattern string) bool {
|
||||||
|
return slices.ContainsFunc(repoGlob(pattern), func(path string) bool { return ppaSeriesMatch(pattern, path) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func ppaSeriesMatch(pattern, path string) bool {
|
||||||
|
stem, ext, _ := strings.Cut(pattern, "*")
|
||||||
|
series := strings.TrimSuffix(strings.TrimPrefix(path, stem), ext)
|
||||||
|
return series != "" && !strings.Contains(series, "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
type RepoMove struct {
|
||||||
|
Kind RepositoryType
|
||||||
|
Old, New string // RepoURL form, e.g. "avengemedia/danklinux" → "duxlabs/dux-repo"
|
||||||
|
OldPath, NewPath string // file (or glob) whose presence means the repo is enabled
|
||||||
|
OldPresent, NewPresent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type RepoMover interface {
|
||||||
|
RepoMoves() []RepoMove
|
||||||
|
ApplyRepoMoves(ctx context.Context, moves []RepoMove, sudoPassword string, progressChan chan<- InstallProgressMsg) error
|
||||||
|
RepoMoveCommands(moves []RepoMove) []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// For image-based hosts, where the package manager cannot run the moves.
|
||||||
|
type ImageRepoMover interface {
|
||||||
|
ImageRepoMoveCommands(moves []RepoMove) []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlanRepoMoves(d Distribution) ([]RepoMove, bool) {
|
||||||
|
mover, ok := d.(RepoMover)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
moves := mover.RepoMoves()
|
||||||
|
return moves, len(moves) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type repoChannel struct {
|
||||||
|
kind RepositoryType
|
||||||
|
repo, legacyRepo func(project string) string
|
||||||
|
paths func(repo string) []string
|
||||||
|
retiredDeps bool // the channel also published the deps project under retiredProjectDeps
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c repoChannel) legacyRepos(project string) []string {
|
||||||
|
repos := []string{c.legacyRepo(legacyProject(project))}
|
||||||
|
if project == projectDeps && c.retiredDeps {
|
||||||
|
repos = append(repos, c.repo(retiredProjectDeps))
|
||||||
|
}
|
||||||
|
return repos
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c repoChannel) moves(exists func(string) bool) []RepoMove {
|
||||||
|
var moves []RepoMove
|
||||||
|
for _, project := range dmsProjects {
|
||||||
|
for _, old := range c.legacyRepos(project) {
|
||||||
|
move := RepoMove{Kind: c.kind, Old: old, New: c.repo(project)}
|
||||||
|
move.OldPath, move.OldPresent = firstExisting(exists, c.paths(move.Old))
|
||||||
|
move.NewPath, move.NewPresent = firstExisting(exists, c.paths(move.New))
|
||||||
|
moves = append(moves, move)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return moves
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstExisting(exists func(string) bool, paths []string) (string, bool) {
|
||||||
|
for _, path := range paths {
|
||||||
|
if exists(path) {
|
||||||
|
return path, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths[0], false
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
coprChannel = repoChannel{RepoTypeCOPR, coprRepo, legacyCoprRepo, func(r string) []string { return []string{coprRepoFile(r)} }, true}
|
||||||
|
obsAptChannel = repoChannel{RepoTypeOBS, newObsRepo, legacyObsRepo, func(r string) []string { return []string{obsAptListFile(r)} }, false}
|
||||||
|
obsZypperChannel = repoChannel{RepoTypeOBS, newObsRepo, legacyObsRepo, func(r string) []string { return []string{obsZypperRepoFile(r)} }, false}
|
||||||
|
ppaChannel = repoChannel{RepoTypePPA, ppaRepo, legacyPpaRepo, ppaSourcesGlobs, true}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m RepoMove) needsAdd() bool { return m.OldPresent && !m.NewPresent }
|
||||||
|
func (m RepoMove) needsRemove() bool { return m.OldPresent }
|
||||||
|
|
||||||
|
// The enable*Repos helpers only read RepoURL; two legacy rows can share a target.
|
||||||
|
func repoMoveAdds(moves []RepoMove) []PackageMapping {
|
||||||
|
var adds []PackageMapping
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, move := range moves {
|
||||||
|
if !move.needsAdd() || seen[move.New] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[move.New] = true
|
||||||
|
adds = append(adds, PackageMapping{Repository: move.Kind, RepoURL: move.New})
|
||||||
|
}
|
||||||
|
return adds
|
||||||
|
}
|
||||||
|
|
||||||
|
func repoMoveRemoves(moves []RepoMove) []RepoMove {
|
||||||
|
var removes []RepoMove
|
||||||
|
for _, move := range moves {
|
||||||
|
if move.needsRemove() {
|
||||||
|
removes = append(removes, move)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BaseDistribution) aptUpdate(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseSystemPackages,
|
||||||
|
Progress: 0.36,
|
||||||
|
Step: "Updating package lists...",
|
||||||
|
NeedsSudo: true,
|
||||||
|
CommandInfo: "sudo apt-get update",
|
||||||
|
}
|
||||||
|
if err := b.runWithProgress(privescExec(ctx, sudoPassword, "apt-get update"), progressChan, PhaseSystemPackages, 0.36, 0.40); err != nil {
|
||||||
|
return fmt.Errorf("failed to update package lists: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LegacyRepoPaths(moves []RepoMove) []string {
|
||||||
|
paths := []string{}
|
||||||
|
for _, move := range repoMoveRemoves(moves) {
|
||||||
|
if !slices.Contains(paths, move.OldPath) {
|
||||||
|
paths = append(paths, move.OldPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
package distros
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOBSZypperAlias(t *testing.T) {
|
||||||
|
if got := obsZypperAlias("home:AvengeMedia:dms"); got != "home_AvengeMedia_dms" {
|
||||||
|
t.Fatalf("alias = %q, want the section header spelling home_AvengeMedia_dms", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCOPRFromRepoMatches(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
fromRepo, repo string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"copr:copr.fedorainfracloud.org:duxlabs:dms-git\n", coprDMSGit, true},
|
||||||
|
{"copr:copr.fedorainfracloud.org:avengemedia:dms-git", coprDMSGit, true},
|
||||||
|
{"copr:copr.fedorainfracloud.org:avengemedia:danklinux", coprDeps, true},
|
||||||
|
{"copr:copr.fedorainfracloud.org:duxlabs:dms", coprDMSGit, false},
|
||||||
|
{"copr:copr.fedorainfracloud.org:avengemedia:dms", coprDMSGit, false},
|
||||||
|
{"copr:copr.fedorainfracloud.org:duxlabs:dms-git", coprDMS, false},
|
||||||
|
{"copr:copr.fedorainfracloud.org:yalter:niri-git", "yalter/niri-git", true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := coprFromRepoMatches(tc.fromRepo, tc.repo); got != tc.want {
|
||||||
|
t.Errorf("coprFromRepoMatches(%q, %q) = %v, want %v", tc.fromRepo, tc.repo, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func existsIn(paths ...string) func(string) bool {
|
||||||
|
return func(p string) bool { return slices.Contains(paths, p) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanRepoMoves(t *testing.T) {
|
||||||
|
legacyDeps := coprRepoFile("avengemedia/danklinux")
|
||||||
|
retiredDeps := coprRepoFile("duxlabs/dux")
|
||||||
|
newDeps := coprRepoFile("duxlabs/dux-repo")
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
exists func(string) bool
|
||||||
|
wantAdds []string
|
||||||
|
wantRemoves []string
|
||||||
|
wantLegacyIdx []string
|
||||||
|
}{
|
||||||
|
{"legacy only", existsIn(legacyDeps), []string{"duxlabs/dux-repo"}, []string{"avengemedia/danklinux"}, []string{legacyDeps}},
|
||||||
|
{"both present", existsIn(legacyDeps, newDeps), nil, []string{"avengemedia/danklinux"}, []string{legacyDeps}},
|
||||||
|
{"new only", existsIn(newDeps), nil, nil, nil},
|
||||||
|
{"none", existsIn(), nil, nil, nil},
|
||||||
|
{"retired dux slug", existsIn(retiredDeps), []string{"duxlabs/dux-repo"}, []string{"duxlabs/dux"}, []string{retiredDeps}},
|
||||||
|
{"both legacy deps", existsIn(legacyDeps, retiredDeps), []string{"duxlabs/dux-repo"}, []string{"avengemedia/danklinux", "duxlabs/dux"}, []string{legacyDeps, retiredDeps}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
moves := coprChannel.moves(tc.exists)
|
||||||
|
if len(moves) != 4 {
|
||||||
|
t.Fatalf("got %d rows, want dms, dms-git and the two legacy deps rows", len(moves))
|
||||||
|
}
|
||||||
|
var adds, removes []string
|
||||||
|
for _, add := range repoMoveAdds(moves) {
|
||||||
|
adds = append(adds, add.RepoURL)
|
||||||
|
}
|
||||||
|
for _, move := range repoMoveRemoves(moves) {
|
||||||
|
removes = append(removes, move.Old)
|
||||||
|
}
|
||||||
|
if !slices.Equal(adds, tc.wantAdds) || !slices.Equal(removes, tc.wantRemoves) {
|
||||||
|
t.Fatalf("adds %v removes %v, want %v / %v", adds, removes, tc.wantAdds, tc.wantRemoves)
|
||||||
|
}
|
||||||
|
legacy := LegacyRepoPaths(moves)
|
||||||
|
if len(legacy) == 0 && len(tc.wantLegacyIdx) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !slices.Equal(legacy, tc.wantLegacyIdx) {
|
||||||
|
t.Fatalf("legacy paths %v, want %v", legacy, tc.wantLegacyIdx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanRepoMovesPPAGlob(t *testing.T) {
|
||||||
|
moves := ppaChannel.moves(func(p string) bool {
|
||||||
|
return p == "/etc/apt/sources.list.d/avengemedia-ubuntu-dms-*.list"
|
||||||
|
})
|
||||||
|
legacy := LegacyRepoPaths(moves)
|
||||||
|
if len(legacy) != 1 || legacy[0] != "/etc/apt/sources.list.d/avengemedia-ubuntu-dms-*.list" {
|
||||||
|
t.Fatalf("legacy = %v, want the 22.04 .list glob for dms only", legacy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// filepath.Glob over a fake listing, where dux-* also matches the dux-repo-* sibling.
|
||||||
|
func globIn(files ...string) func(string) []string {
|
||||||
|
return func(pattern string) []string {
|
||||||
|
var matches []string
|
||||||
|
for _, file := range files {
|
||||||
|
if ok, _ := filepath.Match(pattern, file); ok {
|
||||||
|
matches = append(matches, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUbuntuRepoMovesIgnoreRetiredSiblingGlob(t *testing.T) {
|
||||||
|
orig := repoGlob
|
||||||
|
repoGlob = globIn(
|
||||||
|
"/etc/apt/sources.list.d/duxlabs-ubuntu-dux-repo-resolute.sources",
|
||||||
|
"/etc/apt/sources.list.d/avengemedia-ubuntu-danklinux-resolute.sources")
|
||||||
|
t.Cleanup(func() { repoGlob = orig })
|
||||||
|
|
||||||
|
moves := NewUbuntuDistribution(Registry["ubuntu"], nil).RepoMoves()
|
||||||
|
want := []string{"/etc/apt/sources.list.d/avengemedia-ubuntu-danklinux-*.sources"}
|
||||||
|
if got := LegacyRepoPaths(moves); !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("legacy = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if adds := repoMoveAdds(moves); len(adds) != 0 {
|
||||||
|
t.Fatalf("planned adds %v while dux-repo is present", adds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fakeXBPSConfs(t *testing.T, files map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
origGlob, origRead := repoGlob, repoReadFile
|
||||||
|
var paths []string
|
||||||
|
for path := range files {
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
slices.Sort(paths)
|
||||||
|
repoGlob = globIn(paths...)
|
||||||
|
repoReadFile = func(path string) ([]byte, error) {
|
||||||
|
content, ok := files[path]
|
||||||
|
if !ok {
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
return []byte(content), nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { repoGlob, repoReadFile = origGlob, origRead })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVoidRepoMoves(t *testing.T) {
|
||||||
|
const (
|
||||||
|
legacyDMS = "repository=https://void.danklinux.com/dms/current\n"
|
||||||
|
legacyDeps = "repository=https://void.danklinux.com/danklinux/current\n"
|
||||||
|
newDMS = "repository=https://void.duxlabs.dev/dms/current\n"
|
||||||
|
newDeps = "repository=https://void.duxlabs.dev/dux-repo/current\n"
|
||||||
|
)
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
files map[string]string
|
||||||
|
wantMoves []string
|
||||||
|
wantLegacy []string
|
||||||
|
wantNew bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"both legacy",
|
||||||
|
map[string]string{"/etc/xbps.d/dms.conf": legacyDMS, "/etc/xbps.d/danklinux.conf": "# dms deps\n" + legacyDeps, "/etc/xbps.d/00-repository-main.conf": "repository=https://repo-default.voidlinux.org/current\n"},
|
||||||
|
[]string{
|
||||||
|
"https://void.danklinux.com/danklinux/current -> https://void.duxlabs.dev/dux-repo/current",
|
||||||
|
"https://void.danklinux.com/dms/current -> https://void.duxlabs.dev/dms/current",
|
||||||
|
},
|
||||||
|
[]string{"/etc/xbps.d/danklinux.conf", "/etc/xbps.d/dms.conf"},
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"one migrated",
|
||||||
|
map[string]string{"/etc/xbps.d/dms.conf": newDMS, "/etc/xbps.d/dux.conf": legacyDeps},
|
||||||
|
[]string{"https://void.danklinux.com/danklinux/current -> https://void.duxlabs.dev/dux-repo/current"},
|
||||||
|
[]string{"/etc/xbps.d/dux.conf"},
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"both lines in one file",
|
||||||
|
map[string]string{"/etc/xbps.d/custom.conf": legacyDMS + legacyDeps},
|
||||||
|
[]string{
|
||||||
|
"https://void.danklinux.com/dms/current -> https://void.duxlabs.dev/dms/current",
|
||||||
|
"https://void.danklinux.com/danklinux/current -> https://void.duxlabs.dev/dux-repo/current",
|
||||||
|
},
|
||||||
|
[]string{"/etc/xbps.d/custom.conf"},
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{"greeter only", map[string]string{"/etc/xbps.d/dms.conf": legacyDMS}, []string{"https://void.danklinux.com/dms/current -> https://void.duxlabs.dev/dms/current"}, []string{"/etc/xbps.d/dms.conf"}, false},
|
||||||
|
{"all migrated", map[string]string{"/etc/xbps.d/dms.conf": newDMS, "/etc/xbps.d/dux.conf": newDeps}, nil, nil, true},
|
||||||
|
{"none", map[string]string{"/etc/xbps.d/00-repository-main.conf": "repository=https://repo-default.voidlinux.org/current\n"}, nil, nil, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
fakeXBPSConfs(t, tc.files)
|
||||||
|
v := NewVoidDistribution(Registry["void"], nil)
|
||||||
|
moves, _ := PlanRepoMoves(v)
|
||||||
|
var got []string
|
||||||
|
newPresent := false
|
||||||
|
for _, move := range moves {
|
||||||
|
newPresent = newPresent || move.NewPresent
|
||||||
|
if move.OldPresent {
|
||||||
|
if move.NewPath != move.OldPath {
|
||||||
|
t.Fatalf("%s must be edited in place, got NewPath %s", move.OldPath, move.NewPath)
|
||||||
|
}
|
||||||
|
got = append(got, move.Old+" -> "+move.New)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !slices.Equal(got, tc.wantMoves) {
|
||||||
|
t.Fatalf("moves %q, want %q", got, tc.wantMoves)
|
||||||
|
}
|
||||||
|
if legacy := LegacyRepoPaths(moves); !slices.Equal(legacy, tc.wantLegacy) && len(legacy)+len(tc.wantLegacy) > 0 {
|
||||||
|
t.Fatalf("legacy paths %v, want %v", legacy, tc.wantLegacy)
|
||||||
|
}
|
||||||
|
if newPresent != tc.wantNew {
|
||||||
|
t.Fatalf("new repo present = %v, want %v", newPresent, tc.wantNew)
|
||||||
|
}
|
||||||
|
if commands := v.RepoMoveCommands(moves); (len(commands) == 0) != (len(tc.wantMoves) == 0) {
|
||||||
|
t.Fatalf("commands %q for moves %q", commands, tc.wantMoves)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVoidMappingsUseNewHost(t *testing.T) {
|
||||||
|
v := NewVoidDistribution(Registry["void"], nil)
|
||||||
|
for _, variants := range []map[string]deps.PackageVariant{
|
||||||
|
{"dms (Dux Shell)": deps.VariantStable, "dms-greeter": deps.VariantStable},
|
||||||
|
{"dms (Dux Shell)": deps.VariantGit, "dms-greeter": deps.VariantGit},
|
||||||
|
} {
|
||||||
|
packages := v.GetPackageMappingWithVariants(deps.WindowManagerNiri, variants)
|
||||||
|
want := map[string]string{
|
||||||
|
"dms (Dux Shell)": "https://void.duxlabs.dev/dms/current",
|
||||||
|
"dms-greeter": "https://void.duxlabs.dev/dux-repo/current",
|
||||||
|
"danksearch": "https://void.duxlabs.dev/dux-repo/current",
|
||||||
|
"dankcalendar": "https://void.duxlabs.dev/dux-repo/current",
|
||||||
|
}
|
||||||
|
for dep, url := range want {
|
||||||
|
if got := packages[dep].RepoURL; got != url {
|
||||||
|
t.Errorf("%s (%s) RepoURL = %q, want %q", dep, packages[dep].Name, got, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoMoveCommands(t *testing.T) {
|
||||||
|
withOrig := func(c repoChannel, present ...string) []RepoMove {
|
||||||
|
return c.moves(existsIn(present...))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("fedora", func(t *testing.T) {
|
||||||
|
f := NewFedoraDistribution(Registry["fedora"], nil)
|
||||||
|
moves := withOrig(coprChannel, coprRepoFile("avengemedia/dms"), coprRepoFile("avengemedia/danklinux"), coprRepoFile("duxlabs/dms"))
|
||||||
|
want := []string{
|
||||||
|
"sudo dnf copr enable -y duxlabs/dux-repo",
|
||||||
|
"sudo dnf copr remove -y avengemedia/dms",
|
||||||
|
"sudo dnf copr remove -y avengemedia/danklinux",
|
||||||
|
}
|
||||||
|
if got := f.RepoMoveCommands(moves); !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fedora image-based", func(t *testing.T) {
|
||||||
|
f := NewFedoraDistribution(Registry["fedora"], nil)
|
||||||
|
moves := withOrig(coprChannel, coprRepoFile("avengemedia/danklinux"))
|
||||||
|
want := []string{
|
||||||
|
"sudo curl -fsSLo /etc/yum.repos.d/_copr:copr.fedorainfracloud.org:duxlabs:dux-repo.repo https://copr.fedorainfracloud.org/coprs/duxlabs/dux-repo/repo/fedora-$(rpm -E %fedora)/duxlabs-dux-repo-fedora-$(rpm -E %fedora).repo",
|
||||||
|
"sudo rm -f /etc/yum.repos.d/_copr:copr.fedorainfracloud.org:avengemedia:danklinux.repo",
|
||||||
|
}
|
||||||
|
if got := f.ImageRepoMoveCommands(moves); !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fedora retired dux slug", func(t *testing.T) {
|
||||||
|
f := NewFedoraDistribution(Registry["fedora"], nil)
|
||||||
|
moves := withOrig(coprChannel, coprRepoFile("duxlabs/dux"))
|
||||||
|
want := []string{
|
||||||
|
"sudo dnf copr enable -y duxlabs/dux-repo",
|
||||||
|
"sudo dnf copr remove -y duxlabs/dux",
|
||||||
|
}
|
||||||
|
if got := f.RepoMoveCommands(moves); !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ubuntu", func(t *testing.T) {
|
||||||
|
u := NewUbuntuDistribution(Registry["ubuntu"], nil)
|
||||||
|
moves := withOrig(ppaChannel, "/etc/apt/sources.list.d/avengemedia-ubuntu-danklinux-*.sources", "/etc/apt/sources.list.d/duxlabs-ubuntu-dux-*.sources")
|
||||||
|
want := []string{
|
||||||
|
"sudo add-apt-repository -y ppa:duxlabs/dux-repo",
|
||||||
|
"sudo add-apt-repository -r -y ppa:avengemedia/danklinux",
|
||||||
|
"sudo add-apt-repository -r -y ppa:duxlabs/dux",
|
||||||
|
"sudo apt-get update",
|
||||||
|
}
|
||||||
|
if got := u.RepoMoveCommands(moves); !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("debian", func(t *testing.T) {
|
||||||
|
moves := withOrig(obsAptChannel, obsAptListFile("home:AvengeMedia:dms-git"))
|
||||||
|
want := []string{
|
||||||
|
"curl -fsSL https://download.opensuse.org/repositories/home:/duxlabs:/dms-git/Debian_13/Release.key | gpg --dearmor | sudo tee /etc/apt/keyrings/home-duxlabs-dms-git.gpg",
|
||||||
|
"echo 'deb [signed-by=/etc/apt/keyrings/home-duxlabs-dms-git.gpg arch=amd64] https://download.opensuse.org/repositories/home:/duxlabs:/dms-git/Debian_13/ /' | sudo tee /etc/apt/sources.list.d/home-duxlabs-dms-git.list",
|
||||||
|
"sudo rm -f /etc/apt/sources.list.d/home-AvengeMedia-dms-git.list /etc/apt/keyrings/home-AvengeMedia-dms-git.gpg",
|
||||||
|
"sudo apt-get update",
|
||||||
|
}
|
||||||
|
if got := debianRepoMoveCommands(moves, "Debian_13", "amd64"); !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("void", func(t *testing.T) {
|
||||||
|
fakeXBPSConfs(t, map[string]string{
|
||||||
|
"/etc/xbps.d/dms.conf": "repository=https://void.danklinux.com/dms/current\n",
|
||||||
|
"/etc/xbps.d/dux.conf": "repository=https://void.danklinux.com/danklinux/current\n",
|
||||||
|
})
|
||||||
|
v := NewVoidDistribution(Registry["void"], nil)
|
||||||
|
want := []string{
|
||||||
|
"sudo sed -i 's|repository=https://void.danklinux.com/dms/current|repository=https://void.duxlabs.dev/dms/current|' /etc/xbps.d/dms.conf",
|
||||||
|
"sudo sed -i 's|repository=https://void.danklinux.com/danklinux/current|repository=https://void.duxlabs.dev/dux-repo/current|' /etc/xbps.d/dux.conf",
|
||||||
|
"sudo xbps-install -S",
|
||||||
|
}
|
||||||
|
if got := v.RepoMoveCommands(v.RepoMoves()); !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("void greeter only", func(t *testing.T) {
|
||||||
|
fakeXBPSConfs(t, map[string]string{"/etc/xbps.d/dms.conf": "repository=https://void.danklinux.com/dms/current\n"})
|
||||||
|
v := NewVoidDistribution(Registry["void"], nil)
|
||||||
|
want := []string{
|
||||||
|
"sudo sed -i 's|repository=https://void.danklinux.com/dms/current|repository=https://void.duxlabs.dev/dms/current|' /etc/xbps.d/dms.conf",
|
||||||
|
"sudo sh -c 'echo repository=https://void.duxlabs.dev/dux-repo/current >> /etc/xbps.d/dux.conf'",
|
||||||
|
"sudo xbps-install -S",
|
||||||
|
}
|
||||||
|
if got := v.RepoMoveCommands(v.RepoMoves()); !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Either spelling of the deps line already serves the greeter, so no second dux.conf entry.
|
||||||
|
for name, deps := range map[string]string{
|
||||||
|
"legacy": "repository=https://void.danklinux.com/danklinux/current\n",
|
||||||
|
"new": "repository=https://void.duxlabs.dev/dux-repo/current\n",
|
||||||
|
} {
|
||||||
|
t.Run("void deps already "+name, func(t *testing.T) {
|
||||||
|
fakeXBPSConfs(t, map[string]string{
|
||||||
|
"/etc/xbps.d/dms.conf": "repository=https://void.danklinux.com/dms/current\n",
|
||||||
|
"/etc/xbps.d/danklinux.conf": deps,
|
||||||
|
})
|
||||||
|
v := NewVoidDistribution(Registry["void"], nil)
|
||||||
|
for _, command := range v.RepoMoveCommands(v.RepoMoves()) {
|
||||||
|
if strings.Contains(command, "dux.conf") {
|
||||||
|
t.Fatalf("added a duplicate deps repo: %q", command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("opensuse", func(t *testing.T) {
|
||||||
|
moves := withOrig(obsZypperChannel, obsZypperRepoFile("home:AvengeMedia:danklinux"))
|
||||||
|
want := []string{
|
||||||
|
"sudo zypper addrepo -f https://download.opensuse.org/repositories/home:/duxlabs:/dux-repo/openSUSE_Tumbleweed/home:duxlabs:dux-repo.repo",
|
||||||
|
"sudo zypper --gpg-auto-import-keys refresh",
|
||||||
|
"sudo zypper removerepo home_AvengeMedia_danklinux",
|
||||||
|
}
|
||||||
|
if got := opensuseRepoMoveCommands(moves, "openSUSE_Tumbleweed"); !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Until OBS publishes, movers must plan nothing even with legacy files present, or doctor and migrate nag.
|
||||||
|
func TestOBSMigrationGate(t *testing.T) {
|
||||||
|
if obsMigrationReady {
|
||||||
|
t.Skip("gate open")
|
||||||
|
}
|
||||||
|
orig := repoPathExists
|
||||||
|
repoPathExists = func(string) bool { return true }
|
||||||
|
t.Cleanup(func() { repoPathExists = orig })
|
||||||
|
|
||||||
|
if moves := NewDebianDistribution(Registry["debian"], nil).RepoMoves(); moves != nil {
|
||||||
|
t.Fatalf("debian planned %v while the gate is closed", moves)
|
||||||
|
}
|
||||||
|
if moves := NewOpenSUSEDistribution(Registry["opensuse-tumbleweed"], nil).RepoMoves(); moves != nil {
|
||||||
|
t.Fatalf("opensuse planned %v while the gate is closed", moves)
|
||||||
|
}
|
||||||
|
if got := NewDebianDistribution(Registry["debian"], nil).GetPackageMapping(0)["dms-greeter"].RepoURL; got != "home:AvengeMedia:danklinux" {
|
||||||
|
t.Fatalf("debian mapping = %q, must stay on the legacy OBS project while the gate is closed", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwapVariantsDisablesLegacyCOPR(t *testing.T) {
|
||||||
|
var commands []string
|
||||||
|
origExec, origExists := privescExec, repoPathExists
|
||||||
|
privescExec = func(_ context.Context, _, command string) *exec.Cmd {
|
||||||
|
commands = append(commands, command)
|
||||||
|
return exec.Command("true")
|
||||||
|
}
|
||||||
|
repoPathExists = existsIn(coprRepoFile("avengemedia/dms"))
|
||||||
|
t.Cleanup(func() { privescExec, repoPathExists = origExec, origExists })
|
||||||
|
|
||||||
|
f := NewFedoraDistribution(Registry["fedora"], nil)
|
||||||
|
swaps := []VariantSwap{{Dep: "dms (Dux Shell)", From: f.getDmsMapping(0), To: f.getDmsMapping(1)}}
|
||||||
|
progress := make(chan InstallProgressMsg, 100)
|
||||||
|
if err := f.SwapVariants(context.Background(), swaps, "", progress); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
joined := strings.Join(commands, "\n")
|
||||||
|
if !strings.Contains(joined, "dnf copr disable -y avengemedia/dms") {
|
||||||
|
t.Fatalf("legacy COPR left enabled on an un-migrated host:\n%s", joined)
|
||||||
|
}
|
||||||
|
if strings.Contains(joined, "dnf copr disable -y duxlabs/dms") {
|
||||||
|
t.Fatalf("disabled a COPR whose file does not exist:\n%s", joined)
|
||||||
|
}
|
||||||
|
if !strings.Contains(joined, "dnf copr enable -y duxlabs/dms-git") {
|
||||||
|
t.Fatalf("new git COPR not enabled:\n%s", joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
-16
@@ -2,14 +2,20 @@ package distros
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
|
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/netfetch"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
|
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const hyprlandPPA = "ppa:cppiber/hyprland"
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
Register("ubuntu", "#E95420", FamilyUbuntu, func(config DistroConfig, logChan chan<- string) Distribution {
|
Register("ubuntu", "#E95420", FamilyUbuntu, func(config DistroConfig, logChan chan<- string) Distribution {
|
||||||
return NewUbuntuDistribution(config, logChan)
|
return NewUbuntuDistribution(config, logChan)
|
||||||
@@ -123,6 +129,51 @@ func (u *UbuntuDistribution) SwapVariants(ctx context.Context, swaps []VariantSw
|
|||||||
return u.installAPTPackages(ctx, to, sudoPassword, progressChan)
|
return u.installAPTPackages(ctx, to, sudoPassword, progressChan)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *UbuntuDistribution) RepoMoves() []RepoMove {
|
||||||
|
return ppaChannel.moves(ppaSourcesExists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UbuntuDistribution) ApplyRepoMoves(ctx context.Context, moves []RepoMove, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
|
if adds := repoMoveAdds(moves); len(adds) > 0 {
|
||||||
|
if err := u.enablePPARepos(ctx, adds, sudoPassword, progressChan); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removes := repoMoveRemoves(moves)
|
||||||
|
for _, move := range removes {
|
||||||
|
command := "add-apt-repository -r -y " + move.Old
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseSystemPackages,
|
||||||
|
Progress: 0.30,
|
||||||
|
Step: fmt.Sprintf("Removing PPA %s...", move.Old),
|
||||||
|
NeedsSudo: true,
|
||||||
|
CommandInfo: "sudo " + command,
|
||||||
|
}
|
||||||
|
if err := u.runWithProgress(privescExec(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.30, 0.35); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove PPA %s: %w", move.Old, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(removes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return u.aptUpdate(ctx, sudoPassword, progressChan)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UbuntuDistribution) RepoMoveCommands(moves []RepoMove) []string {
|
||||||
|
var commands []string
|
||||||
|
for _, add := range repoMoveAdds(moves) {
|
||||||
|
commands = append(commands, "sudo add-apt-repository -y "+add.RepoURL)
|
||||||
|
}
|
||||||
|
removes := repoMoveRemoves(moves)
|
||||||
|
for _, move := range removes {
|
||||||
|
commands = append(commands, "sudo add-apt-repository -r -y "+move.Old)
|
||||||
|
}
|
||||||
|
if len(removes) > 0 {
|
||||||
|
commands = append(commands, "sudo apt-get update")
|
||||||
|
}
|
||||||
|
return commands
|
||||||
|
}
|
||||||
|
|
||||||
func (u *UbuntuDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
|
func (u *UbuntuDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
|
||||||
return u.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
|
return u.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
|
||||||
}
|
}
|
||||||
@@ -139,20 +190,20 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
|||||||
// DMS packages from PPAs
|
// DMS packages from PPAs
|
||||||
"dms (Dux Shell)": u.getDmsMapping(variants["dms (Dux Shell)"]),
|
"dms (Dux Shell)": u.getDmsMapping(variants["dms (Dux Shell)"]),
|
||||||
"quickshell": u.getQuickshellMapping(variants["quickshell"]),
|
"quickshell": u.getQuickshellMapping(variants["quickshell"]),
|
||||||
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypePPA, "ppa:avengemedia/danklinux"),
|
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypePPA, ppaDeps),
|
||||||
"matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
"matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: ppaDeps},
|
||||||
"ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
"ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: ppaDeps},
|
||||||
"danksearch": {Name: "danksearch", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
"danksearch": {Name: "danksearch", Repository: RepoTypePPA, RepoURL: ppaDeps},
|
||||||
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypePPA, "ppa:avengemedia/danklinux"),
|
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypePPA, ppaDeps),
|
||||||
}
|
}
|
||||||
|
|
||||||
switch wm {
|
switch wm {
|
||||||
case deps.WindowManagerHyprland:
|
case deps.WindowManagerHyprland:
|
||||||
// Use the cppiber PPA for Hyprland
|
// Use the cppiber PPA for Hyprland
|
||||||
packages["hyprland"] = PackageMapping{Name: "hyprland", Repository: RepoTypePPA, RepoURL: "ppa:cppiber/hyprland"}
|
packages["hyprland"] = PackageMapping{Name: "hyprland", Repository: RepoTypePPA, RepoURL: hyprlandPPA}
|
||||||
packages["hyprctl"] = PackageMapping{Name: "hyprland", Repository: RepoTypePPA, RepoURL: "ppa:cppiber/hyprland"}
|
packages["hyprctl"] = PackageMapping{Name: "hyprland", Repository: RepoTypePPA, RepoURL: hyprlandPPA}
|
||||||
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
|
packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem}
|
||||||
packages["hyprland-guiutils"] = PackageMapping{Name: "hyprland-guiutils", Repository: RepoTypePPA, RepoURL: "ppa:cppiber/hyprland"}
|
packages["hyprland-guiutils"] = PackageMapping{Name: "hyprland-guiutils", Repository: RepoTypePPA, RepoURL: hyprlandPPA}
|
||||||
case deps.WindowManagerNiri:
|
case deps.WindowManagerNiri:
|
||||||
niriVariant := variants["niri"]
|
niriVariant := variants["niri"]
|
||||||
packages["niri"] = u.getNiriMapping(niriVariant)
|
packages["niri"] = u.getNiriMapping(niriVariant)
|
||||||
@@ -164,30 +215,30 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
|||||||
|
|
||||||
func (u *UbuntuDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping {
|
func (u *UbuntuDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if variant == deps.VariantGit {
|
if variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "dms-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/dms-git"}
|
return PackageMapping{Name: "dms-git", Repository: RepoTypePPA, RepoURL: ppaDMSGit}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "dms", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/dms"}
|
return PackageMapping{Name: "dms", Repository: RepoTypePPA, RepoURL: ppaDMS}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UbuntuDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping {
|
func (u *UbuntuDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if forceQuickshellGit || variant == deps.VariantGit {
|
if forceQuickshellGit || variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "quickshell-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}
|
return PackageMapping{Name: "quickshell-git", Repository: RepoTypePPA, RepoURL: ppaDeps}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "quickshell", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}
|
return PackageMapping{Name: "quickshell", Repository: RepoTypePPA, RepoURL: ppaDeps}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UbuntuDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
|
func (u *UbuntuDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if variant == deps.VariantGit {
|
if variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "niri-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}
|
return PackageMapping{Name: "niri-git", Repository: RepoTypePPA, RepoURL: ppaDeps}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "niri", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}
|
return PackageMapping{Name: "niri", Repository: RepoTypePPA, RepoURL: ppaDeps}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UbuntuDistribution) getXwaylandSatelliteMapping(variant deps.PackageVariant) PackageMapping {
|
func (u *UbuntuDistribution) getXwaylandSatelliteMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if variant == deps.VariantGit {
|
if variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "xwayland-satellite-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}
|
return PackageMapping{Name: "xwayland-satellite-git", Repository: RepoTypePPA, RepoURL: ppaDeps}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "xwayland-satellite", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}
|
return PackageMapping{Name: "xwayland-satellite", Repository: RepoTypePPA, RepoURL: ppaDeps}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
@@ -265,6 +316,7 @@ func (u *UbuntuDistribution) InstallPackages(ctx context.Context, dependencies [
|
|||||||
}
|
}
|
||||||
|
|
||||||
systemPkgs, ppaPkgs, manualPkgs, variantMap := u.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
|
systemPkgs, ppaPkgs, manualPkgs, variantMap := u.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
|
||||||
|
systemPkgs, ppaPkgs = u.fallbackUnpublishedPPAs(ctx, systemPkgs, ppaPkgs)
|
||||||
|
|
||||||
// Phase 2: Enable PPA repositories
|
// Phase 2: Enable PPA repositories
|
||||||
if len(ppaPkgs) > 0 {
|
if len(ppaPkgs) > 0 {
|
||||||
@@ -417,6 +469,45 @@ func (u *UbuntuDistribution) extractPackageNames(packages []PackageMapping) []st
|
|||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use the Ubuntu archive when the Hyprland PPA has no build for this series yet.
|
||||||
|
func (u *UbuntuDistribution) fallbackUnpublishedPPAs(ctx context.Context, systemPkgs []string, ppaPkgs []PackageMapping) ([]string, []PackageMapping) {
|
||||||
|
usesHyprlandPPA := false
|
||||||
|
for _, pkg := range ppaPkgs {
|
||||||
|
usesHyprlandPPA = usesHyprlandPPA || pkg.RepoURL == hyprlandPPA
|
||||||
|
}
|
||||||
|
if !usesHyprlandPPA {
|
||||||
|
return systemPkgs, ppaPkgs
|
||||||
|
}
|
||||||
|
|
||||||
|
osInfo, err := GetOSInfo()
|
||||||
|
if err != nil || osInfo.VersionCodename == "" || ppaPublishesSeries(ctx, hyprlandPPA, osInfo.VersionCodename) {
|
||||||
|
return systemPkgs, ppaPkgs
|
||||||
|
}
|
||||||
|
u.log(fmt.Sprintf("%s has no %s build, installing its packages from the Ubuntu archive", hyprlandPPA, osInfo.VersionCodename))
|
||||||
|
|
||||||
|
kept := ppaPkgs[:0]
|
||||||
|
for _, pkg := range ppaPkgs {
|
||||||
|
if pkg.RepoURL == hyprlandPPA {
|
||||||
|
systemPkgs = append(systemPkgs, pkg.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, pkg)
|
||||||
|
}
|
||||||
|
return systemPkgs, kept
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a 404 counts as unpublished; network errors keep the PPA.
|
||||||
|
func ppaPublishesSeries(ctx context.Context, ppa, series string) bool {
|
||||||
|
owner, name, ok := strings.Cut(strings.TrimPrefix(ppa, "ppa:"), "/")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("https://ppa.launchpadcontent.net/%s/%s/ubuntu/dists/%s/Release", owner, name, series)
|
||||||
|
_, err := netfetch.Bytes(ctx, url, netfetch.Options{Timeout: 15 * time.Second})
|
||||||
|
var statusErr *netfetch.StatusError
|
||||||
|
return !errors.As(err, &statusErr) || statusErr.Code != http.StatusNotFound
|
||||||
|
}
|
||||||
|
|
||||||
func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
enabledRepos := make(map[string]bool)
|
enabledRepos := make(map[string]bool)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ type swapFake struct {
|
|||||||
|
|
||||||
func (f *swapFake) GetPackageMappingWithVariants(_ deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping {
|
func (f *swapFake) GetPackageMappingWithVariants(_ deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping {
|
||||||
return map[string]PackageMapping{
|
return map[string]PackageMapping{
|
||||||
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeCOPR, "avengemedia/danklinux"),
|
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeCOPR, "duxlabs/dux-repo"),
|
||||||
"matugen": {Name: "matugen", Repository: RepoTypeSystem},
|
"matugen": {Name: "matugen", Repository: RepoTypeSystem},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,15 @@ import (
|
|||||||
|
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
|
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
|
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/site"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
VoidDMSRepo = site.VoidRepo + "/dms/current"
|
VoidDMSRepo = voidHost + "/" + projectDMS + "/current"
|
||||||
VoidDankLinuxRepo = site.VoidRepo + "/danklinux/current"
|
VoidDepsRepo = voidHost + "/" + projectDeps + "/current"
|
||||||
VoidHyprlandRepo = "https://mirror.black-hole.dev/x86_64"
|
VoidHyprlandRepo = "https://mirror.black-hole.dev/x86_64"
|
||||||
|
|
||||||
|
voidDepsConf = "/etc/xbps.d/dux.conf"
|
||||||
|
|
||||||
voidRunitSvDir = "/etc/sv"
|
voidRunitSvDir = "/etc/sv"
|
||||||
voidRunitServiceDir = "/var/service"
|
voidRunitServiceDir = "/var/service"
|
||||||
)
|
)
|
||||||
@@ -193,6 +194,103 @@ func (v *VoidDistribution) SwapVariants(ctx context.Context, swaps []VariantSwap
|
|||||||
return fmt.Errorf("failed to install %s, restored %s: %w", strings.Join(to, ", "), strings.Join(from, ", "), installErr)
|
return fmt.Errorf("failed to install %s, restored %s: %w", strings.Join(to, ", "), strings.Join(from, ", "), installErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) RepoMoves() []RepoMove {
|
||||||
|
var moves []RepoMove
|
||||||
|
legacy, hasDeps := false, false
|
||||||
|
for _, path := range repoGlob(voidConfGlob) {
|
||||||
|
for _, url := range xbpsRepositoryLines(path) {
|
||||||
|
move, ok := voidRepoMove(path, url)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
legacy = legacy || move.OldPresent
|
||||||
|
hasDeps = hasDeps || strings.HasPrefix(move.New, voidHost+"/"+projectDeps+"/")
|
||||||
|
moves = append(moves, move)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Greeter-only hosts got dms-greeter from dms/current, which no longer carries it.
|
||||||
|
if legacy && !hasDeps {
|
||||||
|
moves = append(moves, RepoMove{Kind: RepoTypeXBPS, New: VoidDepsRepo, NewPath: voidDepsConf})
|
||||||
|
}
|
||||||
|
return moves
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches on the repository line because older installs named the conf files differently.
|
||||||
|
func xbpsRepositoryLines(path string) []string {
|
||||||
|
content, err := repoReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var urls []string
|
||||||
|
for line := range strings.Lines(string(content)) {
|
||||||
|
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||||
|
if !ok || strings.TrimSpace(key) != "repository" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
urls = append(urls, strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
|
func voidRepoMove(path, url string) (RepoMove, bool) {
|
||||||
|
if strings.HasPrefix(url, voidHost+"/") {
|
||||||
|
return RepoMove{Kind: RepoTypeXBPS, New: url, NewPath: path, NewPresent: true}, true
|
||||||
|
}
|
||||||
|
rest, ok := strings.CutPrefix(url, legacyVoidHost+"/")
|
||||||
|
if !ok {
|
||||||
|
return RepoMove{}, false
|
||||||
|
}
|
||||||
|
if deps, ok := strings.CutPrefix(rest, legacyProjectDeps+"/"); ok {
|
||||||
|
rest = projectDeps + "/" + deps
|
||||||
|
}
|
||||||
|
return RepoMove{Kind: RepoTypeXBPS, Old: url, New: voidHost + "/" + rest, OldPath: path, NewPath: path, OldPresent: true}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func voidAddsRepo(move RepoMove) bool {
|
||||||
|
return !move.OldPresent && !move.NewPresent && move.NewPath != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func voidMoveCommands(moves []RepoMove) []string {
|
||||||
|
var commands []string
|
||||||
|
for _, move := range moves {
|
||||||
|
switch {
|
||||||
|
case move.OldPresent:
|
||||||
|
commands = append(commands, fmt.Sprintf("sed -i 's|repository=%s|repository=%s|' %s", move.Old, move.New, move.OldPath))
|
||||||
|
case voidAddsRepo(move):
|
||||||
|
// Appends so an unrelated file that happens to share the name is not clobbered.
|
||||||
|
commands = append(commands, fmt.Sprintf("sh -c 'echo repository=%s >> %s'", move.New, move.NewPath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(commands) > 0 {
|
||||||
|
commands = append(commands, "xbps-install -S")
|
||||||
|
}
|
||||||
|
return commands
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) ApplyRepoMoves(ctx context.Context, moves []RepoMove, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||||
|
for _, command := range voidMoveCommands(moves) {
|
||||||
|
progressChan <- InstallProgressMsg{
|
||||||
|
Phase: PhaseSystemPackages,
|
||||||
|
Progress: 0.30,
|
||||||
|
Step: "Moving XBPS repositories...",
|
||||||
|
NeedsSudo: true,
|
||||||
|
CommandInfo: "sudo " + command,
|
||||||
|
}
|
||||||
|
if err := v.runWithProgress(privescExec(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.30, 0.40); err != nil {
|
||||||
|
return fmt.Errorf("failed to run %s: %w", command, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VoidDistribution) RepoMoveCommands(moves []RepoMove) []string {
|
||||||
|
var commands []string
|
||||||
|
for _, command := range voidMoveCommands(moves) {
|
||||||
|
commands = append(commands, "sudo "+command)
|
||||||
|
}
|
||||||
|
return commands
|
||||||
|
}
|
||||||
|
|
||||||
func (v *VoidDistribution) packageVersion(pkg string) string {
|
func (v *VoidDistribution) packageVersion(pkg string) string {
|
||||||
output, err := exec.Command("xbps-query", "-p", "pkgver", pkg).Output()
|
output, err := exec.Command("xbps-query", "-p", "pkgver", pkg).Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -220,9 +318,9 @@ func (v *VoidDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
|
|||||||
"quickshell": {Name: "quickshell", Repository: RepoTypeSystem},
|
"quickshell": {Name: "quickshell", Repository: RepoTypeSystem},
|
||||||
"matugen": {Name: "matugen", Repository: RepoTypeSystem},
|
"matugen": {Name: "matugen", Repository: RepoTypeSystem},
|
||||||
"dms (Dux Shell)": v.getDmsMapping(variants["dms (Dux Shell)"]),
|
"dms (Dux Shell)": v.getDmsMapping(variants["dms (Dux Shell)"]),
|
||||||
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeXBPS, VoidDMSRepo),
|
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeXBPS, VoidDepsRepo),
|
||||||
"danksearch": {Name: "danksearch", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
|
"danksearch": {Name: "danksearch", Repository: RepoTypeXBPS, RepoURL: VoidDepsRepo},
|
||||||
"dankcalendar": {Name: "dankcalendar", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
|
"dankcalendar": {Name: "dankcalendar", Repository: RepoTypeXBPS, RepoURL: VoidDepsRepo},
|
||||||
}
|
}
|
||||||
|
|
||||||
switch wm {
|
switch wm {
|
||||||
@@ -521,8 +619,8 @@ func (v *VoidDistribution) xbpsRepoName(repoURL string) string {
|
|||||||
switch repoURL {
|
switch repoURL {
|
||||||
case VoidDMSRepo:
|
case VoidDMSRepo:
|
||||||
return "dms"
|
return "dms"
|
||||||
case VoidDankLinuxRepo:
|
case VoidDepsRepo:
|
||||||
return "danklinux"
|
return "dux"
|
||||||
case VoidHyprlandRepo:
|
case VoidHyprlandRepo:
|
||||||
return "hyprland"
|
return "hyprland"
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package headless
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -17,6 +18,8 @@ import (
|
|||||||
// must explicitly confirm the operation.
|
// must explicitly confirm the operation.
|
||||||
var ErrConfirmationRequired = fmt.Errorf("confirmation required: pass --yes to proceed")
|
var ErrConfirmationRequired = fmt.Errorf("confirmation required: pass --yes to proceed")
|
||||||
|
|
||||||
|
var ErrPackagesNotLayered = fmt.Errorf("packages must be layered into the image before configuration can be deployed")
|
||||||
|
|
||||||
// validConfigNames maps lowercase CLI input to the deployer key used in
|
// validConfigNames maps lowercase CLI input to the deployer key used in
|
||||||
// replaceConfigs. Keep in sync with the config types checked by
|
// replaceConfigs. Keep in sync with the config types checked by
|
||||||
// shouldReplaceConfig in deployer.go.
|
// shouldReplaceConfig in deployer.go.
|
||||||
@@ -101,11 +104,17 @@ func (r *Runner) Run() error {
|
|||||||
return fmt.Errorf("OS detection failed: %w", err)
|
return fmt.Errorf("OS detection failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) {
|
switch {
|
||||||
|
case osInfo.Immutable && !distros.IsDistroSupported(osInfo.Distribution.ID):
|
||||||
|
return errors.New(distros.ImmutableGuidance(osInfo))
|
||||||
|
case osInfo.Immutable:
|
||||||
|
fmt.Fprintf(os.Stdout, "Detected: %s (%s), image-based: %s\n", osInfo.PrettyName, osInfo.Architecture, osInfo.ImmutableReason)
|
||||||
|
fmt.Fprintln(os.Stdout, "No packages will be installed; only configuration is deployed.")
|
||||||
|
case distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID):
|
||||||
return fmt.Errorf("unsupported distribution: %s %s", osInfo.PrettyName, osInfo.VersionID)
|
return fmt.Errorf("unsupported distribution: %s %s", osInfo.PrettyName, osInfo.VersionID)
|
||||||
}
|
default:
|
||||||
|
|
||||||
fmt.Fprintf(os.Stdout, "Detected: %s (%s)\n", osInfo.PrettyName, osInfo.Architecture)
|
fmt.Fprintf(os.Stdout, "Detected: %s (%s)\n", osInfo.PrettyName, osInfo.Architecture)
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Create distribution instance
|
// 4. Create distribution instance
|
||||||
distro, err := distros.NewDistribution(osInfo.Distribution.ID, r.logChan)
|
distro, err := distros.NewDistribution(osInfo.Distribution.ID, r.logChan)
|
||||||
@@ -120,6 +129,9 @@ func (r *Runner) Run() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("dependency detection failed: %w", err)
|
return fmt.Errorf("dependency detection failed: %w", err)
|
||||||
}
|
}
|
||||||
|
if osInfo.Immutable {
|
||||||
|
dependencies = distros.ApplyImmutableDetection(dependencies)
|
||||||
|
}
|
||||||
|
|
||||||
reinstallItems, err := r.applyGitVariants(dependencies, gitVariantChecker(distro, wm, dependencies))
|
reinstallItems, err := r.applyGitVariants(dependencies, gitVariantChecker(distro, wm, dependencies))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -158,14 +170,25 @@ func (r *Runner) Run() error {
|
|||||||
marker = " RE "
|
marker = " RE "
|
||||||
status = "(will reinstall)"
|
status = "(will reinstall)"
|
||||||
}
|
}
|
||||||
|
if osInfo.Immutable && dep.Status != deps.StatusInstalled {
|
||||||
|
status = "(not installed)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stdout, "%s%-30s %s\n", marker, dep.Name, status)
|
fmt.Fprintf(os.Stdout, "%s%-30s %s\n", marker, dep.Name, status)
|
||||||
}
|
}
|
||||||
fmt.Fprintln(os.Stdout)
|
fmt.Fprintln(os.Stdout)
|
||||||
|
|
||||||
|
if osInfo.Immutable {
|
||||||
|
if err := r.requireLayeredPackages(distro, dependencies, wm, reinstallItems, disabledItems); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 6b. Require explicit confirmation unless --yes is set
|
// 6b. Require explicit confirmation unless --yes is set
|
||||||
if !r.cfg.Yes {
|
if !r.cfg.Yes {
|
||||||
if replaceConfigs == nil {
|
if osInfo.Immutable {
|
||||||
|
fmt.Fprintln(os.Stdout, "All packages are present. Only configurations will be deployed.")
|
||||||
|
} else if replaceConfigs == nil {
|
||||||
// --replace-configs-all
|
// --replace-configs-all
|
||||||
fmt.Fprintln(os.Stdout, "Packages will be installed and all configurations will be replaced.")
|
fmt.Fprintln(os.Stdout, "Packages will be installed and all configurations will be replaced.")
|
||||||
fmt.Fprintln(os.Stdout, "Existing config files will be backed up before replacement.")
|
fmt.Fprintln(os.Stdout, "Existing config files will be backed up before replacement.")
|
||||||
@@ -186,57 +209,21 @@ func (r *Runner) Run() error {
|
|||||||
return ErrConfirmationRequired
|
return ErrConfirmationRequired
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Authenticate sudo
|
sudoPassword := ""
|
||||||
sudoPassword, err := r.resolveSudoPassword()
|
if !osInfo.Immutable {
|
||||||
|
sudoPassword, err = r.installPackages(distro, dependencies, wm, reinstallItems, disabledItems)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Install packages
|
|
||||||
fmt.Fprintln(os.Stdout, "Installing packages...")
|
|
||||||
r.log("Starting package installation")
|
|
||||||
|
|
||||||
progressChan := make(chan distros.InstallProgressMsg, 100)
|
|
||||||
|
|
||||||
installErr := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
defer close(progressChan)
|
|
||||||
installErr <- distro.InstallPackages(
|
|
||||||
context.Background(),
|
|
||||||
dependencies,
|
|
||||||
wm,
|
|
||||||
sudoPassword,
|
|
||||||
reinstallItems,
|
|
||||||
disabledItems,
|
|
||||||
false, // skipGlobalUseFlags
|
|
||||||
progressChan,
|
|
||||||
)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Consume progress messages and print them
|
|
||||||
for msg := range progressChan {
|
|
||||||
if msg.Error != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error)
|
|
||||||
} else if msg.Step != "" {
|
|
||||||
fmt.Fprintf(os.Stdout, " [%3.0f%%] %s\n", msg.Progress*100, msg.Step)
|
|
||||||
}
|
|
||||||
if msg.LogOutput != "" {
|
|
||||||
r.log(msg.LogOutput)
|
|
||||||
fmt.Fprintf(os.Stdout, " %s\n", msg.LogOutput)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := <-installErr; err != nil {
|
useSystemd := distros.HasSystemd()
|
||||||
return fmt.Errorf("package installation failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
useSystemd := true
|
// 9. Greeter setup. On images dms-greeter escalates itself, so it is left to the user.
|
||||||
if distroConfig, exists := distros.Registry[osInfo.Distribution.ID]; exists && distroConfig.Family == distros.FamilyVoid {
|
if osInfo.Immutable && !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
|
||||||
useSystemd = false
|
fmt.Fprintln(os.Stdout, "Greeter: run 'dms-greeter install --yes' to configure greetd.")
|
||||||
}
|
}
|
||||||
|
if !osInfo.Immutable && !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
|
||||||
// 9. Greeter setup (if dms-greeter was included)
|
|
||||||
if !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
|
|
||||||
fmt.Fprintln(os.Stdout, "Configuring DMS greeter...")
|
fmt.Fprintln(os.Stdout, "Configuring DMS greeter...")
|
||||||
logFunc := func(line string) {
|
logFunc := func(line string) {
|
||||||
r.log(line)
|
r.log(line)
|
||||||
@@ -531,14 +518,14 @@ func (r *Runner) resolveSudoPassword() (string, error) {
|
|||||||
return "", fmt.Errorf(
|
return "", fmt.Errorf(
|
||||||
"sudo authentication required but no cached credentials found\n" +
|
"sudo authentication required but no cached credentials found\n" +
|
||||||
"Options:\n" +
|
"Options:\n" +
|
||||||
" 1. Run 'sudo -v' before dankinstall to cache credentials\n" +
|
" 1. Run 'sudo -v' before duxinstall to cache credentials\n" +
|
||||||
" 2. Configure passwordless sudo for your user",
|
" 2. Configure passwordless sudo for your user",
|
||||||
)
|
)
|
||||||
case privesc.ToolDoas:
|
case privesc.ToolDoas:
|
||||||
return "", fmt.Errorf(
|
return "", fmt.Errorf(
|
||||||
"doas authentication required but no cached credentials found\n" +
|
"doas authentication required but no cached credentials found\n" +
|
||||||
"Options:\n" +
|
"Options:\n" +
|
||||||
" 1. Run 'doas true' before dankinstall to cache credentials (requires 'persist' in /etc/doas.conf)\n" +
|
" 1. Run 'doas true' before duxinstall to cache credentials (requires 'persist' in /etc/doas.conf)\n" +
|
||||||
" 2. Configure a 'nopass' rule in /etc/doas.conf for your user",
|
" 2. Configure a 'nopass' rule in /etc/doas.conf for your user",
|
||||||
)
|
)
|
||||||
case privesc.ToolRun0:
|
case privesc.ToolRun0:
|
||||||
@@ -569,3 +556,69 @@ func (r *Runner) depExists(dependencies []deps.Dependency, name string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Runner) requireLayeredPackages(distro distros.Distribution, dependencies []deps.Dependency, wm deps.WindowManager, reinstallItems, disabledItems map[string]bool) error {
|
||||||
|
pending := distros.PendingDependencies(dependencies, reinstallItems, disabledItems)
|
||||||
|
if len(pending) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
layerer, ok := distro.(distros.PackageLayerer)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("this image has no known way to add packages; install them with the image's own tooling, reboot, and run duxinstall again")
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stdout, "Packages must be added to the image first. Run these, reboot, then run duxinstall again:")
|
||||||
|
fmt.Fprintln(os.Stdout)
|
||||||
|
for _, cmd := range layerer.LayerCommands(dependencies, wm, reinstallItems, disabledItems) {
|
||||||
|
fmt.Fprintf(os.Stdout, " %s\n", cmd)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stdout)
|
||||||
|
r.log("Aborted: packages not layered on image-based host")
|
||||||
|
return ErrPackagesNotLayered
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) installPackages(distro distros.Distribution, dependencies []deps.Dependency, wm deps.WindowManager, reinstallItems, disabledItems map[string]bool) (string, error) {
|
||||||
|
// 7. Authenticate sudo
|
||||||
|
sudoPassword, err := r.resolveSudoPassword()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Install packages
|
||||||
|
fmt.Fprintln(os.Stdout, "Installing packages...")
|
||||||
|
r.log("Starting package installation")
|
||||||
|
|
||||||
|
progressChan := make(chan distros.InstallProgressMsg, 100)
|
||||||
|
|
||||||
|
installErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
defer close(progressChan)
|
||||||
|
installErr <- distro.InstallPackages(
|
||||||
|
context.Background(),
|
||||||
|
dependencies,
|
||||||
|
wm,
|
||||||
|
sudoPassword,
|
||||||
|
reinstallItems,
|
||||||
|
disabledItems,
|
||||||
|
false, // skipGlobalUseFlags
|
||||||
|
progressChan,
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
for msg := range progressChan {
|
||||||
|
if msg.Error != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error)
|
||||||
|
} else if msg.Step != "" {
|
||||||
|
fmt.Fprintf(os.Stdout, " [%3.0f%%] %s\n", msg.Progress*100, msg.Step)
|
||||||
|
}
|
||||||
|
if msg.LogOutput != "" {
|
||||||
|
r.log(msg.LogOutput)
|
||||||
|
fmt.Fprintf(os.Stdout, " %s\n", msg.LogOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := <-installErr; err != nil {
|
||||||
|
return "", fmt.Errorf("package installation failed: %w", err)
|
||||||
|
}
|
||||||
|
return sudoPassword, nil
|
||||||
|
}
|
||||||
@@ -23,15 +23,18 @@ type FileLogger struct {
|
|||||||
func NewFileLogger() (*FileLogger, error) {
|
func NewFileLogger() (*FileLogger, error) {
|
||||||
timestamp := time.Now().Unix()
|
timestamp := time.Now().Unix()
|
||||||
|
|
||||||
// Use DANKINSTALL_LOG_DIR if set, otherwise fall back to /tmp.
|
logDir := os.Getenv("DUXINSTALL_LOG_DIR")
|
||||||
logDir := os.Getenv("DANKINSTALL_LOG_DIR")
|
if logDir == "" {
|
||||||
|
// Pre-rename name, still set in existing scripts.
|
||||||
|
logDir = os.Getenv("DANKINSTALL_LOG_DIR")
|
||||||
|
}
|
||||||
if logDir == "" {
|
if logDir == "" {
|
||||||
logDir = "/tmp"
|
logDir = "/tmp"
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create log directory: %w", err)
|
return nil, fmt.Errorf("failed to create log directory: %w", err)
|
||||||
}
|
}
|
||||||
logPath := filepath.Join(logDir, fmt.Sprintf("dankinstall-%d.log", timestamp))
|
logPath := filepath.Join(logDir, fmt.Sprintf("duxinstall-%d.log", timestamp))
|
||||||
|
|
||||||
file, err := os.Create(logPath)
|
file, err := os.Create(logPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -49,7 +52,7 @@ func NewFileLogger() (*FileLogger, error) {
|
|||||||
passwordRe: passwordRe,
|
passwordRe: passwordRe,
|
||||||
}
|
}
|
||||||
|
|
||||||
header := fmt.Sprintf("=== DankInstall Log ===\nStarted: %s\n\n", time.Now().Format(time.RFC3339))
|
header := fmt.Sprintf("=== DuxInstall Log ===\nStarted: %s\n\n", time.Now().Format(time.RFC3339))
|
||||||
logger.writeToFile(header)
|
logger.writeToFile(header)
|
||||||
|
|
||||||
return logger, nil
|
return logger, nil
|
||||||
@@ -102,7 +105,7 @@ func (l *FileLogger) Close() error {
|
|||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
footer := fmt.Sprintf("\n=== DankInstall Log End ===\nCompleted: %s\n", time.Now().Format(time.RFC3339))
|
footer := fmt.Sprintf("\n=== DuxInstall Log End ===\nCompleted: %s\n", time.Now().Format(time.RFC3339))
|
||||||
l.writer.WriteString(footer) //nolint:errcheck
|
l.writer.WriteString(footer) //nolint:errcheck
|
||||||
l.writer.Flush()
|
l.writer.Flush()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Package mangoconf holds what DMS knows about Mango's config files: where they
|
||||||
|
// live, the session target, and the markers DMS leaves in the binds fragment.
|
||||||
|
package mangoconf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mango reads each line into a 512-byte buffer and the value into 255 bytes; longer is cut silently.
|
||||||
|
const maxValueLen = 255
|
||||||
|
|
||||||
|
func CheckLine(line string) error {
|
||||||
|
_, value, ok := strings.Cut(line, "=")
|
||||||
|
if !ok || len(strings.TrimSpace(value)) <= maxValueLen {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("line is %d characters after '='; Mango truncates past %d", len(strings.TrimSpace(value)), maxValueLen)
|
||||||
|
}
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
// Package mangoconf maps Mango config keys between the run-together names of
|
|
||||||
// Mango <= 0.17.5 and the snake_case names of upstream 8adc6bc3 (mangowm/mango#1491).
|
|
||||||
// Neither parser accepts the other's names.
|
|
||||||
package mangoconf
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Dialect int
|
|
||||||
|
|
||||||
const (
|
|
||||||
Legacy Dialect = iota
|
|
||||||
Snake
|
|
||||||
)
|
|
||||||
|
|
||||||
// Upstream migration order: where two legacy names share a snake name, the
|
|
||||||
// window-rule spelling comes first and wins the reverse lookup.
|
|
||||||
var renames = [][2]string{
|
|
||||||
{"windowrule-once", "window_rule_once"},
|
|
||||||
{"windowrule", "window_rule"},
|
|
||||||
{"monitorrule", "monitor_rule"},
|
|
||||||
{"tagrule", "tag_rule"},
|
|
||||||
{"layerrule", "layer_rule"},
|
|
||||||
{"devicerule", "device_rule"},
|
|
||||||
{"source-optional", "source_optional"},
|
|
||||||
{"exec-once", "exec_once"},
|
|
||||||
{"keymode", "key_mode"},
|
|
||||||
{"isnoborder", "no_border"},
|
|
||||||
{"isnoshadow", "no_shadow"},
|
|
||||||
{"isnoradius", "no_radius"},
|
|
||||||
{"isnoanimation", "no_animation"},
|
|
||||||
{"isnosizehint", "no_size_hint"},
|
|
||||||
{"noblur", "no_blur"},
|
|
||||||
{"noshadow", "no_shadow"},
|
|
||||||
{"noanim", "no_animation"},
|
|
||||||
{"nofocus", "no_focus"},
|
|
||||||
{"nofadein", "no_fade_in"},
|
|
||||||
{"nofadeout", "no_fade_out"},
|
|
||||||
{"noswallow", "no_swallow"},
|
|
||||||
{"special_gappih", "special_gap_inner_horizontal"},
|
|
||||||
{"special_gappiv", "special_gap_inner_vertical"},
|
|
||||||
{"special_gappoh", "special_gap_outer_horizontal"},
|
|
||||||
{"special_gappov", "special_gap_outer_vertical"},
|
|
||||||
{"gappih", "gap_inner_horizontal"},
|
|
||||||
{"gappiv", "gap_inner_vertical"},
|
|
||||||
{"gappoh", "gap_outer_horizontal"},
|
|
||||||
{"gappov", "gap_outer_vertical"},
|
|
||||||
{"overviewgappi", "overview_gap_inner"},
|
|
||||||
{"overviewgappo", "overview_gap_outer"},
|
|
||||||
{"default_mfact", "default_master_factor"},
|
|
||||||
{"default_nmaster", "default_master_count"},
|
|
||||||
{"animation_curve_opafadein", "animation_curve_opacity_fade_in"},
|
|
||||||
{"animation_curve_opafadeout", "animation_curve_opacity_fade_out"},
|
|
||||||
{"dwindle_hsplit", "dwindle_horizontal_split"},
|
|
||||||
{"dwindle_vsplit", "dwindle_vertical_split"},
|
|
||||||
{"fadein_begin_opacity", "fade_in_begin_opacity"},
|
|
||||||
{"fadeout_begin_opacity", "fade_out_begin_opacity"},
|
|
||||||
{"focusdir_only_zone_overlap", "focus_direction_only_zone_overlap"},
|
|
||||||
{"force_fakemaximize", "force_fake_maximize"},
|
|
||||||
{"idleinhibit_ignore_visible", "idle_inhibit_ignore_visible"},
|
|
||||||
{"idleinhibit_when_focus", "idle_inhibit_when_focus"},
|
|
||||||
{"idleinhibit_when_fullscreen", "idle_inhibit_when_fullscreen"},
|
|
||||||
{"syncobj_enable", "sync_obj_enable"},
|
|
||||||
{"isfakefullscreen", "is_fake_fullscreen"},
|
|
||||||
{"isfloating", "is_floating"},
|
|
||||||
{"isfullscreen", "is_fullscreen"},
|
|
||||||
{"isunglobal", "is_unmanaged_global"},
|
|
||||||
{"isglobal", "is_global"},
|
|
||||||
{"isnamedscratchpad", "is_named_scratchpad"},
|
|
||||||
{"isopensilent", "is_open_silent"},
|
|
||||||
{"isoverlay", "is_overlay"},
|
|
||||||
{"istagsilent", "is_tag_silent"},
|
|
||||||
{"isterm", "is_term"},
|
|
||||||
{"appid", "app_id"},
|
|
||||||
{"bordercolor", "border_color"},
|
|
||||||
{"borderpx", "border_px"},
|
|
||||||
{"dropcolor", "drop_color"},
|
|
||||||
{"focuscolor", "focus_color"},
|
|
||||||
{"globalcolor", "global_color"},
|
|
||||||
{"globalkeybinding", "global_key_binding"},
|
|
||||||
{"maximizescreencolor", "maximized_screen_color"},
|
|
||||||
{"mfact", "master_factor"},
|
|
||||||
{"nmaster", "master_count"},
|
|
||||||
{"numlockon", "numlock_on"},
|
|
||||||
{"offsetx", "offset_x"},
|
|
||||||
{"offsety", "offset_y"},
|
|
||||||
{"overlaycolor", "overlay_color"},
|
|
||||||
{"rootcolor", "root_color"},
|
|
||||||
{"scratchpadcolor", "scratchpad_color"},
|
|
||||||
{"shadowscolor", "shadows_color"},
|
|
||||||
{"sloppyfocus", "sloppy_focus"},
|
|
||||||
{"smartgaps", "smart_gaps"},
|
|
||||||
{"splitcolor", "split_color"},
|
|
||||||
{"urgentcolor", "urgent_color"},
|
|
||||||
{"warpcursor", "warp_cursor"},
|
|
||||||
}
|
|
||||||
|
|
||||||
var toSnake, toLegacy = func() (map[string]string, map[string]string) {
|
|
||||||
fwd := make(map[string]string, len(renames))
|
|
||||||
rev := make(map[string]string, len(renames))
|
|
||||||
for _, r := range renames {
|
|
||||||
fwd[r[0]] = r[1]
|
|
||||||
if _, ok := rev[r[1]]; !ok {
|
|
||||||
rev[r[1]] = r[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fwd, rev
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Normalize returns the snake_case name for a key in either dialect.
|
|
||||||
func Normalize(key string) string {
|
|
||||||
if s, ok := toSnake[key]; ok {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
|
|
||||||
// Key spells a snake_case key in this dialect.
|
|
||||||
func (d Dialect) Key(snake string) string {
|
|
||||||
if d == Snake {
|
|
||||||
return snake
|
|
||||||
}
|
|
||||||
if l, ok := toLegacy[snake]; ok {
|
|
||||||
return l
|
|
||||||
}
|
|
||||||
return snake
|
|
||||||
}
|
|
||||||
|
|
||||||
var keyLine = regexp.MustCompile(`^(\s*)([A-Za-z][A-Za-z_-]*)(\s*=\s*)(.*)$`)
|
|
||||||
|
|
||||||
// Translate respells every line key, and the field keys of rule lines, in this dialect.
|
|
||||||
func (d Dialect) Translate(text string) string {
|
|
||||||
lines := strings.Split(text, "\n")
|
|
||||||
for i, line := range lines {
|
|
||||||
m := keyLine.FindStringSubmatch(line)
|
|
||||||
if m == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := Normalize(m[2])
|
|
||||||
value := m[4]
|
|
||||||
switch key {
|
|
||||||
case "window_rule", "window_rule_once", "tag_rule":
|
|
||||||
value = d.translateFields(value, nil)
|
|
||||||
case "layer_rule":
|
|
||||||
value = d.translateFields(value, legacyLayerFields)
|
|
||||||
}
|
|
||||||
lines[i] = m[1] + d.Key(key) + m[3] + value
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layer rules spell these without the window rules' "is" prefix.
|
|
||||||
var legacyLayerFields = map[string]string{"no_shadow": "noshadow", "no_animation": "noanim"}
|
|
||||||
|
|
||||||
func (d Dialect) translateFields(value string, legacyOverride map[string]string) string {
|
|
||||||
parts := strings.Split(value, ",")
|
|
||||||
for i, part := range parts {
|
|
||||||
k, v, ok := strings.Cut(part, ":")
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
trimmed := strings.TrimSpace(k)
|
|
||||||
key := d.Key(Normalize(trimmed))
|
|
||||||
if l, ok := legacyOverride[Normalize(trimmed)]; ok && d == Legacy {
|
|
||||||
key = l
|
|
||||||
}
|
|
||||||
parts[i] = strings.Replace(k, trimmed, key, 1) + ":" + v
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ",")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy builds carry exec_once as a symbol name, so it cannot tell the dialects apart.
|
|
||||||
var snakeMarker = []byte("gap_inner_horizontal")
|
|
||||||
|
|
||||||
var (
|
|
||||||
detectOnce sync.Once
|
|
||||||
detected Dialect
|
|
||||||
)
|
|
||||||
|
|
||||||
// Detect reads the mango binary: snake_case shipped on master builds still reporting 0.17.5, before the 0.18.0 release.
|
|
||||||
func Detect() Dialect {
|
|
||||||
detectOnce.Do(func() {
|
|
||||||
path, err := exec.LookPath("mango")
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
detected = DetectBinary(path)
|
|
||||||
})
|
|
||||||
return detected
|
|
||||||
}
|
|
||||||
|
|
||||||
func DetectBinary(path string) Dialect {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil || !bytes.Contains(data, snakeMarker) {
|
|
||||||
return Legacy
|
|
||||||
}
|
|
||||||
return Snake
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mango reads each line into a 512-byte buffer and the value into 255 bytes; longer is cut silently.
|
|
||||||
const maxValueLen = 255
|
|
||||||
|
|
||||||
func CheckLine(line string) error {
|
|
||||||
_, value, ok := strings.Cut(line, "=")
|
|
||||||
if !ok || len(strings.TrimSpace(value)) <= maxValueLen {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("line is %d characters after '='; Mango truncates past %d", len(strings.TrimSpace(value)), maxValueLen)
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
package mangoconf
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestTranslateRespellsKeysBothWays(t *testing.T) {
|
|
||||||
legacy := "exec-once=dms run\nsource=./dms/colors.conf\ngappih = 5\nbordercolor = 0x595959ff\nwindowrule=appid:foo,isfloating:1,isnoanimation:1\nlayerrule=noanim:1,noshadow:1,layer_name:rofi\n# exec-once=kept\n"
|
|
||||||
snake := "exec_once=dms run\nsource=./dms/colors.conf\ngap_inner_horizontal = 5\nborder_color = 0x595959ff\nwindow_rule=app_id:foo,is_floating:1,no_animation:1\nlayer_rule=no_animation:1,no_shadow:1,layer_name:rofi\n# exec-once=kept\n"
|
|
||||||
assert.Equal(t, snake, Snake.Translate(legacy))
|
|
||||||
assert.Equal(t, legacy, Legacy.Translate(snake))
|
|
||||||
assert.Equal(t, legacy, Legacy.Translate(legacy))
|
|
||||||
assert.Equal(t, "tag_rule=id:1,master_factor:0.6,master_count:2\n", Snake.Translate("tagrule=id:1,mfact:0.6,nmaster:2\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectBinary(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
old := filepath.Join(dir, "old")
|
|
||||||
cur := filepath.Join(dir, "new")
|
|
||||||
require.NoError(t, os.WriteFile(old, []byte("\x00run_exec_once\x00gappih\x00"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(cur, []byte("\x00exec_once\x00gap_inner_horizontal\x00"), 0o755))
|
|
||||||
assert.Equal(t, Legacy, DetectBinary(old))
|
|
||||||
assert.Equal(t, Snake, DetectBinary(cur))
|
|
||||||
assert.Equal(t, Legacy, DetectBinary(filepath.Join(dir, "missing")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMigrateLeavesUserConfigUnlessAsked(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dms"), 0o755))
|
|
||||||
main := filepath.Join(dir, "config.conf")
|
|
||||||
layout := filepath.Join(dir, "dms", "layout.conf")
|
|
||||||
input := filepath.Join(dir, "dms", "input.conf")
|
|
||||||
require.NoError(t, os.WriteFile(main, []byte("exec-once=dms run\n"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(layout, []byte("borderpx=2\n"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(input, []byte("numlockon=1\n"), 0o644))
|
|
||||||
|
|
||||||
changed, err := Snake.Migrate(dir, false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{layout, input}, changed)
|
|
||||||
data, _ := os.ReadFile(main)
|
|
||||||
assert.Equal(t, "exec-once=dms run\n", string(data))
|
|
||||||
data, _ = os.ReadFile(input)
|
|
||||||
assert.Equal(t, "numlock_on=1\n", string(data))
|
|
||||||
|
|
||||||
changed, err = Snake.Migrate(dir, true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{main}, changed)
|
|
||||||
backups, _ := filepath.Glob(main + ".backup.*")
|
|
||||||
assert.Len(t, backups, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMigrateReportsUnwritableFragmentAndContinues(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dms"), 0o755))
|
|
||||||
binds := filepath.Join(dir, "dms", "binds.conf")
|
|
||||||
layout := filepath.Join(dir, "dms", "layout.conf")
|
|
||||||
require.NoError(t, os.WriteFile(binds, []byte("exec-once=foo\n"), 0o444))
|
|
||||||
require.NoError(t, os.WriteFile(layout, []byte("borderpx=2\n"), 0o644))
|
|
||||||
|
|
||||||
changed, err := Snake.Migrate(dir, false)
|
|
||||||
assert.ErrorContains(t, err, "binds.conf")
|
|
||||||
assert.Equal(t, []string{layout}, changed)
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package mangoconf
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DMS-owned fragments under mango/dms; config.conf is the user's and only
|
|
||||||
// migrated on request.
|
|
||||||
var fragments = []string{"binds.conf", "colors.conf", "layout.conf", "cursor.conf", "input.conf", "outputs.conf", "windowrules.conf"}
|
|
||||||
|
|
||||||
// Migrate respells the dms fragments, and config.conf (after a backup) when includeMain is set.
|
|
||||||
// It returns the files rewritten; an unwritable file is reported without stopping the others.
|
|
||||||
func (d Dialect) Migrate(mangoDir string, includeMain bool) ([]string, error) {
|
|
||||||
var changed []string
|
|
||||||
var errs []error
|
|
||||||
paths := make([]string, 0, len(fragments)+1)
|
|
||||||
for _, f := range fragments {
|
|
||||||
paths = append(paths, filepath.Join(mangoDir, "dms", f))
|
|
||||||
}
|
|
||||||
if includeMain {
|
|
||||||
paths = append(paths, filepath.Join(mangoDir, "config.conf"))
|
|
||||||
}
|
|
||||||
for _, path := range paths {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
translated := d.Translate(string(data))
|
|
||||||
if translated == string(data) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if filepath.Base(path) == "config.conf" {
|
|
||||||
backup := path + ".backup." + time.Now().Format("2006-01-02_15-04-05")
|
|
||||||
if err := os.WriteFile(backup, data, 0o644); err != nil {
|
|
||||||
errs = append(errs, fmt.Errorf("backup %s: %w", path, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(path, []byte(translated), 0o644); err != nil {
|
|
||||||
errs = append(errs, fmt.Errorf("write %s: %w", path, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
changed = append(changed, path)
|
|
||||||
}
|
|
||||||
return changed, errors.Join(errs...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d Dialect) String() string {
|
|
||||||
if d == Snake {
|
|
||||||
return "snake"
|
|
||||||
}
|
|
||||||
return "legacy"
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -20,7 +19,6 @@ import (
|
|||||||
|
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/dank16"
|
"github.com/duxlabs-dev/dux-shell/core/internal/dank16"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
|
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
||||||
"github.com/godbus/dbus/v5"
|
"github.com/godbus/dbus/v5"
|
||||||
"github.com/lucasb-eyer/go-colorful"
|
"github.com/lucasb-eyer/go-colorful"
|
||||||
@@ -44,7 +42,6 @@ const (
|
|||||||
TemplateKindGTK
|
TemplateKindGTK
|
||||||
TemplateKindVSCode
|
TemplateKindVSCode
|
||||||
TemplateKindEmacs
|
TemplateKindEmacs
|
||||||
TemplateKindMango
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type TemplateDef struct {
|
type TemplateDef struct {
|
||||||
@@ -63,7 +60,7 @@ var templateRegistry = []TemplateDef{
|
|||||||
{ID: "gtk", Kind: TemplateKindGTK, RunUnconditionally: true},
|
{ID: "gtk", Kind: TemplateKindGTK, RunUnconditionally: true},
|
||||||
{ID: "niri", Commands: []string{"niri"}, ConfigFile: "niri.toml"},
|
{ID: "niri", Commands: []string{"niri"}, ConfigFile: "niri.toml"},
|
||||||
{ID: "hyprland", Commands: []string{"Hyprland"}, ConfigFile: "hyprland.toml"},
|
{ID: "hyprland", Commands: []string{"Hyprland"}, ConfigFile: "hyprland.toml"},
|
||||||
{ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml", RequiredEnv: "MANGO_INSTANCE_SIGNATURE", Kind: TemplateKindMango},
|
{ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml", RequiredEnv: "MANGO_INSTANCE_SIGNATURE"},
|
||||||
{ID: "qt5ct", Commands: []string{"qt5ct"}, ConfigFile: "qt5ct.toml"},
|
{ID: "qt5ct", Commands: []string{"qt5ct"}, ConfigFile: "qt5ct.toml"},
|
||||||
{ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"},
|
{ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"},
|
||||||
{ID: "fcitx5", Commands: []string{"fcitx5"}, ConfigDirs: []string{"fcitx5"}, ConfigFile: "fcitx5.toml"},
|
{ID: "fcitx5", Commands: []string{"fcitx5"}, ConfigDirs: []string{"fcitx5"}, ConfigFile: "fcitx5.toml"},
|
||||||
@@ -667,7 +664,7 @@ func userConfigSection(opts *Options) string {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "[config]\n\n"
|
return "[config]\n\n"
|
||||||
}
|
}
|
||||||
section := extractTOMLSection(string(data), "[config]", "[templates]")
|
section := extractTOMLSection(string(data), "[config]", "\n[templates")
|
||||||
if section == "" {
|
if section == "" {
|
||||||
return "[config]\n\n"
|
return "[config]\n\n"
|
||||||
}
|
}
|
||||||
@@ -725,12 +722,6 @@ output_path = '%s'
|
|||||||
for _, editor := range vscodeEditors {
|
for _, editor := range vscodeEditors {
|
||||||
appendVSCodeConfig(cfgFile, editor.name, editor.extensionsDir(homeDir), opts.ShellDir)
|
appendVSCodeConfig(cfgFile, editor.name, editor.extensionsDir(homeDir), opts.ShellDir)
|
||||||
}
|
}
|
||||||
case TemplateKindMango:
|
|
||||||
configFile := tmpl.ConfigFile
|
|
||||||
if mangoconf.Detect() == mangoconf.Snake {
|
|
||||||
configFile = "mangowc-snake.toml"
|
|
||||||
}
|
|
||||||
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, configFile)
|
|
||||||
case TemplateKindEmacs:
|
case TemplateKindEmacs:
|
||||||
if utils.EmacsConfigDir() != "" {
|
if utils.EmacsConfigDir() != "" {
|
||||||
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
|
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
|
||||||
@@ -749,7 +740,7 @@ output_path = '%s'
|
|||||||
|
|
||||||
if opts.RunUserTemplates {
|
if opts.RunUserTemplates {
|
||||||
if data, err := os.ReadFile(userConfigPath); err == nil {
|
if data, err := os.ReadFile(userConfigPath); err == nil {
|
||||||
templatesSection := extractTOMLSection(string(data), "[templates]", "")
|
templatesSection := extractTOMLSection("\n"+string(data), "\n[templates", "\n[config]")
|
||||||
if templatesSection != "" {
|
if templatesSection != "" {
|
||||||
cfgFile.WriteString(templatesSection)
|
cfgFile.WriteString(templatesSection)
|
||||||
cfgFile.WriteString("\n")
|
cfgFile.WriteString("\n")
|
||||||
@@ -965,6 +956,7 @@ var vscodeEditors = []vscodeEditor{
|
|||||||
{"cursor", ".cursor"},
|
{"cursor", ".cursor"},
|
||||||
{"windsurf", ".windsurf"},
|
{"windsurf", ".windsurf"},
|
||||||
{"positron", ".positron"},
|
{"positron", ".positron"},
|
||||||
|
{"antigravity", ".antigravity-ide"},
|
||||||
{"vscode-insiders", ".vscode-insiders"},
|
{"vscode-insiders", ".vscode-insiders"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1154,21 +1146,32 @@ func runMatugen(baseArgs []string, sourceMode string) error {
|
|||||||
|
|
||||||
// matugen's stderr is echoed as before and its tail travels in the error, so a failure
|
// matugen's stderr is echoed as before and its tail travels in the error, so a failure
|
||||||
// reaching the shell through the socket still names the broken template or option.
|
// reaching the shell through the socket still names the broken template or option.
|
||||||
|
// It goes through a file, not a pipe: with a pipe, Wait blocks until every hook
|
||||||
|
// child that inherited stderr exits, and a backgrounded post_hook never does (#3901).
|
||||||
func execMatugen(args []string) error {
|
func execMatugen(args []string) error {
|
||||||
|
stderrFile, err := os.CreateTemp("", "matugen-stderr-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("matugen: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(stderrFile.Name())
|
||||||
|
defer stderrFile.Close()
|
||||||
|
|
||||||
cmd := exec.Command("matugen", args...)
|
cmd := exec.Command("matugen", args...)
|
||||||
cmd.Env = utils.EnvWithUserBinPath(nil)
|
cmd.Env = utils.EnvWithUserBinPath(nil)
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
var stderr bytes.Buffer
|
cmd.Stderr = stderrFile
|
||||||
cmd.Stderr = io.MultiWriter(os.Stderr, &stderr)
|
runErr := cmd.Run()
|
||||||
err := cmd.Run()
|
|
||||||
if err == nil {
|
stderr, _ := os.ReadFile(stderrFile.Name())
|
||||||
|
os.Stderr.Write(stderr)
|
||||||
|
if runErr == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
tail := stderrTail(stderr.String(), 6)
|
tail := stderrTail(string(stderr), 6)
|
||||||
if tail == "" {
|
if tail == "" {
|
||||||
return fmt.Errorf("matugen: %w", err)
|
return fmt.Errorf("matugen: %w", runErr)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("matugen: %w\n%s", err, tail)
|
return fmt.Errorf("matugen: %w\n%s", runErr, tail)
|
||||||
}
|
}
|
||||||
|
|
||||||
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
|
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
|
||||||
@@ -1218,11 +1221,7 @@ func writeDryRunConfig(opts *Options) (string, error) {
|
|||||||
}
|
}
|
||||||
defer cfgFile.Close()
|
defer cfgFile.Close()
|
||||||
|
|
||||||
section := userConfigSection(opts)
|
cfgFile.WriteString(userConfigSection(opts))
|
||||||
if idx := strings.Index(section, "\n[templates"); idx != -1 {
|
|
||||||
section = section[:idx+1]
|
|
||||||
}
|
|
||||||
cfgFile.WriteString(section)
|
|
||||||
cfgFile.WriteString("[templates]\n")
|
cfgFile.WriteString("[templates]\n")
|
||||||
return cfgFile.Name(), nil
|
return cfgFile.Name(), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -511,7 +511,7 @@ func TestBuildMergedConfigColorsOnly(t *testing.T) {
|
|||||||
assert.False(t, strings.Contains(content, "output_path = 'CONFIG_DIR/"), "colors-only config should not emit app template outputs")
|
assert.False(t, strings.Contains(content, "output_path = 'CONFIG_DIR/"), "colors-only config should not emit app template outputs")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildMergedConfigSkipsMangowcWithoutActiveSession(t *testing.T) {
|
func TestBuildMergedConfigMangoSession(t *testing.T) {
|
||||||
t.Setenv("MANGO_INSTANCE_SIGNATURE", "")
|
t.Setenv("MANGO_INSTANCE_SIGNATURE", "")
|
||||||
|
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
@@ -555,6 +555,31 @@ func TestBuildMergedConfigSkipsMangowcWithoutActiveSession(t *testing.T) {
|
|||||||
t.Fatalf("failed to read merged config: %v", err)
|
t.Fatalf("failed to read merged config: %v", err)
|
||||||
}
|
}
|
||||||
assert.NotContains(t, string(output), "[templates.dmsmango]")
|
assert.NotContains(t, string(output), "[templates.dmsmango]")
|
||||||
|
|
||||||
|
socket := filepath.Join(tempDir, "mango.sock")
|
||||||
|
if err := os.WriteFile(socket, nil, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("MANGO_INSTANCE_SIGNATURE", socket)
|
||||||
|
checker := mocks_utils.NewMockAppChecker(t)
|
||||||
|
checker.EXPECT().AnyCommandExists("mango").Return(true)
|
||||||
|
opts.AppChecker = checker
|
||||||
|
cfgFile, err = os.CreateTemp(tempDir, "merged-active-*.toml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer cfgFile.Close()
|
||||||
|
if err := buildMergedConfig(opts, cfgFile, filepath.Join(tempDir, "templates")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cfgFile.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
output, err = os.ReadFile(cfgFile.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assert.Contains(t, string(output), mangowcConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAppendConfigConfigDirExists(t *testing.T) {
|
func TestAppendConfigConfigDirExists(t *testing.T) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/duxlabs-dev/dux-go/wayland/client"
|
"github.com/duxlabs-dev/dux-go/wayland/client"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wlr_output_management"
|
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wlr_output_management"
|
||||||
@@ -151,6 +152,31 @@ func GetHyprlandMonitorScale(name string) float64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// !TODO: remove with the frameInLogicalOrientation version fallback
|
||||||
|
var hyprlandVersion = sync.OnceValues(func() (int, int) {
|
||||||
|
output, err := exec.Command("hyprctl", "-j", "version").Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
var info struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output, &info); err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
var major, minor int
|
||||||
|
fmt.Sscanf(info.Version, "%d.%d", &major, &minor)
|
||||||
|
return major, minor
|
||||||
|
})
|
||||||
|
|
||||||
|
func hyprlandVersionAtLeast(major, minor int) bool {
|
||||||
|
haveMajor, haveMinor := hyprlandVersion()
|
||||||
|
if haveMajor != major {
|
||||||
|
return haveMajor > major
|
||||||
|
}
|
||||||
|
return haveMinor >= minor
|
||||||
|
}
|
||||||
|
|
||||||
func getHyprlandFocusedMonitor() string {
|
func getHyprlandFocusedMonitor() string {
|
||||||
output, err := exec.Command("hyprctl", "-j", "monitors").Output()
|
output, err := exec.Command("hyprctl", "-j", "monitors").Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -609,16 +609,13 @@ func (r *RegionSelector) preCaptureOutput(output *WaylandOutput, pc *PreCapture,
|
|||||||
pc.yInverted = false
|
pc.yInverted = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if output.transform != TransformNormal {
|
transformed, err := output.untransformFrame(capturedBuf)
|
||||||
invTransform := InverseTransform(output.transform)
|
|
||||||
transformed, err := capturedBuf.ApplyTransform(invTransform)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("apply transform failed", "err", err)
|
log.Error("apply transform failed", "err", err)
|
||||||
} else if transformed != capturedBuf {
|
} else if transformed != capturedBuf {
|
||||||
capturedBuf.Close()
|
capturedBuf.Close()
|
||||||
capturedBuf = transformed
|
capturedBuf = transformed
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if withCursor {
|
if withCursor {
|
||||||
pc.screenBuf = capturedBuf
|
pc.screenBuf = capturedBuf
|
||||||
|
|||||||
@@ -49,6 +49,28 @@ func (o *WaylandOutput) effectiveScale() float64 {
|
|||||||
return scale
|
return scale
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hyprland renders screencopy frames in the output's logical orientation since
|
||||||
|
// 901881c9f (v0.57.0); wlroots and niri still hand back the native framebuffer.
|
||||||
|
// !TODO: drop the version fallback once every distro ships hyprland >= 0.57
|
||||||
|
func (o *WaylandOutput) frameInLogicalOrientation(frameW, frameH int) bool {
|
||||||
|
switch o.transform {
|
||||||
|
case TransformNormal:
|
||||||
|
return true
|
||||||
|
case Transform90, Transform270, TransformFlipped90, TransformFlipped270:
|
||||||
|
if o.width != o.height {
|
||||||
|
return frameW == int(o.height) && frameH == int(o.width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DetectCompositor() == CompositorHyprland && hyprlandVersionAtLeast(0, 57)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *WaylandOutput) untransformFrame(buf *ShmBuffer) (*ShmBuffer, error) {
|
||||||
|
if o.frameInLogicalOrientation(buf.Width, buf.Height) {
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
return buf.ApplyTransform(InverseTransform(o.transform))
|
||||||
|
}
|
||||||
|
|
||||||
func (o *WaylandOutput) bounds() Region {
|
func (o *WaylandOutput) bounds() Region {
|
||||||
x, y, w, h := o.x, o.y, o.width, o.height
|
x, y, w, h := o.x, o.y, o.width, o.height
|
||||||
if DetectCompositor() == CompositorHyprland {
|
if DetectCompositor() == CompositorHyprland {
|
||||||
@@ -677,12 +699,7 @@ func (s *Screenshoter) captureWholeOutput(output *WaylandOutput) (*CaptureResult
|
|||||||
result.YInverted = false
|
result.YInverted = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if output.transform == TransformNormal {
|
transformed, err := output.untransformFrame(result.Buffer)
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
invTransform := InverseTransform(output.transform)
|
|
||||||
transformed, err := result.Buffer.ApplyTransform(invTransform)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Buffer.Close()
|
result.Buffer.Close()
|
||||||
return nil, fmt.Errorf("apply transform: %w", err)
|
return nil, fmt.Errorf("apply transform: %w", err)
|
||||||
|
|||||||
@@ -40,10 +40,6 @@ func HandleRequest(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
|
|||||||
handleSubscribe(conn, req, m)
|
handleSubscribe(conn, req, m)
|
||||||
case "clipboard.search":
|
case "clipboard.search":
|
||||||
handleSearch(conn, req, m)
|
handleSearch(conn, req, m)
|
||||||
case "clipboard.getConfig":
|
|
||||||
handleGetConfig(conn, req, m)
|
|
||||||
case "clipboard.setConfig":
|
|
||||||
handleSetConfig(conn, req, m)
|
|
||||||
case "clipboard.store":
|
case "clipboard.store":
|
||||||
handleStore(conn, req, m)
|
handleStore(conn, req, m)
|
||||||
case "clipboard.pinEntry":
|
case "clipboard.pinEntry":
|
||||||
@@ -320,40 +316,6 @@ func handleSearch(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
|
|||||||
conn.Respond(req.ID, m.Search(p))
|
conn.Respond(req.ID, m.Search(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleGetConfig(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
|
|
||||||
conn.Respond(req.ID, m.GetConfig())
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleSetConfig(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
|
|
||||||
cfg := m.GetConfig()
|
|
||||||
|
|
||||||
if v, ok := req.Get[float64]("maxHistory"); ok {
|
|
||||||
cfg.MaxHistory = int(v)
|
|
||||||
}
|
|
||||||
if v, ok := req.Get[float64]("maxEntrySize"); ok {
|
|
||||||
cfg.MaxEntrySize = int64(v)
|
|
||||||
}
|
|
||||||
if v, ok := req.Get[float64]("autoClearDays"); ok {
|
|
||||||
cfg.AutoClearDays = int(v)
|
|
||||||
}
|
|
||||||
if v, ok := req.Get[bool]("clearAtStartup"); ok {
|
|
||||||
cfg.ClearAtStartup = v
|
|
||||||
}
|
|
||||||
if v, ok := req.Get[bool]("disabled"); ok {
|
|
||||||
cfg.Disabled = v
|
|
||||||
}
|
|
||||||
if v, ok := req.Get[float64]("maxPinned"); ok {
|
|
||||||
cfg.MaxPinned = int(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.SetConfig(cfg); err != nil {
|
|
||||||
conn.RespondError(req.ID, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "config updated"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleStore(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
|
func handleStore(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
|
||||||
data, err := params.String(req.Params, "data")
|
data, err := params.String(req.Params, "data")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package clipboard
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"cmp"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -29,6 +30,7 @@ import (
|
|||||||
_ "golang.org/x/image/webp"
|
_ "golang.org/x/image/webp"
|
||||||
|
|
||||||
bolt "go.etcd.io/bbolt"
|
bolt "go.etcd.io/bbolt"
|
||||||
|
berrors "go.etcd.io/bbolt/errors"
|
||||||
|
|
||||||
wlclient "github.com/duxlabs-dev/dux-go/wayland/client"
|
wlclient "github.com/duxlabs-dev/dux-go/wayland/client"
|
||||||
"github.com/duxlabs-dev/dux-go/wayland/ext_data_control"
|
"github.com/duxlabs-dev/dux-go/wayland/ext_data_control"
|
||||||
@@ -55,8 +57,14 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
|
|||||||
|
|
||||||
configPath, _ := getConfigPath()
|
configPath, _ := getConfigPath()
|
||||||
|
|
||||||
|
db, err := openDB(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
m := &Manager{
|
m := &Manager{
|
||||||
config: config,
|
config: config,
|
||||||
|
db: db,
|
||||||
configPath: configPath,
|
configPath: configPath,
|
||||||
display: display,
|
display: display,
|
||||||
wlCtx: wlCtx,
|
wlCtx: wlCtx,
|
||||||
@@ -69,6 +77,7 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
|
|||||||
|
|
||||||
if !config.Disabled {
|
if !config.Disabled {
|
||||||
if err := m.setupRegistry(); err != nil {
|
if err := m.setupRegistry(); err != nil {
|
||||||
|
db.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,12 +87,6 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
|
|||||||
|
|
||||||
go m.watchConfig()
|
go m.watchConfig()
|
||||||
|
|
||||||
db, err := openDB(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to open db: %w", err)
|
|
||||||
}
|
|
||||||
m.db = db
|
|
||||||
|
|
||||||
if err := m.migrateHashes(); err != nil {
|
if err := m.migrateHashes(); err != nil {
|
||||||
log.Errorf("Failed to migrate hashes: %v", err)
|
log.Errorf("Failed to migrate hashes: %v", err)
|
||||||
}
|
}
|
||||||
@@ -117,6 +120,9 @@ func openDB(path string) (*bolt.DB, error) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, berrors.ErrTimeout) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
log.Errorf("Clipboard db corrupted, salvaging readable entries: %v", err)
|
log.Errorf("Clipboard db corrupted, salvaging readable entries: %v", err)
|
||||||
|
|
||||||
@@ -244,6 +250,9 @@ func (m *Manager) setupRegistry() error {
|
|||||||
m.dataControlMgr = dataControlMgr
|
m.dataControlMgr = dataControlMgr
|
||||||
log.Info("Bound ext_data_control_manager_v1")
|
log.Info("Bound ext_data_control_manager_v1")
|
||||||
case "wl_seat":
|
case "wl_seat":
|
||||||
|
if m.seat != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
seat := wlclient.NewSeat(ctx)
|
seat := wlclient.NewSeat(ctx)
|
||||||
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
|
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
|
||||||
log.Errorf("Failed to bind wl_seat: %v", err)
|
log.Errorf("Failed to bind wl_seat: %v", err)
|
||||||
@@ -272,17 +281,65 @@ func (m *Manager) setupRegistry() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) setupDataDeviceSync() {
|
func (m *Manager) setupDataDeviceSync() {
|
||||||
if m.dataControlMgr == nil || m.seat == nil {
|
if !m.bindDataDevice() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := m.display.Context()
|
if err := m.display.Context().Dispatch(); err != nil {
|
||||||
|
log.Errorf("Failed to dispatch initial events: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Data device setup complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs on the dispatcher goroutine; a disabled manager must hold no device so it receives no offers.
|
||||||
|
func (m *Manager) syncTracking() {
|
||||||
|
if m.getConfig().Disabled {
|
||||||
|
m.releaseDataDevice()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.dataDevice != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.registry == nil {
|
||||||
|
if err := m.setupRegistry(); err != nil {
|
||||||
|
log.Errorf("Failed to enable clipboard tracking: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.bindDataDevice() {
|
||||||
|
log.Info("Data device setup complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) releaseDataDevice() {
|
||||||
|
m.deviceBound.Store(false)
|
||||||
|
|
||||||
|
if m.currentOffer != nil {
|
||||||
|
m.releaseOffer(m.currentOffer)
|
||||||
|
m.currentOffer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.dataDevice == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1).Destroy()
|
||||||
|
m.dataDevice = nil
|
||||||
|
m.mimeTypes = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) bindDataDevice() bool {
|
||||||
|
if m.dataControlMgr == nil || m.seat == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
|
dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
|
||||||
|
|
||||||
dataDevice, err := dataMgr.GetDataDevice(m.seat)
|
dataDevice, err := dataMgr.GetDataDevice(m.seat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("Failed to send get_data_device request: %v", err)
|
log.Errorf("Failed to send get_data_device request: %v", err)
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
dataDevice.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) {
|
dataDevice.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) {
|
||||||
@@ -302,8 +359,12 @@ func (m *Manager) setupDataDeviceSync() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
dataDevice.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) {
|
dataDevice.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) {
|
||||||
|
// The first event replays the selection that existed before binding.
|
||||||
if !m.initialized {
|
if !m.initialized {
|
||||||
m.initialized = true
|
m.initialized = true
|
||||||
|
if e.Id != nil {
|
||||||
|
m.releaseOffer(e.Id)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +392,11 @@ func (m *Manager) setupDataDeviceSync() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An event can land between a runtime disable and the posted release.
|
||||||
|
if m.getConfig().Disabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
m.offerMutex.RLock()
|
m.offerMutex.RLock()
|
||||||
mimes := m.offerMimeTypes[offer]
|
mimes := m.offerMimeTypes[offer]
|
||||||
m.offerMutex.RUnlock()
|
m.offerMutex.RUnlock()
|
||||||
@@ -389,14 +455,10 @@ func (m *Manager) setupDataDeviceSync() {
|
|||||||
go m.readAndStore(r, preferredMime, altR, altMime)
|
go m.readAndStore(r, preferredMime, altR, altMime)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
m.initialized = false
|
||||||
m.dataDevice = dataDevice
|
m.dataDevice = dataDevice
|
||||||
|
m.deviceBound.Store(true)
|
||||||
if err := ctx.Dispatch(); err != nil {
|
return true
|
||||||
log.Errorf("Failed to dispatch initial events: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("Data device setup complete")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) releaseOffer(offer any) {
|
func (m *Manager) releaseOffer(offer any) {
|
||||||
@@ -515,6 +577,9 @@ func (m *Manager) storeClipboardEntry(data []byte, mimeType string, altData []by
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) storeEntry(entry Entry) error {
|
func (m *Manager) storeEntry(entry Entry) error {
|
||||||
|
if m.getConfig().Disabled {
|
||||||
|
return errTrackingDisabled
|
||||||
|
}
|
||||||
if m.db == nil {
|
if m.db == nil {
|
||||||
return fmt.Errorf("database not available")
|
return fmt.Errorf("database not available")
|
||||||
}
|
}
|
||||||
@@ -569,21 +634,31 @@ func (m *Manager) deduplicateInTx(b *bolt.Bucket, hash uint64) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) trimLengthInTx(b *bolt.Bucket) error {
|
func (m *Manager) trimLengthInTx(b *bolt.Bucket) error {
|
||||||
if m.config.MaxHistory < 0 {
|
maxHistory := m.getConfig().MaxHistory
|
||||||
|
if maxHistory < 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
key []byte
|
||||||
|
entry Entry
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
c := b.Cursor()
|
c := b.Cursor()
|
||||||
var count int
|
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||||
for k, v := c.Last(); k != nil; k, v = c.Prev() {
|
|
||||||
entry, err := decodeEntryMeta(v)
|
entry, err := decodeEntryMeta(v)
|
||||||
if err == nil && entry.Pinned {
|
if err != nil || entry.Pinned {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if count < m.config.MaxHistory {
|
rows = append(rows, row{slices.Clone(k), entry})
|
||||||
count++
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if err := b.Delete(k); err != nil {
|
|
||||||
|
slices.SortFunc(rows, func(a, b row) int {
|
||||||
|
return compareEntriesNewestFirst(a.entry, b.entry)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, r := range rows[min(maxHistory, len(rows)):] {
|
||||||
|
if err := b.Delete(r.key); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -601,7 +676,7 @@ func encodeEntry(e Entry) ([]byte, error) {
|
|||||||
binary.Write(buf, binary.BigEndian, uint32(len(e.Preview)))
|
binary.Write(buf, binary.BigEndian, uint32(len(e.Preview)))
|
||||||
buf.WriteString(e.Preview)
|
buf.WriteString(e.Preview)
|
||||||
binary.Write(buf, binary.BigEndian, int32(e.Size))
|
binary.Write(buf, binary.BigEndian, int32(e.Size))
|
||||||
binary.Write(buf, binary.BigEndian, e.Timestamp.Unix())
|
binary.Write(buf, binary.BigEndian, e.Timestamp.UnixNano())
|
||||||
if e.IsImage {
|
if e.IsImage {
|
||||||
buf.WriteByte(1)
|
buf.WriteByte(1)
|
||||||
} else {
|
} else {
|
||||||
@@ -667,7 +742,13 @@ func decodeEntryFields(data []byte, withData bool) (Entry, error) {
|
|||||||
|
|
||||||
var timestamp int64
|
var timestamp int64
|
||||||
binary.Read(buf, binary.BigEndian, ×tamp)
|
binary.Read(buf, binary.BigEndian, ×tamp)
|
||||||
|
// Rows written before the switch to nanosecond precision hold seconds;
|
||||||
|
// no second-resolution stamp reaches 1e12 until the year 33658.
|
||||||
|
if timestamp < 1e12 {
|
||||||
e.Timestamp = time.Unix(timestamp, 0)
|
e.Timestamp = time.Unix(timestamp, 0)
|
||||||
|
} else {
|
||||||
|
e.Timestamp = time.Unix(0, timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
var isImage byte
|
var isImage byte
|
||||||
binary.Read(buf, binary.BigEndian, &isImage)
|
binary.Read(buf, binary.BigEndian, &isImage)
|
||||||
@@ -1043,9 +1124,25 @@ func (m *Manager) GetHistory() []Entry {
|
|||||||
go m.deleteStaleEntries(stale)
|
go m.deleteStaleEntries(stale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sortEntriesNewestFirst(history)
|
||||||
|
|
||||||
return history
|
return history
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sortEntriesNewestFirst orders by timestamp, falling back to id: TouchEntry
|
||||||
|
// rewrites a row in place to keep its id stable, so bucket key order stops
|
||||||
|
// tracking recency.
|
||||||
|
func sortEntriesNewestFirst(entries []Entry) {
|
||||||
|
slices.SortFunc(entries, compareEntriesNewestFirst)
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareEntriesNewestFirst(a, b Entry) int {
|
||||||
|
if c := b.Timestamp.Compare(a.Timestamp); c != 0 {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return cmp.Compare(b.ID, a.ID)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) deleteStaleEntries(ids []uint64) {
|
func (m *Manager) deleteStaleEntries(ids []uint64) {
|
||||||
if m.db == nil {
|
if m.db == nil {
|
||||||
return
|
return
|
||||||
@@ -1181,9 +1278,24 @@ func (m *Manager) TouchEntry(id uint64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.Timestamp = time.Now()
|
// Round(0) drops the monotonic reading so the in-memory stamp compares the
|
||||||
|
// same way as the one decoded back from the bucket.
|
||||||
|
entry.Timestamp = time.Now().Round(0)
|
||||||
|
|
||||||
if err := m.storeEntry(*entry); err != nil {
|
// storeEntry would dedupe the row against itself and hand out a fresh
|
||||||
|
// sequence number, invalidating the id the UI is still holding.
|
||||||
|
err = m.dbUpdate(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket([]byte("clipboard"))
|
||||||
|
if b == nil {
|
||||||
|
return fmt.Errorf("clipboard bucket missing")
|
||||||
|
}
|
||||||
|
encoded, err := encodeEntry(*entry)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return b.Put(itob(id), encoded)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1336,19 +1448,22 @@ func (m *Manager) reopenDB() {
|
|||||||
m.db = db
|
m.db = db
|
||||||
}
|
}
|
||||||
|
|
||||||
var errSelectionUnavailable = errors.New("clipboard selection unavailable: tracking disabled or no data control protocol")
|
var (
|
||||||
|
errSelectionUnavailable = errors.New("clipboard selection unavailable: tracking disabled or no data control protocol")
|
||||||
|
errTrackingDisabled = errors.New("clipboard tracking disabled")
|
||||||
|
)
|
||||||
|
|
||||||
// Both are bound once in NewManager; a disabled manager never binds them but
|
// A disabled manager still serves copy requests; failing here lets callers
|
||||||
// still serves copy requests, so callers must fail before touching history.
|
// such as dms screenshot fall back to their own copy.
|
||||||
func (m *Manager) canTakeSelection() bool {
|
func (m *Manager) canTakeSelection() bool {
|
||||||
return m.dataControlMgr != nil && m.dataDevice != nil
|
return m.deviceBound.Load() && !m.getConfig().Disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) SetClipboard(data []byte, mimeType string) error {
|
func (m *Manager) SetClipboard(data []byte, mimeType string) error {
|
||||||
if !m.canTakeSelection() {
|
if !m.canTakeSelection() {
|
||||||
return errSelectionUnavailable
|
return errSelectionUnavailable
|
||||||
}
|
}
|
||||||
if int64(len(data)) > m.config.MaxEntrySize {
|
if int64(len(data)) > m.getConfig().MaxEntrySize {
|
||||||
return fmt.Errorf("data too large")
|
return fmt.Errorf("data too large")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1366,7 +1481,7 @@ func (m *Manager) SetClipboardEntry(entry *Entry) error {
|
|||||||
if !m.canTakeSelection() {
|
if !m.canTakeSelection() {
|
||||||
return errSelectionUnavailable
|
return errSelectionUnavailable
|
||||||
}
|
}
|
||||||
if int64(len(entry.Data)) > m.config.MaxEntrySize {
|
if int64(len(entry.Data)) > m.getConfig().MaxEntrySize {
|
||||||
return fmt.Errorf("data too large")
|
return fmt.Errorf("data too large")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1515,16 +1630,7 @@ func (m *Manager) Close() {
|
|||||||
m.subMutex.Unlock()
|
m.subMutex.Unlock()
|
||||||
|
|
||||||
m.releaseCurrentSource()
|
m.releaseCurrentSource()
|
||||||
|
m.releaseDataDevice()
|
||||||
if m.currentOffer != nil {
|
|
||||||
m.releaseOffer(m.currentOffer)
|
|
||||||
m.currentOffer = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.dataDevice != nil {
|
|
||||||
device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1)
|
|
||||||
device.Destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.dataControlMgr != nil {
|
if m.dataControlMgr != nil {
|
||||||
mgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
|
mgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
|
||||||
@@ -1724,19 +1830,12 @@ func (m *Manager) Search(params SearchParams) SearchResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) GetConfig() Config {
|
|
||||||
return m.config
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) SetConfig(cfg Config) error {
|
func (m *Manager) SetConfig(cfg Config) error {
|
||||||
m.configMutex.Lock()
|
if err := SaveConfig(cfg); err != nil {
|
||||||
m.config = cfg
|
return err
|
||||||
m.configMutex.Unlock()
|
}
|
||||||
|
m.applyConfigChange(cfg)
|
||||||
m.updateState()
|
return nil
|
||||||
m.notifySubscribers()
|
|
||||||
|
|
||||||
return SaveConfig(cfg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) getConfig() Config {
|
func (m *Manager) getConfig() Config {
|
||||||
@@ -1777,7 +1876,12 @@ func (m *Manager) watchConfig() {
|
|||||||
if event.Op&(fsnotify.Write|fsnotify.Create) == 0 {
|
if event.Op&(fsnotify.Write|fsnotify.Create) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
newCfg := LoadConfig()
|
// os.WriteFile truncates first; parsing that empty read would
|
||||||
|
// briefly reset every setting to its default.
|
||||||
|
newCfg, err := readConfig()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
m.applyConfigChange(newCfg)
|
m.applyConfigChange(newCfg)
|
||||||
case err, ok := <-watcher.Errors:
|
case err, ok := <-watcher.Errors:
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1794,11 +1898,9 @@ func (m *Manager) applyConfigChange(newCfg Config) {
|
|||||||
m.config = newCfg
|
m.config = newCfg
|
||||||
m.configMutex.Unlock()
|
m.configMutex.Unlock()
|
||||||
|
|
||||||
switch {
|
if newCfg.Disabled != oldCfg.Disabled {
|
||||||
case newCfg.Disabled && !oldCfg.Disabled:
|
log.Infof("Clipboard tracking disabled=%v", newCfg.Disabled)
|
||||||
log.Info("Clipboard tracking disabled")
|
m.post(m.syncTracking)
|
||||||
case !newCfg.Disabled && oldCfg.Disabled:
|
|
||||||
log.Info("Clipboard tracking enabled")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
m.updateState()
|
m.updateState()
|
||||||
@@ -1809,7 +1911,7 @@ func (m *Manager) StoreData(data []byte, mimeType string) error {
|
|||||||
cfg := m.getConfig()
|
cfg := m.getConfig()
|
||||||
|
|
||||||
if cfg.Disabled {
|
if cfg.Disabled {
|
||||||
return fmt.Errorf("clipboard tracking disabled")
|
return errTrackingDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.db == nil {
|
if m.db == nil {
|
||||||
@@ -2148,6 +2250,8 @@ func (m *Manager) GetPinnedEntries() []Entry {
|
|||||||
log.Errorf("Failed to get pinned entries: %v", err)
|
log.Errorf("Failed to get pinned entries: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sortEntriesNewestFirst(pinned)
|
||||||
|
|
||||||
return pinned
|
return pinned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package clipboard
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/png"
|
"image/png"
|
||||||
"net"
|
"net"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
bolt "go.etcd.io/bbolt"
|
bolt "go.etcd.io/bbolt"
|
||||||
|
|
||||||
"github.com/duxlabs-dev/dux-go/ipc"
|
"github.com/duxlabs-dev/dux-go/ipc"
|
||||||
|
wlclient "github.com/duxlabs-dev/dux-go/wayland/client"
|
||||||
"github.com/duxlabs-dev/dux-go/wayland/ext_data_control"
|
"github.com/duxlabs-dev/dux-go/wayland/ext_data_control"
|
||||||
mocks_wlcontext "github.com/duxlabs-dev/dux-shell/core/internal/mocks/wlcontext"
|
mocks_wlcontext "github.com/duxlabs-dev/dux-shell/core/internal/mocks/wlcontext"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/server/models"
|
"github.com/duxlabs-dev/dux-shell/core/internal/server/models"
|
||||||
@@ -51,13 +53,15 @@ func newTestManagerWithDB(t *testing.T) *Manager {
|
|||||||
mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
|
mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
|
||||||
mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Return().Maybe()
|
mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Return().Maybe()
|
||||||
|
|
||||||
return &Manager{
|
m := &Manager{
|
||||||
config: DefaultConfig(),
|
config: DefaultConfig(),
|
||||||
db: db,
|
db: db,
|
||||||
wlCtx: mockCtx,
|
wlCtx: mockCtx,
|
||||||
dataControlMgr: &ext_data_control.ExtDataControlManagerV1{},
|
dataControlMgr: &ext_data_control.ExtDataControlManagerV1{},
|
||||||
dataDevice: &ext_data_control.ExtDataControlDeviceV1{},
|
dataDevice: &ext_data_control.ExtDataControlDeviceV1{},
|
||||||
}
|
}
|
||||||
|
m.deviceBound.Store(true)
|
||||||
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEncodeDecodeEntry_Roundtrip(t *testing.T) {
|
func TestEncodeDecodeEntry_Roundtrip(t *testing.T) {
|
||||||
@@ -443,7 +447,9 @@ func TestUnpinEntry_KeepsTopUnpinnedDuplicate(t *testing.T) {
|
|||||||
|
|
||||||
history = m.GetHistory()
|
history = m.GetHistory()
|
||||||
require.Len(t, history, 3)
|
require.Len(t, history, 3)
|
||||||
require.Equal(t, topHistoryDuplicate.ID, history[0].ID)
|
// GetHistory sorts by timestamp, so the newer row leads even though its
|
||||||
|
// id is lower.
|
||||||
|
require.Equal(t, olderHistoryDuplicate.ID, history[0].ID)
|
||||||
require.NoError(t, m.UnpinEntry(pinnedID))
|
require.NoError(t, m.UnpinEntry(pinnedID))
|
||||||
|
|
||||||
history = m.GetHistory()
|
history = m.GetHistory()
|
||||||
@@ -887,6 +893,24 @@ func TestOpenDB_HealsGarbageFile(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenDB_LockedByAnotherProcessKeepsHistory(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "db")
|
||||||
|
holder, err := openDB(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
m := &Manager{config: DefaultConfig(), db: holder, dbPath: path}
|
||||||
|
require.NoError(t, m.storeEntry(Entry{Data: []byte("x"), MimeType: "text/plain"}))
|
||||||
|
|
||||||
|
_, err = openDB(path)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.NoError(t, holder.Close())
|
||||||
|
|
||||||
|
reopened, err := openDB(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer reopened.Close()
|
||||||
|
m = &Manager{config: DefaultConfig(), db: reopened, dbPath: path}
|
||||||
|
assert.Len(t, m.GetHistory(), 1)
|
||||||
|
}
|
||||||
|
|
||||||
func TestStoreEntry_CorruptDBReturnsErrorNotPanic(t *testing.T) {
|
func TestStoreEntry_CorruptDBReturnsErrorNotPanic(t *testing.T) {
|
||||||
for _, tt := range corruptDBCases {
|
for _, tt := range corruptDBCases {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -1040,3 +1064,86 @@ func TestCopyFile_DisabledTrackingRefusesCopy(t *testing.T) {
|
|||||||
require.ErrorIs(t, m.CopyFile(path), errSelectionUnavailable)
|
require.ErrorIs(t, m.CopyFile(path), errSelectionUnavailable)
|
||||||
assert.Empty(t, m.GetHistory())
|
assert.Empty(t, m.GetHistory())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyConfigChange_DisableReleasesDataDevice(t *testing.T) {
|
||||||
|
sock := filepath.Join(t.TempDir(), "wayland-test")
|
||||||
|
ln, err := net.Listen("unix", sock)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { ln.Close() })
|
||||||
|
|
||||||
|
display, err := wlclient.Connect(sock)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { display.Context().Close() })
|
||||||
|
|
||||||
|
m := newTestManagerWithDB(t)
|
||||||
|
mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
|
||||||
|
mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) { fn() })
|
||||||
|
m.wlCtx = mockCtx
|
||||||
|
device := ext_data_control.NewExtDataControlDeviceV1(display.Context())
|
||||||
|
m.dataDevice = device
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.Disabled = true
|
||||||
|
m.applyConfigChange(cfg)
|
||||||
|
|
||||||
|
assert.True(t, device.IsZombie())
|
||||||
|
assert.Nil(t, m.dataDevice)
|
||||||
|
require.ErrorIs(t, m.CopyText("secret"), errSelectionUnavailable)
|
||||||
|
assert.Empty(t, m.GetHistory())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTouchEntry_SurvivesTrimWhenHistoryFull(t *testing.T) {
|
||||||
|
m := newTestManagerWithDB(t)
|
||||||
|
m.config.MaxHistory = 3
|
||||||
|
|
||||||
|
for i := range 3 {
|
||||||
|
require.NoError(t, m.storeEntry(Entry{
|
||||||
|
Data: []byte(fmt.Sprintf("entry-%d", i)),
|
||||||
|
MimeType: "text/plain",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
oldest := m.GetHistory()[2]
|
||||||
|
require.NoError(t, m.TouchEntry(oldest.ID))
|
||||||
|
|
||||||
|
// A new copy trims the bucket back to MaxHistory.
|
||||||
|
require.NoError(t, m.storeEntry(Entry{
|
||||||
|
Data: []byte("newcomer"),
|
||||||
|
MimeType: "text/plain",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
history := m.GetHistory()
|
||||||
|
require.Len(t, history, 3)
|
||||||
|
ids := make([]uint64, len(history))
|
||||||
|
for i, e := range history {
|
||||||
|
ids[i] = e.ID
|
||||||
|
}
|
||||||
|
assert.Contains(t, ids, oldest.ID, "the touched row must survive trimming")
|
||||||
|
assert.Equal(t, oldest.ID, history[1].ID, "the touched row ranks second, behind the newer copy")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetHistory_SameSecondOrdersByID(t *testing.T) {
|
||||||
|
m := newTestManagerWithDB(t)
|
||||||
|
m.config.MaxHistory = 10
|
||||||
|
|
||||||
|
stamp := time.Now()
|
||||||
|
for i := range 3 {
|
||||||
|
require.NoError(t, m.storeEntry(Entry{
|
||||||
|
Data: []byte(fmt.Sprintf("same-second-%d", i)),
|
||||||
|
MimeType: "text/plain",
|
||||||
|
Timestamp: stamp,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touching the oldest row rewrites it in place, so the bucket now yields
|
||||||
|
// it first even though every row shares a timestamp.
|
||||||
|
oldest := m.GetHistory()[2]
|
||||||
|
require.NoError(t, m.TouchEntry(oldest.ID))
|
||||||
|
|
||||||
|
history := m.GetHistory()
|
||||||
|
require.Len(t, history, 3)
|
||||||
|
assert.Equal(t, oldest.ID, history[0].ID, "the touched row must lead: its timestamp is newer")
|
||||||
|
assert.Greater(t, history[1].ID, history[2].ID, "rows still tied on timestamp break on newest id")
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
"github.com/godbus/dbus/v5"
|
||||||
@@ -44,22 +45,30 @@ func getConfigPath() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() Config {
|
func LoadConfig() Config {
|
||||||
|
cfg, err := readConfig()
|
||||||
|
if err != nil {
|
||||||
|
return DefaultConfig()
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func readConfig() (Config, error) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
path, err := getConfigPath()
|
path, err := getConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cfg
|
return cfg, err
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cfg
|
return cfg, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
return DefaultConfig()
|
return DefaultConfig(), err
|
||||||
}
|
}
|
||||||
return cfg
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func SaveConfig(cfg Config) error {
|
func SaveConfig(cfg Config) error {
|
||||||
@@ -140,6 +149,7 @@ type Manager struct {
|
|||||||
pasteSupported bool
|
pasteSupported bool
|
||||||
|
|
||||||
initialized bool
|
initialized bool
|
||||||
|
deviceBound atomic.Bool
|
||||||
|
|
||||||
alive bool
|
alive bool
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package dbus
|
package dbus
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/duxlabs-dev/dux-go/ipc"
|
"github.com/duxlabs-dev/dux-go/ipc"
|
||||||
@@ -43,7 +44,7 @@ func extractObjectParams(p map[string]any, requirePath bool) (objectParams, erro
|
|||||||
return objectParams{bus: bus, dest: dest, path: path, iface: iface}, nil
|
return objectParams{bus: bus, dest: dest, path: path, iface: iface}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleRequest(conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID string) {
|
func HandleRequest(ctx context.Context, conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID string) {
|
||||||
switch req.Method {
|
switch req.Method {
|
||||||
case "dbus.call":
|
case "dbus.call":
|
||||||
handleCall(conn, req, m)
|
handleCall(conn, req, m)
|
||||||
@@ -58,7 +59,7 @@ func HandleRequest(conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID s
|
|||||||
case "dbus.listNames":
|
case "dbus.listNames":
|
||||||
handleListNames(conn, req, m)
|
handleListNames(conn, req, m)
|
||||||
case "dbus.subscribe":
|
case "dbus.subscribe":
|
||||||
handleSubscribe(conn, req, m, clientID)
|
handleSubscribe(ctx, conn, req, m, clientID)
|
||||||
case "dbus.unsubscribe":
|
case "dbus.unsubscribe":
|
||||||
handleUnsubscribe(conn, req, m)
|
handleUnsubscribe(conn, req, m)
|
||||||
default:
|
default:
|
||||||
@@ -200,7 +201,7 @@ func handleListNames(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
|
|||||||
conn.Respond(req.ID, result)
|
conn.Respond(req.ID, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSubscribe(conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID string) {
|
func handleSubscribe(ctx context.Context, conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID string) {
|
||||||
if id := params.StringOpt(req.Params, "clientId", ""); id != "" {
|
if id := params.StringOpt(req.Params, "clientId", ""); id != "" {
|
||||||
clientID = id
|
clientID = id
|
||||||
}
|
}
|
||||||
@@ -216,12 +217,13 @@ func handleSubscribe(conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID
|
|||||||
iface := params.StringOpt(req.Params, "interface", "")
|
iface := params.StringOpt(req.Params, "interface", "")
|
||||||
member := params.StringOpt(req.Params, "member", "")
|
member := params.StringOpt(req.Params, "member", "")
|
||||||
|
|
||||||
result, err := m.Subscribe(clientID, bus, sender, path, iface, member)
|
result, err := m.Subscribe(conn, clientID, bus, sender, path, iface, member)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conn.RespondError(req.ID, err.Error())
|
conn.RespondError(req.ID, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.ReleaseOnClose(ctx, conn)
|
||||||
conn.Respond(req.ID, result)
|
conn.Respond(req.ID, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package dbus
|
package dbus
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/duxlabs-dev/dux-go/dbusutil"
|
"github.com/duxlabs-dev/dux-go/dbusutil"
|
||||||
|
"github.com/duxlabs-dev/dux-go/ipc"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||||
"github.com/godbus/dbus/v5"
|
"github.com/godbus/dbus/v5"
|
||||||
)
|
)
|
||||||
@@ -188,7 +190,7 @@ func (m *Manager) ListNames(bus string) (*ListNamesResult, error) {
|
|||||||
return &ListNamesResult{Names: names}, nil
|
return &ListNamesResult{Names: names}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Subscribe(clientID, bus, sender, path, iface, member string) (*SubscribeResult, error) {
|
func (m *Manager) Subscribe(owner *ipc.ConnWriter, clientID, bus, sender, path, iface, member string) (*SubscribeResult, error) {
|
||||||
conn, err := m.getConn(bus)
|
conn, err := m.getConn(bus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -223,6 +225,7 @@ func (m *Manager) Subscribe(clientID, bus, sender, path, iface, member string) (
|
|||||||
Interface: iface,
|
Interface: iface,
|
||||||
Member: member,
|
Member: member,
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
|
Owner: owner,
|
||||||
}
|
}
|
||||||
m.subscriptions.Store(subID, sub)
|
m.subscriptions.Store(subID, sub)
|
||||||
|
|
||||||
@@ -267,10 +270,23 @@ func (m *Manager) Unsubscribe(subID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) UnsubscribeClient(clientID string) {
|
// Match rules live as long as the request connection that added them, not the
|
||||||
|
// subscribe socket, which the shell re-dials whenever its service list changes.
|
||||||
|
func (m *Manager) ReleaseOnClose(ctx context.Context, owner *ipc.ConnWriter) {
|
||||||
|
if _, loaded := m.owners.LoadOrStore(owner, struct{}{}); loaded {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
m.owners.Delete(owner)
|
||||||
|
m.unsubscribeOwner(owner)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) unsubscribeOwner(owner *ipc.ConnWriter) {
|
||||||
var toDelete []string
|
var toDelete []string
|
||||||
m.subscriptions.Range(func(subID string, sub *signalSubscription) bool {
|
m.subscriptions.Range(func(subID string, sub *signalSubscription) bool {
|
||||||
if sub.ClientID == clientID {
|
if sub.Owner == owner {
|
||||||
toDelete = append(toDelete, subID)
|
toDelete = append(toDelete, subID)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -281,20 +297,23 @@ func (m *Manager) UnsubscribeClient(clientID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A re-dialed subscribe socket can register before the old one tears down, so
|
||||||
|
// the old release must only drop the channel it created. Channels are never
|
||||||
|
// closed here because dispatchSignal may be mid-send on another goroutine.
|
||||||
func (m *Manager) SubscribeSignals(clientID string) chan SignalEvent {
|
func (m *Manager) SubscribeSignals(clientID string) chan SignalEvent {
|
||||||
ch := make(chan SignalEvent, 64)
|
ch := make(chan SignalEvent, 64)
|
||||||
existing, loaded := m.signalSubscribers.LoadOrStore(clientID, ch)
|
m.subscribersMu.Lock()
|
||||||
if loaded {
|
m.signalSubscribers.Store(clientID, ch)
|
||||||
return existing
|
m.subscribersMu.Unlock()
|
||||||
}
|
|
||||||
return ch
|
return ch
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) UnsubscribeSignals(clientID string) {
|
func (m *Manager) UnsubscribeSignals(clientID string, ch chan SignalEvent) {
|
||||||
if ch, ok := m.signalSubscribers.LoadAndDelete(clientID); ok {
|
m.subscribersMu.Lock()
|
||||||
close(ch)
|
defer m.subscribersMu.Unlock()
|
||||||
|
if current, ok := m.signalSubscribers.Load(clientID); ok && current == ch {
|
||||||
|
m.signalSubscribers.Delete(clientID)
|
||||||
}
|
}
|
||||||
m.UnsubscribeClient(clientID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) processSystemSignals() {
|
func (m *Manager) processSystemSignals() {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package dbus
|
package dbus
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/duxlabs-dev/dux-go/ipc"
|
||||||
"github.com/duxlabs-dev/dux-go/syncmap"
|
"github.com/duxlabs-dev/dux-go/syncmap"
|
||||||
"github.com/godbus/dbus/v5"
|
"github.com/godbus/dbus/v5"
|
||||||
)
|
)
|
||||||
@@ -12,7 +15,9 @@ type Manager struct {
|
|||||||
sessionSignals chan *dbus.Signal
|
sessionSignals chan *dbus.Signal
|
||||||
|
|
||||||
subscriptions syncmap.Map[string, *signalSubscription]
|
subscriptions syncmap.Map[string, *signalSubscription]
|
||||||
|
subscribersMu sync.Mutex
|
||||||
signalSubscribers syncmap.Map[string, chan SignalEvent]
|
signalSubscribers syncmap.Map[string, chan SignalEvent]
|
||||||
|
owners syncmap.Map[*ipc.ConnWriter, struct{}]
|
||||||
}
|
}
|
||||||
|
|
||||||
type signalSubscription struct {
|
type signalSubscription struct {
|
||||||
@@ -22,6 +27,7 @@ type signalSubscription struct {
|
|||||||
Interface string
|
Interface string
|
||||||
Member string
|
Member string
|
||||||
ClientID string
|
ClientID string
|
||||||
|
Owner *ipc.ConnWriter
|
||||||
}
|
}
|
||||||
|
|
||||||
type SignalEvent struct {
|
type SignalEvent struct {
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
package loginctl
|
package loginctl
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errLockBeforeSuspendManaged = errors.New("lock before suspend is managed by administrator policy")
|
||||||
|
errSleepInhibitorManaged = errors.New("the sleep inhibitor is managed by administrator policy")
|
||||||
|
)
|
||||||
|
|
||||||
func (m *Manager) Lock() error {
|
func (m *Manager) Lock() error {
|
||||||
if m.sessionObj == nil {
|
if m.sessionObj == nil {
|
||||||
return fmt.Errorf("session object not available")
|
return fmt.Errorf("session object not available")
|
||||||
@@ -85,8 +91,21 @@ func (m *Manager) Terminate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) SetLockBeforeSuspend(enabled bool) {
|
// applyLockPolicy starts from the managed settings, before the shell connects.
|
||||||
|
func (m *Manager) applyLockPolicy() {
|
||||||
|
if managed := m.lockPolicy.LockBeforeSuspend; managed != nil {
|
||||||
|
m.lockBeforeSuspend.Store(*managed)
|
||||||
|
}
|
||||||
|
enabled, managed := m.lockPolicy.sleepInhibitor()
|
||||||
|
m.sleepInhibitorEnabled.Store(enabled || !managed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) SetLockBeforeSuspend(enabled bool) error {
|
||||||
|
if managed := m.lockPolicy.LockBeforeSuspend; managed != nil && *managed != enabled {
|
||||||
|
return errLockBeforeSuspendManaged
|
||||||
|
}
|
||||||
m.lockBeforeSuspend.Store(enabled)
|
m.lockBeforeSuspend.Store(enabled)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) markLockerReady() {
|
func (m *Manager) markLockerReady() {
|
||||||
@@ -107,7 +126,10 @@ func (m *Manager) markLockerReady() {
|
|||||||
m.prelockedReady.Store(true)
|
m.prelockedReady.Store(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) SetSleepInhibitorEnabled(enabled bool) {
|
func (m *Manager) SetSleepInhibitorEnabled(enabled bool) error {
|
||||||
|
if managedValue, managed := m.lockPolicy.sleepInhibitor(); managed && managedValue != enabled {
|
||||||
|
return errSleepInhibitorManaged
|
||||||
|
}
|
||||||
m.sleepInhibitorEnabled.Store(enabled)
|
m.sleepInhibitorEnabled.Store(enabled)
|
||||||
if enabled {
|
if enabled {
|
||||||
// Re-acquire inhibitor if enabled
|
// Re-acquire inhibitor if enabled
|
||||||
@@ -116,4 +138,5 @@ func (m *Manager) SetSleepInhibitorEnabled(enabled bool) {
|
|||||||
// Release inhibitor if disabled
|
// Release inhibitor if disabled
|
||||||
m.releaseSleepInhibitor()
|
m.releaseSleepInhibitor()
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,10 @@ func handleSetLockBeforeSuspend(conn *ipc.ConnWriter, req ipc.Request, manager *
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
manager.SetLockBeforeSuspend(enabled)
|
if err := manager.SetLockBeforeSuspend(enabled); err != nil {
|
||||||
|
conn.RespondError(req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "lock before suspend set"})
|
conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "lock before suspend set"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +114,10 @@ func handleSetSleepInhibitorEnabled(conn *ipc.ConnWriter, req ipc.Request, manag
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
manager.SetSleepInhibitorEnabled(enabled)
|
if err := manager.SetSleepInhibitorEnabled(enabled); err != nil {
|
||||||
|
conn.RespondError(req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "sleep inhibitor setting updated"})
|
conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "sleep inhibitor setting updated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -424,3 +425,85 @@ func TestHandleRequest(t *testing.T) {
|
|||||||
assert.Empty(t, resp.Error)
|
assert.Empty(t, resp.Error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleSetLockBeforeSuspend(t *testing.T) {
|
||||||
|
on, off := true, false
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
managed *bool
|
||||||
|
request bool
|
||||||
|
want bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "unmanaged", request: true, want: true},
|
||||||
|
{name: "managed, same value", managed: &on, request: true, want: true},
|
||||||
|
{name: "managed on, request off", managed: &on, request: false, want: true, wantErr: true},
|
||||||
|
{name: "managed off, request on", managed: &off, request: true, want: false, wantErr: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
manager := &Manager{lockPolicy: LockPolicy{LockBeforeSuspend: tt.managed}}
|
||||||
|
manager.applyLockPolicy()
|
||||||
|
|
||||||
|
mc := newMockNetConn()
|
||||||
|
conn := ipc.NewConnWriter(mc)
|
||||||
|
HandleRequest(conn, ipc.Request{ID: 7, Method: "loginctl.setLockBeforeSuspend", Params: map[string]any{"enabled": tt.request}}, manager)
|
||||||
|
|
||||||
|
var resp ipc.Response[models.SuccessResult]
|
||||||
|
require.NoError(t, json.NewDecoder(mc.writeBuf).Decode(&resp))
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Equal(t, "lock before suspend is managed by administrator policy", resp.Error)
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, resp.Error)
|
||||||
|
}
|
||||||
|
assert.Equal(t, tt.want, manager.lockBeforeSuspend.Load())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSetSleepInhibitorEnabled(t *testing.T) {
|
||||||
|
on, off := true, false
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
policy LockPolicy
|
||||||
|
request bool
|
||||||
|
want bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "unmanaged", request: false, want: false},
|
||||||
|
{name: "both managed on, request off", policy: LockPolicy{LockBeforeSuspend: &on, LoginctlLockIntegration: &on}, request: false, want: true, wantErr: true},
|
||||||
|
{name: "both managed on, request on", policy: LockPolicy{LockBeforeSuspend: &on, LoginctlLockIntegration: &on}, request: true, want: true},
|
||||||
|
{name: "lock before suspend managed off", policy: LockPolicy{LockBeforeSuspend: &off}, request: true, want: false, wantErr: true},
|
||||||
|
{name: "integration managed off", policy: LockPolicy{LoginctlLockIntegration: &off}, request: true, want: false, wantErr: true},
|
||||||
|
{name: "lock before suspend managed on, integration left to the user", policy: LockPolicy{LockBeforeSuspend: &on}, request: false, want: false},
|
||||||
|
{name: "integration managed on, lock before suspend left to the user", policy: LockPolicy{LoginctlLockIntegration: &on}, request: false, want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
manager := &Manager{lockPolicy: tt.policy}
|
||||||
|
manager.applyLockPolicy()
|
||||||
|
inhibit, err := os.CreateTemp(t.TempDir(), "inhibit")
|
||||||
|
require.NoError(t, err)
|
||||||
|
held := manager.sleepInhibitorEnabled.Load()
|
||||||
|
if held {
|
||||||
|
manager.inhibitFile = inhibit
|
||||||
|
}
|
||||||
|
|
||||||
|
mc := newMockNetConn()
|
||||||
|
conn := ipc.NewConnWriter(mc)
|
||||||
|
HandleRequest(conn, ipc.Request{ID: 7, Method: "loginctl.setSleepInhibitorEnabled", Params: map[string]any{"enabled": tt.request}}, manager)
|
||||||
|
|
||||||
|
var resp ipc.Response[models.SuccessResult]
|
||||||
|
require.NoError(t, json.NewDecoder(mc.writeBuf).Decode(&resp))
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Equal(t, "the sleep inhibitor is managed by administrator policy", resp.Error)
|
||||||
|
assert.Equal(t, held, manager.inhibitFile != nil, "a refused request leaves the inhibitor as it was")
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, resp.Error)
|
||||||
|
}
|
||||||
|
assert.Equal(t, tt.want, manager.sleepInhibitorEnabled.Load())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"github.com/godbus/dbus/v5"
|
"github.com/godbus/dbus/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewManager() (*Manager, error) {
|
func NewManager(keepLockOnUnlock bool, lockPolicy LockPolicy) (*Manager, error) {
|
||||||
conn, err := dbus.SystemBus()
|
conn, err := dbus.SystemBus()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to connect to system bus: %w", err)
|
return nil, fmt.Errorf("failed to connect to system bus: %w", err)
|
||||||
@@ -25,8 +25,11 @@ func NewManager() (*Manager, error) {
|
|||||||
conn: conn,
|
conn: conn,
|
||||||
dirty: make(chan struct{}, 1),
|
dirty: make(chan struct{}, 1),
|
||||||
signals: make(chan *dbus.Signal, 256),
|
signals: make(chan *dbus.Signal, 256),
|
||||||
|
|
||||||
|
keepLockOnUnlock: keepLockOnUnlock,
|
||||||
|
lockPolicy: lockPolicy,
|
||||||
}
|
}
|
||||||
m.sleepInhibitorEnabled.Store(true)
|
m.applyLockPolicy()
|
||||||
|
|
||||||
if err := m.initialize(); err != nil {
|
if err := m.initialize(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -126,3 +126,28 @@ func TestStateChangedMeaningfully(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestManager_ApplyLockPolicy(t *testing.T) {
|
||||||
|
on, off := true, false
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
policy LockPolicy
|
||||||
|
lockBeforeSuspend bool
|
||||||
|
sleepInhibitorEnabled bool
|
||||||
|
}{
|
||||||
|
{name: "unmanaged", sleepInhibitorEnabled: true},
|
||||||
|
{name: "lock before suspend managed on", policy: LockPolicy{LockBeforeSuspend: &on}, lockBeforeSuspend: true, sleepInhibitorEnabled: true},
|
||||||
|
{name: "both managed on", policy: LockPolicy{LockBeforeSuspend: &on, LoginctlLockIntegration: &on}, lockBeforeSuspend: true, sleepInhibitorEnabled: true},
|
||||||
|
{name: "lock before suspend managed off", policy: LockPolicy{LockBeforeSuspend: &off}},
|
||||||
|
{name: "integration managed off", policy: LockPolicy{LockBeforeSuspend: &on, LoginctlLockIntegration: &off}, lockBeforeSuspend: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
manager := &Manager{lockPolicy: tt.policy}
|
||||||
|
manager.applyLockPolicy()
|
||||||
|
assert.Equal(t, tt.lockBeforeSuspend, manager.lockBeforeSuspend.Load())
|
||||||
|
assert.Equal(t, tt.sleepInhibitorEnabled, manager.sleepInhibitorEnabled.Load())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,14 @@ func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case dbusSessionInterface + ".Unlock":
|
case dbusSessionInterface + ".Unlock":
|
||||||
|
if m.keepLockOnUnlock {
|
||||||
|
// Only the locker clears LockedHint, so take both from logind, and
|
||||||
|
// keep the cached state when logind cannot be read.
|
||||||
|
if m.sessionObj != nil && m.updateSessionState() == nil && !m.snapshotState().Locked {
|
||||||
|
m.prelockedReady.Store(false)
|
||||||
|
}
|
||||||
|
m.notifySubscribers()
|
||||||
|
} else {
|
||||||
lockedHint := m.sessionLockedHint()
|
lockedHint := m.sessionLockedHint()
|
||||||
m.stateMutex.Lock()
|
m.stateMutex.Lock()
|
||||||
m.state.Locked = false
|
m.state.Locked = false
|
||||||
@@ -37,6 +45,7 @@ func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
|
|||||||
m.stateMutex.Unlock()
|
m.stateMutex.Unlock()
|
||||||
m.notifySubscribers()
|
m.notifySubscribers()
|
||||||
m.prelockedReady.Store(false)
|
m.prelockedReady.Store(false)
|
||||||
|
}
|
||||||
|
|
||||||
// Cancel the lock timer if it's still running
|
// Cancel the lock timer if it's still running
|
||||||
m.lockTimerMu.Lock()
|
m.lockTimerMu.Lock()
|
||||||
|
|||||||
@@ -38,19 +38,22 @@ func TestManager_HandleDBusSignal_Lock(t *testing.T) {
|
|||||||
func TestManager_HandleDBusSignal_Unlock(t *testing.T) {
|
func TestManager_HandleDBusSignal_Unlock(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
lockedHint bool
|
keepLock bool
|
||||||
|
call *dbus.Call // logind's GetAll answer; nil means no session object
|
||||||
|
wantLocked bool
|
||||||
|
wantLockedHint bool
|
||||||
}{
|
}{
|
||||||
{name: "logind cleared LockedHint", lockedHint: false},
|
{name: "logind cleared LockedHint", call: lockedHintCall(false)},
|
||||||
{name: "logind still reports LockedHint", lockedHint: true},
|
{name: "logind still reports LockedHint", call: lockedHintCall(true), wantLockedHint: true},
|
||||||
|
{name: "logind unreachable", call: &dbus.Call{Err: assert.AnError}},
|
||||||
|
{name: "policy, LockedHint still set", keepLock: true, call: lockedHintCall(true), wantLocked: true, wantLockedHint: true},
|
||||||
|
{name: "policy, LockedHint cleared", keepLock: true, call: lockedHintCall(false)},
|
||||||
|
{name: "policy, logind unreachable", keepLock: true, call: &dbus.Call{Err: assert.AnError}, wantLocked: true, wantLockedHint: true},
|
||||||
|
{name: "policy, no session object", keepLock: true, wantLocked: true, wantLockedHint: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
sessionObj := mockdbus.NewMockBusObject(t)
|
|
||||||
sessionObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.login1.Session").Return(&dbus.Call{
|
|
||||||
Body: []any{map[string]dbus.Variant{"LockedHint": dbus.MakeVariant(tt.lockedHint)}},
|
|
||||||
})
|
|
||||||
|
|
||||||
manager := &Manager{
|
manager := &Manager{
|
||||||
state: &SessionState{
|
state: &SessionState{
|
||||||
Locked: true,
|
Locked: true,
|
||||||
@@ -58,21 +61,34 @@ func TestManager_HandleDBusSignal_Unlock(t *testing.T) {
|
|||||||
},
|
},
|
||||||
stateMutex: sync.RWMutex{},
|
stateMutex: sync.RWMutex{},
|
||||||
dirty: make(chan struct{}, 1),
|
dirty: make(chan struct{}, 1),
|
||||||
sessionObj: sessionObj,
|
keepLockOnUnlock: tt.keepLock,
|
||||||
}
|
}
|
||||||
|
if tt.call != nil {
|
||||||
|
sessionObj := mockdbus.NewMockBusObject(t)
|
||||||
|
sessionObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.login1.Session").Return(tt.call)
|
||||||
|
manager.sessionObj = sessionObj
|
||||||
|
}
|
||||||
|
manager.markLockerReady()
|
||||||
|
|
||||||
manager.handleDBusSignal(&dbus.Signal{
|
manager.handleDBusSignal(&dbus.Signal{
|
||||||
Name: "org.freedesktop.login1.Session.Unlock",
|
Name: "org.freedesktop.login1.Session.Unlock",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantLocked, manager.prelockedReady.Load())
|
||||||
manager.stateMutex.RLock()
|
manager.stateMutex.RLock()
|
||||||
defer manager.stateMutex.RUnlock()
|
defer manager.stateMutex.RUnlock()
|
||||||
assert.False(t, manager.state.Locked)
|
assert.Equal(t, tt.wantLocked, manager.state.Locked)
|
||||||
assert.Equal(t, tt.lockedHint, manager.state.LockedHint)
|
assert.Equal(t, tt.wantLockedHint, manager.state.LockedHint)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lockedHintCall(lockedHint bool) *dbus.Call {
|
||||||
|
return &dbus.Call{Body: []any{map[string]dbus.Variant{
|
||||||
|
"LockedHint": dbus.MakeVariant(lockedHint),
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
|
||||||
func TestManager_HandleDBusSignal_PrepareForSleep(t *testing.T) {
|
func TestManager_HandleDBusSignal_PrepareForSleep(t *testing.T) {
|
||||||
t.Run("preparing for sleep - true", func(t *testing.T) {
|
t.Run("preparing for sleep - true", func(t *testing.T) {
|
||||||
manager := &Manager{
|
manager := &Manager{
|
||||||
|
|||||||
@@ -69,4 +69,26 @@ type Manager struct {
|
|||||||
lockTimer *time.Timer
|
lockTimer *time.Timer
|
||||||
sleepInhibitorEnabled atomic.Bool
|
sleepInhibitorEnabled atomic.Bool
|
||||||
fallbackDelay time.Duration
|
fallbackDelay time.Duration
|
||||||
|
keepLockOnUnlock bool
|
||||||
|
lockPolicy LockPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
// LockPolicy holds the lock settings an administrator policy manages; nil
|
||||||
|
// leaves a setting to the shell.
|
||||||
|
type LockPolicy struct {
|
||||||
|
LockBeforeSuspend *bool
|
||||||
|
LoginctlLockIntegration *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// sleepInhibitor returns the inhibitor state the shell asks for,
|
||||||
|
// loginctlLockIntegration && lockBeforeSuspend, when the managed settings
|
||||||
|
// decide it on their own.
|
||||||
|
func (p LockPolicy) sleepInhibitor() (enabled, managed bool) {
|
||||||
|
switch {
|
||||||
|
case p.LockBeforeSuspend != nil && !*p.LockBeforeSuspend, p.LoginctlLockIntegration != nil && !*p.LoginctlLockIntegration:
|
||||||
|
return false, true
|
||||||
|
case p.LockBeforeSuspend != nil && p.LoginctlLockIntegration != nil:
|
||||||
|
return true, true
|
||||||
|
}
|
||||||
|
return false, false
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -278,7 +279,7 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
|
|
||||||
a.backend.cachedVPNCredsMu.Lock()
|
a.backend.cachedVPNCredsMu.Lock()
|
||||||
cached := a.backend.cachedVPNCreds
|
cached := a.backend.cachedVPNCreds
|
||||||
if cached != nil && cached.ConnectionUUID == connUuid {
|
if cached != nil && cached.ConnectionUUID == connUuid && slices.Contains(fields, "password") {
|
||||||
a.backend.cachedVPNCreds = nil
|
a.backend.cachedVPNCreds = nil
|
||||||
a.backend.cachedVPNCredsMu.Unlock()
|
a.backend.cachedVPNCredsMu.Unlock()
|
||||||
|
|
||||||
@@ -451,7 +452,7 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
VpnService: vpnSvc,
|
VpnService: vpnSvc,
|
||||||
SettingName: settingName,
|
SettingName: settingName,
|
||||||
Fields: fields,
|
Fields: fields,
|
||||||
FieldsInfo: buildFieldsInfo(settingName, fields, vpnSvc),
|
FieldsInfo: buildFieldsInfo(settingName, fields, hints, vpnSvc),
|
||||||
Hints: hints,
|
Hints: hints,
|
||||||
Reason: reason,
|
Reason: reason,
|
||||||
ConnectionId: connId,
|
ConnectionId: connId,
|
||||||
@@ -577,13 +578,13 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
default:
|
default:
|
||||||
out[settingName] = sec
|
out[settingName] = sec
|
||||||
}
|
}
|
||||||
if settingName == "vpn" && a.backend != nil && !isPKCS11 && (vpnUsername != "" || reply.Save) {
|
|
||||||
secrets := make(map[string]string)
|
secrets := make(map[string]string)
|
||||||
for k, v := range reply.Secrets {
|
for k, v := range reply.Secrets {
|
||||||
if k != "username" {
|
if k != "username" && k != "challenge-response" {
|
||||||
secrets[k] = v
|
secrets[k] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if settingName == "vpn" && a.backend != nil && !isPKCS11 && (vpnUsername != "" || (reply.Save && len(secrets) > 0)) {
|
||||||
a.backend.pendingVPNSaveMu.Lock()
|
a.backend.pendingVPNSaveMu.Lock()
|
||||||
a.backend.pendingVPNSave = &pendingVPNCredentials{
|
a.backend.pendingVPNSave = &pendingVPNCredentials{
|
||||||
ConnectionPath: string(path),
|
ConnectionPath: string(path),
|
||||||
@@ -835,13 +836,46 @@ func fieldsNeeded(setting string, hints []string, conn map[string]nmVariantMap)
|
|||||||
return hints
|
return hints
|
||||||
}
|
}
|
||||||
return infer8021xFields(conn)
|
return infer8021xFields(conn)
|
||||||
case "vpn", "wireguard":
|
case "vpn":
|
||||||
|
return parseVPNHints(hints).fields
|
||||||
|
case "wireguard":
|
||||||
return hints
|
return hints
|
||||||
default:
|
default:
|
||||||
return []string{}
|
return []string{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
vpnHintMessagePrefix = "x-vpn-message:"
|
||||||
|
vpnHintChallengePrefix = "x-dynamic-challenge:"
|
||||||
|
vpnHintChallengeEchoPrefix = "x-dynamic-challenge-echo:"
|
||||||
|
)
|
||||||
|
|
||||||
|
type vpnPromptHints struct {
|
||||||
|
fields []string
|
||||||
|
echo map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NM-openvpn asks for a CRV1 challenge answer as x-dynamic-challenge[-echo]:<secret>
|
||||||
|
// and NM appends the plugin's prompt text as x-vpn-message:<text>, which the modal shows.
|
||||||
|
func parseVPNHints(hints []string) vpnPromptHints {
|
||||||
|
parsed := vpnPromptHints{echo: map[string]bool{}}
|
||||||
|
for _, hint := range hints {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(hint, vpnHintMessagePrefix):
|
||||||
|
case strings.HasPrefix(hint, vpnHintChallengeEchoPrefix):
|
||||||
|
field := strings.TrimPrefix(hint, vpnHintChallengeEchoPrefix)
|
||||||
|
parsed.echo[field] = true
|
||||||
|
parsed.fields = append(parsed.fields, field)
|
||||||
|
case strings.HasPrefix(hint, vpnHintChallengePrefix):
|
||||||
|
parsed.fields = append(parsed.fields, strings.TrimPrefix(hint, vpnHintChallengePrefix))
|
||||||
|
default:
|
||||||
|
parsed.fields = append(parsed.fields, hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
func infer8021xFields(conn map[string]nmVariantMap) []string {
|
func infer8021xFields(conn map[string]nmVariantMap) []string {
|
||||||
dot1x, ok := conn["802-1x"]
|
dot1x, ok := conn["802-1x"]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -877,7 +911,8 @@ func infer8021xFields(conn map[string]nmVariantMap) []string {
|
|||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildFieldsInfo(setting string, fields []string, vpnService string) []FieldInfo {
|
func buildFieldsInfo(setting string, fields []string, hints []string, vpnService string) []FieldInfo {
|
||||||
|
echo := parseVPNHints(hints).echo
|
||||||
result := make([]FieldInfo, 0, len(fields))
|
result := make([]FieldInfo, 0, len(fields))
|
||||||
for _, f := range fields {
|
for _, f := range fields {
|
||||||
info := FieldInfo{Name: f}
|
info := FieldInfo{Name: f}
|
||||||
@@ -899,6 +934,9 @@ func buildFieldsInfo(setting string, fields []string, vpnService string) []Field
|
|||||||
}
|
}
|
||||||
case "vpn":
|
case "vpn":
|
||||||
info.Label, info.IsSecret = vpnFieldMeta(f, vpnService)
|
info.Label, info.IsSecret = vpnFieldMeta(f, vpnService)
|
||||||
|
if echo[f] {
|
||||||
|
info.IsSecret = false
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
info.Label = f
|
info.Label = f
|
||||||
info.IsSecret = true
|
info.IsSecret = true
|
||||||
@@ -1052,6 +1090,8 @@ func vpnFieldMeta(field, _ string) (label string, isSecret bool) {
|
|||||||
return "IPSec Pre-Shared Key", true
|
return "IPSec Pre-Shared Key", true
|
||||||
case "cert-pass":
|
case "cert-pass":
|
||||||
return "Certificate Password", true
|
return "Certificate Password", true
|
||||||
|
case "challenge-response":
|
||||||
|
return "Challenge Response", true
|
||||||
case "http-proxy-password":
|
case "http-proxy-password":
|
||||||
return "HTTP Proxy Password", true
|
return "HTTP Proxy Password", true
|
||||||
case "username":
|
case "username":
|
||||||
|
|||||||
@@ -308,6 +308,47 @@ func TestSecretAgent_GetSecrets_NoInteractionFlag(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "NoSecrets")
|
assert.Contains(t, err.Error(), "NoSecrets")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSecretAgent_GetSecrets_OpenVPNDynamicChallenge(t *testing.T) {
|
||||||
|
conn := map[string]nmVariantMap{
|
||||||
|
"connection": {
|
||||||
|
"id": dbus.MakeVariant("Work VPN"),
|
||||||
|
"type": dbus.MakeVariant("vpn"),
|
||||||
|
},
|
||||||
|
"vpn": {
|
||||||
|
"service-type": dbus.MakeVariant("org.freedesktop.NetworkManager.openvpn"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hint string
|
||||||
|
isSecret bool
|
||||||
|
}{
|
||||||
|
{"noecho", "x-dynamic-challenge:challenge-response", true},
|
||||||
|
{"echo", "x-dynamic-challenge-echo:challenge-response", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
broker := &fakePromptBroker{
|
||||||
|
asked: make(chan PromptRequest, 1),
|
||||||
|
reply: PromptReply{Secrets: map[string]string{"challenge-response": "123456"}},
|
||||||
|
}
|
||||||
|
agent := &SecretAgent{prompts: broker}
|
||||||
|
hints := []string{tt.hint, "x-vpn-message:Enter your OTP"}
|
||||||
|
|
||||||
|
out, err := agent.GetSecrets(conn, "/test/path", "vpn", hints, nmSecretAgentFlagAllowInteraction)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
req := <-broker.asked
|
||||||
|
assert.Equal(t, []string{"challenge-response"}, req.Fields)
|
||||||
|
assert.Equal(t, []FieldInfo{{Name: "challenge-response", Label: "Challenge Response", IsSecret: tt.isSecret}}, req.FieldsInfo)
|
||||||
|
|
||||||
|
secrets, _ := out["vpn"]["secrets"].Value().(map[string]string)
|
||||||
|
assert.Equal(t, map[string]string{"challenge-response": "123456"}, secrets)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildWiFiSecretsResponse(t *testing.T) {
|
func TestBuildWiFiSecretsResponse(t *testing.T) {
|
||||||
t.Run("wpa-psk returns psk", func(t *testing.T) {
|
t.Run("wpa-psk returns psk", func(t *testing.T) {
|
||||||
out := buildWiFiSecretsResponse("802-11-wireless-security", map[string]string{"psk": "hunter2"})
|
out := buildWiFiSecretsResponse("802-11-wireless-security", map[string]string{"psk": "hunter2"})
|
||||||
|
|||||||
@@ -258,16 +258,11 @@ func (b *NetworkManagerBackend) listEthernetConnections() ([]WiredConnection, er
|
|||||||
return nil, fmt.Errorf("no ethernet device available")
|
return nil, fmt.Errorf("no ethernet device available")
|
||||||
}
|
}
|
||||||
|
|
||||||
s := b.settings
|
settingsMgr, err := b.networkManagerSettings()
|
||||||
if s == nil {
|
|
||||||
s, err := gonetworkmanager.NewSettings()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get settings: %w", err)
|
return nil, err
|
||||||
}
|
|
||||||
b.settings = s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
settingsMgr := s.(gonetworkmanager.Settings)
|
|
||||||
connections, err := settingsMgr.ListConnections()
|
connections, err := settingsMgr.ListConnections()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get connections: %w", err)
|
return nil, fmt.Errorf("failed to get connections: %w", err)
|
||||||
|
|||||||
@@ -305,6 +305,9 @@ func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) {
|
|||||||
}
|
}
|
||||||
b.updateHotspotState()
|
b.updateHotspotState()
|
||||||
b.listCellularConnections()
|
b.listCellularConnections()
|
||||||
|
// Device state does not change when an inactive wired profile is
|
||||||
|
// added or deleted, so the profile list must be refreshed here too.
|
||||||
|
b.listEthernetConnections()
|
||||||
if b.onStateChange != nil {
|
if b.onStateChange != nil {
|
||||||
b.onStateChange()
|
b.onStateChange()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,3 +130,57 @@ func TestNetworkManagerBackend_HandleWiFiChange_CoalescesAccessPointBurst(t *tes
|
|||||||
return stateChangeCalls.Load() == 1
|
return stateChangeCalls.Load() == 1
|
||||||
}, 4*wifiNetworksUpdateDelay, 10*time.Millisecond)
|
}, 4*wifiNetworksUpdateDelay, 10*time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNetworkManagerBackend_HandleDBusSignal_ConnectionRemovedRefreshesWiredConnections(t *testing.T) {
|
||||||
|
mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
|
||||||
|
mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil).Maybe()
|
||||||
|
|
||||||
|
remaining := mock_gonetworkmanager.NewMockConnection(t)
|
||||||
|
remaining.EXPECT().GetPath().Return(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings/1")).Maybe()
|
||||||
|
remaining.EXPECT().GetSettings().Return(gonetworkmanager.ConnectionSettings{
|
||||||
|
"connection": {
|
||||||
|
"id": "Wired connection 1",
|
||||||
|
"uuid": "uuid-1",
|
||||||
|
"type": "802-3-ethernet",
|
||||||
|
},
|
||||||
|
}, nil).Maybe()
|
||||||
|
|
||||||
|
mockSettings := mock_gonetworkmanager.NewMockSettings(t)
|
||||||
|
mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{remaining}, nil).Maybe()
|
||||||
|
|
||||||
|
backend, err := NewNetworkManagerBackend(mockNM)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
backend.settings = mockSettings
|
||||||
|
backend.ethernetDevice = mock_gonetworkmanager.NewMockDevice(t)
|
||||||
|
|
||||||
|
backend.stateMutex.Lock()
|
||||||
|
backend.state.WiredConnections = []WiredConnection{
|
||||||
|
{ID: "Wired connection 1", UUID: "uuid-1", Type: "802-3-ethernet"},
|
||||||
|
{ID: "Wired connection 2", UUID: "uuid-2", Type: "802-3-ethernet"},
|
||||||
|
}
|
||||||
|
backend.stateMutex.Unlock()
|
||||||
|
|
||||||
|
backend.handleDBusSignal(&dbus.Signal{
|
||||||
|
Name: "org.freedesktop.NetworkManager.Settings.ConnectionRemoved",
|
||||||
|
Body: []any{dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings/2")},
|
||||||
|
})
|
||||||
|
|
||||||
|
backend.stateMutex.RLock()
|
||||||
|
defer backend.stateMutex.RUnlock()
|
||||||
|
assert.Len(t, backend.state.WiredConnections, 1)
|
||||||
|
assert.Equal(t, "uuid-1", backend.state.WiredConnections[0].UUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNetworkManagerBackend_ListEthernetConnections_InvalidSettingsHandle(t *testing.T) {
|
||||||
|
mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
|
||||||
|
|
||||||
|
backend, err := NewNetworkManagerBackend(mockNM)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
backend.settings = "not a settings handle"
|
||||||
|
backend.ethernetDevice = mock_gonetworkmanager.NewMockDevice(t)
|
||||||
|
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
_, err = backend.listEthernetConnections()
|
||||||
|
})
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@ package network
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os/user"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"github.com/Wifx/gonetworkmanager/v2"
|
"github.com/Wifx/gonetworkmanager/v2"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||||
|
"github.com/godbus/dbus/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (b *NetworkManagerBackend) GetWiFiEnabled() (bool, error) {
|
func (b *NetworkManagerBackend) GetWiFiEnabled() (bool, error) {
|
||||||
@@ -768,6 +770,9 @@ func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionReque
|
|||||||
"type": "802-11-wireless",
|
"type": "802-11-wireless",
|
||||||
"autoconnect": true,
|
"autoconnect": true,
|
||||||
}
|
}
|
||||||
|
if !b.canModifySystemConnections() {
|
||||||
|
restrictConnectionToCurrentUser(settings["connection"])
|
||||||
|
}
|
||||||
|
|
||||||
settings["ipv4"] = map[string]any{"method": "auto"}
|
settings["ipv4"] = map[string]any{"method": "auto"}
|
||||||
settings["ipv6"] = map[string]any{"method": "auto"}
|
settings["ipv6"] = map[string]any{"method": "auto"}
|
||||||
@@ -934,6 +939,39 @@ func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionReque
|
|||||||
// updateConnectionCredentials applies user-supplied credentials to a saved
|
// updateConnectionCredentials applies user-supplied credentials to a saved
|
||||||
// profile before activation; without this a retype after a password change is
|
// profile before activation; without this a retype after a password change is
|
||||||
// silently ignored in favor of the stored (stale) secret.
|
// silently ignored in favor of the stored (stale) secret.
|
||||||
|
const nmPermissionModifySystem = dbusNMInterface + ".settings.modify.system"
|
||||||
|
|
||||||
|
// GetPermissions never prompts. Short of a silent yes, a shared profile prompts on
|
||||||
|
// creation and on every secret-agent request (NM re-checks the agent), so own it
|
||||||
|
// per user like gnome-control-center does.
|
||||||
|
func (b *NetworkManagerBackend) canModifySystemConnections() bool {
|
||||||
|
conn := b.dbusConn
|
||||||
|
if conn == nil {
|
||||||
|
var err error
|
||||||
|
conn, err = dbus.ConnectSystemBus()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
var permissions map[string]string
|
||||||
|
obj := conn.Object(dbusNMInterface, dbus.ObjectPath(dbusNMPath))
|
||||||
|
if err := obj.Call(dbusNMInterface+".GetPermissions", 0).Store(&permissions); err != nil {
|
||||||
|
log.Warnf("[createAndConnectWiFi] GetPermissions failed, creating a user-owned profile: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return permissions[nmPermissionModifySystem] == "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
func restrictConnectionToCurrentUser(connMeta map[string]any) {
|
||||||
|
current, err := user.Current()
|
||||||
|
if err != nil || current.Username == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connMeta["permissions"] = []string{"user:" + current.Username + ":"}
|
||||||
|
}
|
||||||
|
|
||||||
func updateConnectionCredentials(conn gonetworkmanager.Connection, req ConnectionRequest) error {
|
func updateConnectionCredentials(conn gonetworkmanager.Connection, req ConnectionRequest) error {
|
||||||
settings, err := conn.GetSettings()
|
settings, err := conn.GetSettings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Loaded 100 of 557 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user