mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-30 15:07:49 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cf13f150a | ||
|
|
16fc39cc16 | ||
|
|
7060c8eeb7 | ||
|
|
87312075dd | ||
|
|
7ad1b7ef1d | ||
|
|
a0b74b2acf | ||
|
|
4927ef5b9b | ||
|
|
46a12d9152 | ||
|
|
caabd9228b | ||
|
|
f65e934e1b | ||
|
|
bca7513825 | ||
|
|
fd137f0c81 | ||
|
|
6f68c94bc3 | ||
|
|
40a129c9d6 | ||
|
|
095d1c6fd5 | ||
|
|
d367e7e71b | ||
|
|
e777288904 | ||
|
|
1038bc70d0 | ||
|
|
673ceb261e | ||
|
|
f288dbc52e | ||
|
|
ff62d7618f | ||
|
|
26c4cd1154 | ||
|
|
8ce37501bd | ||
|
|
e48c1221e7 | ||
|
|
a26298d294 | ||
|
|
b18fc38693 | ||
|
|
8e8442fa76 | ||
|
|
47f7f3d798 | ||
|
|
791537bf7b | ||
|
|
012237a1ad | ||
|
|
c13ffb530a | ||
|
|
d254f40acd | ||
|
|
2a9f83ccb2 | ||
|
|
2308934453 | ||
|
|
a1c8db2558 | ||
|
|
eafb118b1f | ||
|
|
3b63f55779 | ||
|
|
61528822ba | ||
|
|
df8b1d85ca | ||
|
|
e4c4fe44ea | ||
|
|
0b174e0702 | ||
|
|
e97e41036b | ||
|
|
ff1bccbe7b | ||
|
|
2af1c4c1a0 | ||
|
|
197f4b1faa | ||
|
|
19f3e58dcb | ||
|
|
18af7856f9 | ||
|
|
f5d22b101e | ||
|
|
46373f8bee | ||
|
|
229483272a | ||
|
|
2dd237a3af | ||
|
|
d2792b8a11 | ||
|
|
af9876d232 | ||
|
|
9d279e7f88 | ||
|
|
27ff725a17 | ||
|
|
5d53fdb684 | ||
|
|
4a9ab29515 | ||
|
|
083b9c55e2 | ||
|
|
a330e9bfa5 | ||
|
|
8f1a8ad824 | ||
|
|
eac4ae8414 | ||
|
|
07c899dd0c | ||
|
|
4f0e2c6fd4 | ||
|
|
7479d4bb79 | ||
|
|
59ede533d8 | ||
|
|
27a55349bc | ||
|
|
170e8f5074 | ||
|
|
a88be89444 | ||
|
|
76025038df | ||
|
|
ee6e941459 | ||
|
|
2804183b7f | ||
|
|
3d4ea22e73 | ||
|
|
fdebf6d9e6 | ||
|
|
5855b6ff77 | ||
|
|
fa62bf2868 | ||
|
|
b2e1817aad | ||
|
|
2609b49846 | ||
|
|
853b399973 | ||
|
|
290360428e | ||
|
|
86dac60bc2 | ||
|
|
5442f3923b | ||
|
|
6a15b44298 | ||
|
|
5cd0b61402 | ||
|
|
f56a088e44 | ||
|
|
826477e776 | ||
|
|
06e0ac182c | ||
|
|
e265e464bb | ||
|
|
8827aa0041 | ||
|
|
fc7c92f8b2 | ||
|
|
ca5028411e | ||
|
|
a92e60ebef | ||
|
|
693d0c8bb7 | ||
|
|
a7bd6a8e06 | ||
|
|
28d5d56028 | ||
|
|
4a8fc4d4ba | ||
|
|
a3c4d0ab1a | ||
|
|
d60fa2c86f | ||
|
|
3aaab84ba3 | ||
|
|
cb20e898cd | ||
|
|
486de548e0 | ||
|
|
6b32cbe40c | ||
|
|
b0fea51132 | ||
|
|
404c9672d7 | ||
|
|
5b54dafa61 | ||
|
|
3c89a1554b | ||
|
|
473befd4ec | ||
|
|
ce7dcd7439 | ||
|
|
0691242c9a | ||
|
|
0274b00787 | ||
|
|
c719ffede2 | ||
|
|
e689cd2ce7 | ||
|
|
6d65f304e5 | ||
|
|
696be97018 | ||
|
|
20bcf8559b | ||
|
|
3388f78905 | ||
|
|
c5b058c83e | ||
|
|
66382dfa0d | ||
|
|
4d49e126ab | ||
|
|
7e0f110fb8 | ||
|
|
52d6a46d89 | ||
|
|
6f36e49c68 | ||
|
|
2d16cc2f27 | ||
|
|
b7be4a344a | ||
|
|
1e31015acb | ||
|
|
71417b7ca7 | ||
|
|
6bd0e24bd4 | ||
|
|
e50bb088b6 | ||
|
|
efd9324a4d | ||
|
|
0cd03e0180 | ||
|
|
ba7b3620a8 | ||
|
|
c7692f830c | ||
|
|
5a20a8f162 | ||
|
|
acc2c947e5 | ||
|
|
d8734bbe1a | ||
|
|
668dde2a33 | ||
|
|
900e6110d9 | ||
|
|
44d498e362 | ||
|
|
bf9a51c448 | ||
|
|
1a46db0517 | ||
|
|
7781c207f1 | ||
|
|
5b7777785a | ||
|
|
0aab0ec09b | ||
|
|
da79eca3c0 | ||
|
|
aeacaf7afc | ||
|
|
14b3ddaf83 | ||
|
|
6e86b53ccd | ||
|
|
c5d1d4fe46 | ||
|
|
39a58f77ec | ||
|
|
47c9d8d89b | ||
|
|
d8786eaaf7 | ||
|
|
4f969c8e10 | ||
|
|
1b59a474dd | ||
|
|
28f921fa02 | ||
|
|
8f2a486006 | ||
|
|
27a703367f | ||
|
|
8e851b3dc4 | ||
|
|
112d05901f | ||
|
|
afeea489c9 | ||
|
|
90d3c9731c | ||
|
|
b81954454a | ||
|
|
479b1434de | ||
|
|
203b1369b3 | ||
|
|
5ffc581ecf | ||
|
|
3284acab28 | ||
|
|
1fb9f5271a |
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,39 +55,13 @@ 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 {
|
||||||
Exists bool `json:"exists"`
|
Exists bool `json:"exists"`
|
||||||
Included bool `json:"included"`
|
Included bool `json:"included"`
|
||||||
ConfigFormat string `json:"configFormat,omitempty"`
|
|
||||||
ReadOnly bool `json:"readOnly,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runResolveInclude(cmd *cobra.Command, args []string) {
|
func runResolveInclude(cmd *cobra.Command, args []string) {
|
||||||
@@ -132,64 +106,13 @@ func checkHyprlandInclude(filename string) (IncludeResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mainLua := filepath.Join(configDir, "hyprland.lua")
|
mainLua := filepath.Join(configDir, "hyprland.lua")
|
||||||
if _, err := os.Stat(mainLua); err == nil {
|
if _, err := os.Stat(mainLua); err != nil {
|
||||||
result.ConfigFormat = "lua"
|
return result, nil
|
||||||
|
}
|
||||||
result.Included = luaconfig.RequiresTarget(mainLua, targetAbs, make(map[string]bool))
|
result.Included = luaconfig.RequiresTarget(mainLua, targetAbs, make(map[string]bool))
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 0.55/0.56 installs still on hyprland.conf: read-only until migrated.
|
|
||||||
mainConf := filepath.Join(configDir, "hyprland.conf")
|
|
||||||
if _, err := os.Stat(mainConf); err != nil {
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
result.ConfigFormat = "hyprlang"
|
|
||||||
result.ReadOnly = true
|
|
||||||
result.Included = hyprlandFindIncludeHyprlang(mainConf, targetAbs, make(map[string]bool))
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func hyprlandFindIncludeHyprlang(filePath, target string, processed map[string]bool) bool {
|
|
||||||
absPath, err := filepath.Abs(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if processed[absPath] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
processed[absPath] = true
|
|
||||||
|
|
||||||
data, err := os.ReadFile(absPath)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
baseDir := filepath.Dir(absPath)
|
|
||||||
for line := range strings.SplitSeq(string(data), "\n") {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
if !strings.HasPrefix(trimmed, "source") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.SplitN(trimmed, "=", 2)
|
|
||||||
if len(parts) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
fullPath, err := resolveSourcePath(baseDir, strings.TrimSpace(parts[1]))
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if fullPath == target || hyprlandFindIncludeHyprlang(fullPath, target, processed) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkNiriInclude(filename string) (IncludeResult, error) {
|
func checkNiriInclude(filename string) (IncludeResult, error) {
|
||||||
configDir := filepath.Join(utils.XDGConfigHome(), "niri")
|
configDir := filepath.Join(utils.XDGConfigHome(), "niri")
|
||||||
|
|
||||||
|
|||||||
@@ -43,32 +43,17 @@ func TestMangowcFindIncludeNestedRelativeToIncludingFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckHyprlandIncludeFormat(t *testing.T) {
|
func TestCheckHyprlandIncludeIgnoresLegacyConf(t *testing.T) {
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
files map[string]string
|
|
||||||
wantFormat string
|
|
||||||
wantReadOnly bool
|
|
||||||
wantIncluded bool
|
|
||||||
}{
|
|
||||||
{"hyprlang sourced", map[string]string{"hyprland.conf": "source = ./dms/colors.conf\n"}, "hyprlang", true, true},
|
|
||||||
{"hyprlang not sourced", map[string]string{"hyprland.conf": "bind = SUPER, Q, killactive\n"}, "hyprlang", true, false},
|
|
||||||
{"lua wins over leftover conf", map[string]string{"hyprland.conf": "source = ./dms/colors.conf\n", "hyprland.lua": "\n"}, "lua", false, false},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
xdg := t.TempDir()
|
xdg := t.TempDir()
|
||||||
t.Setenv("XDG_CONFIG_HOME", xdg)
|
t.Setenv("XDG_CONFIG_HOME", xdg)
|
||||||
for name, content := range tt.files {
|
writeConfigFile(t, filepath.Join(xdg, "hypr", "hyprland.conf"), "source = ./dms/colors.conf\n")
|
||||||
writeConfigFile(t, filepath.Join(xdg, "hypr", name), content)
|
writeConfigFile(t, filepath.Join(xdg, "hypr", "dms", "colors.conf"), "")
|
||||||
}
|
|
||||||
got, err := checkHyprlandInclude("colors.conf")
|
got, err := checkHyprlandInclude("colors.conf")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got.ConfigFormat != tt.wantFormat || got.ReadOnly != tt.wantReadOnly || got.Included != tt.wantIncluded {
|
if got.Included {
|
||||||
t.Fatalf("got %+v, want format %q readOnly %v included %v", got, tt.wantFormat, tt.wantReadOnly, tt.wantIncluded)
|
t.Fatalf("got %+v; a hyprland.conf source must not count once Hyprland reads only Lua", got)
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+312
-68
@@ -12,6 +12,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -215,11 +216,13 @@ func runDoctor(cmd *cobra.Command, args []string) {
|
|||||||
|
|
||||||
qsFeatures, qsMissingFeatures := checkQuickshellFeatures()
|
qsFeatures, qsMissingFeatures := checkQuickshellFeatures()
|
||||||
|
|
||||||
|
versions, qsVersion := checkVersions(qsMissingFeatures)
|
||||||
results := slices.Concat(
|
results := slices.Concat(
|
||||||
checkSystemInfo(),
|
checkSystemInfo(),
|
||||||
checkVersions(qsMissingFeatures),
|
versions,
|
||||||
checkDMSInstallation(),
|
checkDMSInstallation(),
|
||||||
checkWindowManagers(),
|
checkPackageRepos(),
|
||||||
|
checkWindowManagers(qsVersion),
|
||||||
qsFeatures,
|
qsFeatures,
|
||||||
checkOptionalDependencies(),
|
checkOptionalDependencies(),
|
||||||
checkConfigurationFiles(),
|
checkConfigurationFiles(),
|
||||||
@@ -285,15 +288,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
|
||||||
@@ -684,18 +687,18 @@ func readOSRelease() map[string]string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkVersions(qsMissingFeatures bool) []checkResult {
|
func checkVersions(qsMissingFeatures bool) (results []checkResult, qsSemver string) {
|
||||||
dmsCliPath, _ := os.Executable()
|
dmsCliPath, _ := os.Executable()
|
||||||
dmsCliDetails := ""
|
dmsCliDetails := ""
|
||||||
if doctorVerbose {
|
if doctorVerbose {
|
||||||
dmsCliDetails = dmsCliPath
|
dmsCliDetails = dmsCliPath
|
||||||
}
|
}
|
||||||
|
|
||||||
results := []checkResult{
|
results = []checkResult{
|
||||||
{catVersions, "DMS CLI", statusOK, formatVersion(Version), dmsCliDetails, doctorDocsURL + "#dms-cli"},
|
{catVersions, "DMS CLI", statusOK, formatVersion(Version), dmsCliDetails, doctorDocsURL + "#dms-cli"},
|
||||||
}
|
}
|
||||||
|
|
||||||
qsVersion, qsStatus, qsPath, qsDetails := getQuickshellVersionInfo(qsMissingFeatures)
|
qsVersion, qsStatus, qsPath, qsDetails, qsSemver := getQuickshellVersionInfo(qsMissingFeatures)
|
||||||
if doctorVerbose && qsPath != "" && qsDetails == "" {
|
if doctorVerbose && qsPath != "" && qsDetails == "" {
|
||||||
qsDetails = qsPath
|
qsDetails = qsPath
|
||||||
}
|
}
|
||||||
@@ -708,7 +711,7 @@ func checkVersions(qsMissingFeatures bool) []checkResult {
|
|||||||
results = append(results, checkResult{catVersions, "DMS Shell", statusError, "Not installed or not detected", "Run 'dms setup' to install", doctorDocsURL + "#dms-shell"})
|
results = append(results, checkResult{catVersions, "DMS Shell", statusError, "Not installed or not detected", "Run 'dms setup' to install", doctorDocsURL + "#dms-shell"})
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results, qsSemver
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDMSShellVersion() (version, path string) {
|
func getDMSShellVersion() (version, path string) {
|
||||||
@@ -731,9 +734,9 @@ func getDMSShellVersion() (version, path string) {
|
|||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func getQuickshellVersionInfo(missingFeatures bool) (string, status, string, string) {
|
func getQuickshellVersionInfo(missingFeatures bool) (display string, st status, path, details, semver string) {
|
||||||
if !utils.CommandExists("qs") {
|
if !utils.CommandExists("qs") {
|
||||||
return "Not installed", statusError, "", ""
|
return "Not installed", statusError, "", "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
qsPath, _ := exec.LookPath("qs")
|
qsPath, _ := exec.LookPath("qs")
|
||||||
@@ -745,21 +748,21 @@ func getQuickshellVersionInfo(missingFeatures bool) (string, status, string, str
|
|||||||
if errors.As(err, &exitErr) {
|
if errors.As(err, &exitErr) {
|
||||||
details = quickshellVersionFailureDetails(string(exitErr.Stderr))
|
details = quickshellVersionFailureDetails(string(exitErr.Stderr))
|
||||||
}
|
}
|
||||||
return "Installed (version check failed)", statusWarn, qsPath, details
|
return "Installed (version check failed)", statusWarn, qsPath, details, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
fullVersion := strings.TrimSpace(string(output))
|
fullVersion := strings.TrimSpace(string(output))
|
||||||
if matches := quickshellVersionRegex.FindStringSubmatch(fullVersion); len(matches) >= 2 {
|
if matches := quickshellVersionRegex.FindStringSubmatch(fullVersion); len(matches) >= 2 {
|
||||||
if version.CompareVersions(matches[1], "0.2.0") < 0 {
|
if version.CompareVersions(matches[1], "0.2.0") < 0 {
|
||||||
return fmt.Sprintf("%s (needs >= 0.2.0)", fullVersion), statusError, qsPath, ""
|
return fmt.Sprintf("%s (needs >= 0.2.0)", fullVersion), statusError, qsPath, "", matches[1]
|
||||||
}
|
}
|
||||||
if missingFeatures {
|
if missingFeatures {
|
||||||
return fullVersion, statusWarn, qsPath, ""
|
return fullVersion, statusWarn, qsPath, "", matches[1]
|
||||||
}
|
}
|
||||||
return fullVersion, statusOK, qsPath, ""
|
return fullVersion, statusOK, qsPath, "", matches[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
return fullVersion, statusWarn, qsPath, ""
|
return fullVersion, statusWarn, qsPath, "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func quickshellVersionFailureDetails(stderr string) string {
|
func quickshellVersionFailureDetails(stderr string) string {
|
||||||
@@ -804,7 +807,37 @@ func checkDMSInstallation() []checkResult {
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkWindowManagers() []checkResult {
|
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(qsVersion string) []checkResult {
|
||||||
compositors := []struct {
|
compositors := []struct {
|
||||||
name, versionCmd, versionArg string
|
name, versionCmd, versionArg string
|
||||||
parseVersion func(string) string
|
parseVersion func(string) string
|
||||||
@@ -856,7 +889,16 @@ func checkWindowManagers() []checkResult {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if c.name == "Hyprland" {
|
if c.name == "Hyprland" {
|
||||||
results = append(results, checkHyprlandConfigFormat(filepath.Join(utils.XDGConfigHome(), "hypr"))...)
|
results = append(results, checkHyprlandQuickshellFloor(ver, qsVersion)...)
|
||||||
|
results = append(results, checkHyprlandDeprecatedConfig()...)
|
||||||
|
}
|
||||||
|
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",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -878,7 +920,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 +928,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, ", "),
|
||||||
@@ -947,18 +1026,39 @@ func checkCompositorBlurSupport() checkResult {
|
|||||||
return checkResult{catCompositor, "Background Blur", statusWarn, "Unsupported", "Compositor does not support ext-background-effect-v1", doctorDocsURL + "#compositor-checks"}
|
return checkResult{catCompositor, "Background Blur", statusWarn, "Unsupported", "Compositor does not support ext-background-effect-v1", doctorDocsURL + "#compositor-checks"}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same detection as the keybinds parser: a regular hyprland.lua wins, else hyprland.conf is read-only.
|
func checkHyprlandQuickshellFloor(hyprVer, qsVer string) []checkResult {
|
||||||
func checkHyprlandConfigFormat(hyprDir string) []checkResult {
|
if !hyprlandReadsOnlyLua(hyprVer) || qsVer == "" || version.CompareVersions(qsVer, "0.3.2") >= 0 {
|
||||||
if st, err := os.Stat(filepath.Join(hyprDir, "hyprland.lua")); err == nil && st.Mode().IsRegular() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if st, err := os.Stat(filepath.Join(hyprDir, "hyprland.conf")); err != nil || !st.Mode().IsRegular() {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return []checkResult{{
|
return []checkResult{{
|
||||||
catCompositor, "Hyprland Lua config", statusWarn,
|
catCompositor, "Quickshell for Hyprland 0.57", statusWarn,
|
||||||
"Hyprland config is hyprland.conf",
|
fmt.Sprintf("Quickshell %s is older than 0.3.2", qsVer),
|
||||||
"DMS keybinds, window rules and displays are read-only until you run `dms setup` to migrate to Lua. Hyprland 0.57 drops .conf support.",
|
"Hyprland 0.57 sends workspace addresses that older Quickshell collapses to workspace 0, breaking named and special workspaces. Upgrade Quickshell.",
|
||||||
|
doctorDocsURL + "#compositor-checks",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hyprland before 0.57 answers `unknown request`, which fails to parse and is skipped.
|
||||||
|
func checkHyprlandDeprecatedConfig() []checkResult {
|
||||||
|
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out, err := exec.Command("hyprctl", "-j", "deprecated-config").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return hyprlandDeprecatedConfigResults(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hyprlandDeprecatedConfigResults(out []byte) []checkResult {
|
||||||
|
var notices []string
|
||||||
|
if err := json.Unmarshal(out, ¬ices); err != nil || len(notices) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []checkResult{{
|
||||||
|
catCompositor, "Hyprland deprecated config", statusWarn,
|
||||||
|
fmt.Sprintf("%d deprecated option(s)", len(notices)),
|
||||||
|
strings.Join(notices, "; "),
|
||||||
doctorDocsURL + "#compositor-checks",
|
doctorDocsURL + "#compositor-checks",
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
@@ -967,6 +1067,14 @@ func hyprlandLacksLua(ver string) bool {
|
|||||||
return ver != "" && version.CompareVersions(ver, "0.55.0") < 0
|
return ver != "" && version.CompareVersions(ver, "0.55.0") < 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hyprlandReadsOnlyLua(ver string) bool {
|
||||||
|
return ver != "" && version.CompareVersions(ver, "0.57.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 {
|
||||||
@@ -1500,6 +1608,8 @@ func checkSystemdServices() []checkResult {
|
|||||||
status = statusInfo
|
status = statusInfo
|
||||||
}
|
}
|
||||||
results = append(results, checkResult{catServices, "greetd", status, greetdState.enabled, "", doctorDocsURL + "#services"})
|
results = append(results, checkResult{catServices, "greetd", status, greetdState.enabled, "", doctorDocsURL + "#services"})
|
||||||
|
results = append(results, checkGreetdSession("/etc/greetd/config.toml")...)
|
||||||
|
results = append(results, checkRetiredGreeterPackages()...)
|
||||||
case doctorVerbose:
|
case doctorVerbose:
|
||||||
results = append(results, checkResult{catServices, "greetd", statusInfo, "Not installed", "Optional greeter service", doctorDocsURL + "#services"})
|
results = append(results, checkResult{catServices, "greetd", statusInfo, "Not installed", "Optional greeter service", doctorDocsURL + "#services"})
|
||||||
}
|
}
|
||||||
@@ -1507,6 +1617,112 @@ func checkSystemdServices() []checkResult {
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func greeterLuaHint() string {
|
||||||
|
return "Hyprland 0.57 only reads Lua; switch to the dux-greeter package and rerun `dms-greeter install`. " + greeterPackageInstallHint()
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkGreetdSession(configPath string) []checkResult {
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fields := strings.Fields(greetdDefaultSessionCommand(string(data)))
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
warn := func(message string) []checkResult {
|
||||||
|
return []checkResult{{catServices, "Greeter session", statusWarn, message, greeterLuaHint(), doctorDocsURL + "#services"}}
|
||||||
|
}
|
||||||
|
if bin, err := exec.LookPath(fields[0]); err == nil && isLegacyWrapperScript(bin) {
|
||||||
|
return warn("greetd runs a pre-1.6 greeter wrapper script")
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(greeterFlagValue(fields, "--command"), "hyprland") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cfg := strings.Trim(greeterFlagValue(fields, "-C", "--config"), `"'`)
|
||||||
|
if cfg == "" || strings.HasSuffix(cfg, ".lua") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(cfg)
|
||||||
|
if err != nil || greeterHyprlandLuaContent(content) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return warn("Greeter Hyprland config is not Lua: " + cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
var greeterHyprlandLuaPattern = regexp.MustCompile(`(^|[^[:alnum:]_])hl\.`)
|
||||||
|
|
||||||
|
// Mirrors dux-greeter's launcher.IsHyprlandLuaConfig content check.
|
||||||
|
func greeterHyprlandLuaContent(content []byte) bool {
|
||||||
|
for line := range strings.Lines(string(content)) {
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if greeterHyprlandLuaPattern.MatchString(line) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func greetdDefaultSessionCommand(configTOML string) string {
|
||||||
|
inDefault := false
|
||||||
|
for line := range strings.SplitSeq(configTOML, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "[") {
|
||||||
|
inDefault = line == "[default_session]"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, value, ok := strings.Cut(line, "=")
|
||||||
|
if !inDefault || !ok || strings.TrimSpace(key) != "command" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if unquoted, err := strconv.Unquote(value); err == nil {
|
||||||
|
return unquoted
|
||||||
|
}
|
||||||
|
return strings.Trim(value, `'`)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func greeterFlagValue(fields []string, flags ...string) string {
|
||||||
|
for i, f := range fields {
|
||||||
|
for _, flag := range flags {
|
||||||
|
if f == flag && i+1 < len(fields) {
|
||||||
|
return fields[i+1]
|
||||||
|
}
|
||||||
|
if value, ok := strings.CutPrefix(f, flag+"="); ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkRetiredGreeterPackages() []checkResult {
|
||||||
|
if !utils.CommandExists("pacman") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// pacman -Q exits non-zero when any name is missing but still lists the installed ones
|
||||||
|
out, _ := exec.Command("pacman", "-Q", "greetd-dms-greeter", "greetd-dms-greeter-bin", "greetd-dms-greeter-git").Output()
|
||||||
|
var installed []string
|
||||||
|
for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") {
|
||||||
|
if name, _, _ := strings.Cut(line, " "); name != "" {
|
||||||
|
installed = append(installed, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(installed) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []checkResult{{
|
||||||
|
catServices, "Greeter package", statusWarn,
|
||||||
|
"Retired package installed: " + strings.Join(installed, ", "),
|
||||||
|
greeterLuaHint(),
|
||||||
|
doctorDocsURL + "#services",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
// Chromium, Electron and libadwaita follow light/dark through the settings
|
// Chromium, Electron and libadwaita follow light/dark through the settings
|
||||||
// portal; without it they fall back to GTK theme colours that never update.
|
// portal; without it they fall back to GTK theme colours that never update.
|
||||||
func checkSettingsPortal() []checkResult {
|
func checkSettingsPortal() []checkResult {
|
||||||
@@ -1712,6 +1928,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 +1965,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 +2040,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 +2049,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 +2095,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 +2114,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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,24 +433,66 @@ func TestHyprlandLacksLua(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckHyprlandConfigFormat(t *testing.T) {
|
func TestCheckHyprlandQuickshellFloor(t *testing.T) {
|
||||||
for name, tc := range map[string]struct {
|
for _, tc := range []struct {
|
||||||
files []string
|
hypr, qs string
|
||||||
warn bool
|
warn bool
|
||||||
}{
|
}{
|
||||||
"lua only": {[]string{"hyprland.lua"}, false},
|
{"0.57.0", "0.3.1", true},
|
||||||
"lua wins": {[]string{"hyprland.lua", "hyprland.conf"}, false},
|
{"0.57.0", "0.3.2", false},
|
||||||
"conf only": {[]string{"hyprland.conf"}, true},
|
{"0.56.1", "0.2.1", false},
|
||||||
"neither": {nil, false},
|
{"0.57.0", "", false},
|
||||||
} {
|
} {
|
||||||
|
if got := checkHyprlandQuickshellFloor(tc.hypr, tc.qs); (len(got) == 1 && got[0].status == statusWarn) != tc.warn || (!tc.warn && len(got) != 0) {
|
||||||
|
t.Errorf("hyprland %q quickshell %q: got %+v, want warn=%v", tc.hypr, tc.qs, got, tc.warn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHyprlandDeprecatedConfigResults(t *testing.T) {
|
||||||
|
got := hyprlandDeprecatedConfigResults([]byte(`["general:no_border_on_floating", "decoration:shadow:range"]`))
|
||||||
|
if len(got) != 1 || got[0].status != statusWarn || !strings.Contains(got[0].details, "decoration:shadow:range") {
|
||||||
|
t.Fatalf("got %+v, want one warning listing both keys", got)
|
||||||
|
}
|
||||||
|
for _, out := range []string{"[]", "unknown request"} {
|
||||||
|
if got := hyprlandDeprecatedConfigResults([]byte(out)); len(got) != 0 {
|
||||||
|
t.Errorf("%q: got %+v, want nothing", out, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckGreetdSession(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
for _, f := range tc.files {
|
wrapper := filepath.Join(dir, "dms-greeter")
|
||||||
if err := os.WriteFile(filepath.Join(dir, f), nil, 0o644); err != nil {
|
if err := os.WriteFile(wrapper, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
hyprlang := filepath.Join(dir, "dms-hypr.conf")
|
||||||
|
if err := os.WriteFile(hyprlang, []byte("# see hl.dsp docs\nexec-once = waybar\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
got := checkHyprlandConfigFormat(dir)
|
luaConf := filepath.Join(dir, "lua-hypr.conf")
|
||||||
if warned := len(got) == 1 && got[0].status == statusWarn; warned != tc.warn || (!tc.warn && len(got) != 0) {
|
if err := os.WriteFile(luaConf, []byte("hl.config({ misc = { disable_hyprland_logo = true } })\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
command string
|
||||||
|
warn bool
|
||||||
|
}{
|
||||||
|
"pre-1.6 wrapper": {wrapper + " --command niri", true},
|
||||||
|
"hyprlang greeter config": {"dux-greeter --command hyprland -C " + hyprlang, true},
|
||||||
|
"hyprlang via --config=": {"dux-greeter --command hyprland --config='" + hyprlang + "'", true},
|
||||||
|
"lua content in a .conf": {"dux-greeter --command hyprland -C " + luaConf, false},
|
||||||
|
"lua greeter config": {"dux-greeter --command hyprland -C /etc/greetd/dms-hypr.lua", false},
|
||||||
|
"unreadable greeter config": {"dux-greeter --command hyprland -C " + filepath.Join(dir, "missing.conf"), false},
|
||||||
|
"niri greeter": {"dux-greeter --command niri -C /etc/greetd/niri.kdl", false},
|
||||||
|
} {
|
||||||
|
config := filepath.Join(t.TempDir(), "config.toml")
|
||||||
|
toml := "[terminal]\nvt = 1\n\n[default_session]\ncommand = \"" + tc.command + "\"\nuser = \"greeter\"\n"
|
||||||
|
if err := os.WriteFile(config, []byte(toml), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := checkGreetdSession(config); (len(got) == 1 && got[0].status == statusWarn) != tc.warn || (!tc.warn && len(got) != 0) {
|
||||||
t.Errorf("%s: got %+v, want warn=%v", name, got, tc.warn)
|
t.Errorf("%s: got %+v, want warn=%v", name, got, tc.warn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,22 +14,26 @@ import (
|
|||||||
var greeterCmd = &cobra.Command{
|
var greeterCmd = &cobra.Command{
|
||||||
Use: "greeter",
|
Use: "greeter",
|
||||||
Short: "Deprecated: moved to the standalone dms-greeter binary",
|
Short: "Deprecated: moved to the standalone dms-greeter binary",
|
||||||
Long: "The greeter has moved to the standalone dms-greeter package.\nThis command forwards to 'dms-greeter' when it is installed.",
|
Long: "The greeter has moved to the standalone dux-greeter package.\nThis command forwards to 'dms-greeter' when it is installed.",
|
||||||
DisableFlagParsing: true,
|
DisableFlagParsing: true,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
RunE: func(_ *cobra.Command, args []string) error {
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
binary, err := exec.LookPath("dms-greeter")
|
binary, err := exec.LookPath("dms-greeter")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("'dms greeter' has moved to the standalone dms-greeter package.\n %s", greeterPackageInstallHint())
|
return fmt.Errorf("'dms greeter' has moved to the standalone dux-greeter package.\n %s", greeterPackageInstallHint())
|
||||||
}
|
}
|
||||||
if isLegacyWrapperScript(binary) {
|
if isLegacyWrapperScript(binary) {
|
||||||
return fmt.Errorf("'dms greeter' has moved to the standalone dms-greeter package; %s is the old wrapper script.\n %s", binary, greeterPackageInstallHint())
|
return fmt.Errorf("'dms greeter' has moved to the standalone dux-greeter package; %s is the old wrapper script.\n %s", binary, greeterPackageInstallHint())
|
||||||
}
|
}
|
||||||
fmt.Fprintln(os.Stderr, "warning: 'dms greeter' is deprecated; use 'dms-greeter' directly")
|
fmt.Fprintln(os.Stderr, "warning: 'dms greeter' is deprecated; use 'dms-greeter' directly")
|
||||||
return syscall.Exec(binary, append([]string{"dms-greeter"}, args...), os.Environ())
|
return syscall.Exec(binary, append([]string{"dms-greeter"}, args...), os.Environ())
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -46,11 +50,11 @@ func isLegacyWrapperScript(path string) bool {
|
|||||||
func greeterPackageInstallHint() string {
|
func greeterPackageInstallHint() string {
|
||||||
osInfo, err := distros.GetOSInfo()
|
osInfo, err := distros.GetOSInfo()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "Install package: dms-greeter"
|
return "Install package: dux-greeter"
|
||||||
}
|
}
|
||||||
config, exists := distros.Registry[osInfo.Distribution.ID]
|
config, exists := distros.Registry[osInfo.Distribution.ID]
|
||||||
if !exists {
|
if !exists {
|
||||||
return "Install package: dms-greeter"
|
return "Install package: dux-greeter"
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.ID == "pika" {
|
if config.ID == "pika" {
|
||||||
@@ -59,18 +63,19 @@ func greeterPackageInstallHint() string {
|
|||||||
|
|
||||||
switch config.Family {
|
switch config.Family {
|
||||||
case distros.FamilyDebian:
|
case distros.FamilyDebian:
|
||||||
return "Install with 'sudo apt install dms-greeter' (requires Dux Labs OBS repo — see " + site.Docs + "/dms-greeter/installation#debian)"
|
return "Install with 'sudo apt install dux-greeter' (requires Dux Labs OBS repo — see " + site.Docs + "/dms-greeter/installation#debian)"
|
||||||
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 dux-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 dux-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 dux-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 dux-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 dux-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",
|
||||||
@@ -563,10 +563,7 @@ func compositorPrimaryPaths(wm deps.WindowManager) []string {
|
|||||||
case deps.WindowManagerNiri:
|
case deps.WindowManagerNiri:
|
||||||
return []string{filepath.Join(homeDir, ".config", "niri", "config.kdl")}
|
return []string{filepath.Join(homeDir, ".config", "niri", "config.kdl")}
|
||||||
case deps.WindowManagerHyprland:
|
case deps.WindowManagerHyprland:
|
||||||
return []string{
|
return []string{filepath.Join(config.HyprlandConfigDir(), "hyprland.lua")}
|
||||||
filepath.Join(homeDir, ".config", "hypr", "hyprland.lua"),
|
|
||||||
filepath.Join(homeDir, ".config", "hypr", "hyprland.conf"),
|
|
||||||
}
|
|
||||||
case deps.WindowManagerMango:
|
case deps.WindowManagerMango:
|
||||||
return []string{
|
return []string{
|
||||||
filepath.Join(homeDir, ".config", "mango", "config.conf"),
|
filepath.Join(homeDir, ".config", "mango", "config.conf"),
|
||||||
|
|||||||
@@ -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,20 @@ 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/config"
|
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
|
||||||
"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{
|
||||||
@@ -54,7 +57,6 @@ func (b *dmsBackend) Close() {
|
|||||||
func (b *dmsBackend) Done() <-chan error { return b.done }
|
func (b *dmsBackend) Done() <-chan error { return b.done }
|
||||||
|
|
||||||
func bootBackend(ctx context.Context) (shellapp.Backend, error) {
|
func bootBackend(ctx context.Context) (shellapp.Backend, error) {
|
||||||
config.CleanupStrayHyprlandConfFile(log.Infof)
|
|
||||||
server.CLIVersion = Version
|
server.CLIVersion = Version
|
||||||
|
|
||||||
srv := server.New()
|
srv := server.New()
|
||||||
@@ -83,18 +85,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 {
|
||||||
+4
-4
@@ -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,12 +9,13 @@ 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
|
||||||
github.com/godbus/dbus/v5 v5.2.4
|
github.com/godbus/dbus/v5 v5.2.4
|
||||||
github.com/holoplot/go-evdev v0.0.0-20260909133534-d873ed210bda
|
github.com/holoplot/go-evdev v0.0.0-20260909133534-d873ed210bda
|
||||||
|
github.com/kevinburke/ssh_config v1.6.0
|
||||||
github.com/klauspost/compress v1.20.3
|
github.com/klauspost/compress v1.20.3
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.1
|
github.com/lucasb-eyer/go-colorful v1.4.1
|
||||||
github.com/mattn/go-isatty v0.0.24
|
github.com/mattn/go-isatty v0.0.24
|
||||||
@@ -31,6 +32,7 @@ require (
|
|||||||
github.com/yuin/goldmark v1.8.6
|
github.com/yuin/goldmark v1.8.6
|
||||||
go.etcd.io/bbolt v1.5.0
|
go.etcd.io/bbolt v1.5.0
|
||||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745
|
go4.org/mem v0.0.0-20240501181205-ae6ca9944745
|
||||||
|
golang.org/x/crypto v0.58.0
|
||||||
golang.org/x/image v0.47.0
|
golang.org/x/image v0.47.0
|
||||||
golang.org/x/sys v0.49.0
|
golang.org/x/sys v0.49.0
|
||||||
golang.org/x/text v0.43.0
|
golang.org/x/text v0.43.0
|
||||||
@@ -71,7 +73,6 @@ require (
|
|||||||
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
|
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
|
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
|
||||||
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
|
||||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
|
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 // indirect
|
github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.31 // indirect
|
github.com/mattn/go-runewidth v0.0.31 // indirect
|
||||||
@@ -97,7 +98,6 @@ require (
|
|||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect
|
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect
|
||||||
golang.org/x/crypto v0.58.0 // indirect
|
|
||||||
golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect
|
golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect
|
||||||
golang.org/x/net v0.61.0 // indirect
|
golang.org/x/net v0.61.0 // indirect
|
||||||
golang.org/x/sync v0.24.0 // indirect
|
golang.org/x/sync v0.24.0 // indirect
|
||||||
|
|||||||
+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=
|
||||||
|
|||||||
+21
-5
@@ -35,15 +35,25 @@ aarch64)
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Get the latest release version
|
REPO_URL="https://github.com/duxlabs-dev/dux-shell"
|
||||||
LATEST_VERSION=$(curl -sL https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
|
||||||
|
# The web redirect has no per-ip quota; api.github.com allows 60 unauthenticated requests per hour per ip
|
||||||
|
LATEST_VERSION=$(curl -sI "$REPO_URL/releases/latest" | tr -d '\r' | awk 'tolower($1) == "location:" { sub(".*/releases/tag/", "", $2); print $2 }')
|
||||||
|
|
||||||
|
if [ -z "$LATEST_VERSION" ]; then
|
||||||
|
API_RESPONSE=$(curl -sL https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest)
|
||||||
|
LATEST_VERSION=$(printf "%s" "$API_RESPONSE" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -z "$LATEST_VERSION" ]; then
|
if [ -z "$LATEST_VERSION" ]; then
|
||||||
printf "%bError: Could not fetch latest version%b\n" "$RED" "$NC"
|
printf "%bError: Could not fetch latest version%b\n" "$RED" "$NC"
|
||||||
|
if printf "%s" "$API_RESPONSE" | grep -q "rate limit"; then
|
||||||
|
printf "GitHub API rate limit reached for your IP address (60 requests per hour). Wait an hour or install from a different network.\n"
|
||||||
|
fi
|
||||||
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 +62,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="$REPO_URL/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
|
||||||
|
}
|
||||||
|
if string(value) == "null" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
settings[key] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return disabled, nil
|
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"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wlr_screencopy"
|
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wlr_screencopy"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wp_color_management"
|
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wp_color_management"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wp_viewporter"
|
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wp_viewporter"
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/screenshot"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
||||||
wlhelpers "github.com/duxlabs-dev/dux-shell/core/internal/wayland/client"
|
wlhelpers "github.com/duxlabs-dev/dux-shell/core/internal/wayland/client"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/wayland/keymap"
|
"github.com/duxlabs-dev/dux-shell/core/internal/wayland/keymap"
|
||||||
@@ -535,9 +536,8 @@ func (p *Picker) captureForSurface(ls *LayerSurface) {
|
|||||||
ls.state.OnScreencopyReady()
|
ls.state.OnScreencopyReady()
|
||||||
|
|
||||||
screenBuf := ls.state.ScreenBuffer()
|
screenBuf := ls.state.ScreenBuffer()
|
||||||
if screenBuf != nil && ls.output.transform != TransformNormal {
|
if screenBuf != nil {
|
||||||
invTransform := InverseTransform(ls.output.transform)
|
transformed, err := ls.output.untransformFrame(screenBuf)
|
||||||
transformed, err := screenBuf.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 != screenBuf {
|
} else if transformed != screenBuf {
|
||||||
@@ -562,6 +562,13 @@ func (p *Picker) captureForSurface(ls *LayerSurface) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o *Output) untransformFrame(buf *ShmBuffer) (*ShmBuffer, error) {
|
||||||
|
if screenshot.FrameInLogicalOrientation(o.transform, o.width, o.height, buf.Width, buf.Height) {
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
return buf.ApplyTransform(InverseTransform(o.transform))
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Picker) redrawSurface(ls *LayerSurface) {
|
func (p *Picker) redrawSurface(ls *LayerSurface) {
|
||||||
slot := ls.state.FrontIndex()
|
slot := ls.state.FrontIndex()
|
||||||
if ls.slotBusy[slot] || ls.framePending {
|
if ls.slotBusy[slot] || ls.framePending {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package colorpicker
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestUntransformFrameLeavesAnUprightFrame(t *testing.T) {
|
||||||
|
buf, err := CreateShmBuffer(1080, 1920, 1080*4)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer buf.Close()
|
||||||
|
|
||||||
|
output := &Output{width: 1920, height: 1080, transform: Transform90}
|
||||||
|
got, err := output.untransformFrame(buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != buf {
|
||||||
|
defer got.Close()
|
||||||
|
t.Fatalf("rotated a frame already in logical orientation to %dx%d", got.Width, got.Height)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,9 +70,7 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(_ context.Context, wm dep
|
|||||||
filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"),
|
filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"),
|
||||||
},
|
},
|
||||||
"Hyprland": {
|
"Hyprland": {
|
||||||
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"),
|
filepath.Join(HyprlandConfigDir(), "hyprland.lua"),
|
||||||
// A conf-only install still counts: deploying moves it into backups.
|
|
||||||
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
|
|
||||||
},
|
},
|
||||||
"Mango": {
|
"Mango": {
|
||||||
filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf"),
|
filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf"),
|
||||||
@@ -262,7 +260,8 @@ func (cd *ConfigDeployer) deployNiriConfig(terminalCommand string, useSystemd bo
|
|||||||
|
|
||||||
newConfig := strings.ReplaceAll(NiriConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
|
newConfig := strings.ReplaceAll(NiriConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
|
||||||
|
|
||||||
if !cd.systemdAutostart(deps.WindowManagerNiri, useSystemd) {
|
autostart := cd.systemdAutostart(deps.WindowManagerNiri, useSystemd)
|
||||||
|
if !autostart {
|
||||||
newConfig = cd.transformNiriConfigForNonSystemd(newConfig, terminalCommand)
|
newConfig = cd.transformNiriConfigForNonSystemd(newConfig, terminalCommand)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,14 +275,19 @@ func (cd *ConfigDeployer) deployNiriConfig(terminalCommand string, useSystemd bo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// niri reloads config.kdl as soon as it changes, so its includes must already exist.
|
||||||
|
if err := cd.deployNiriDmsConfigs(dmsDir, terminalCommand); err != nil {
|
||||||
|
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
|
||||||
|
return result, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(result.Path, []byte(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.deployNiriDmsConfigs(dmsDir, terminalCommand); err != nil {
|
if autostart {
|
||||||
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
|
cd.startInLiveSession(deps.WindowManagerNiri)
|
||||||
return result, result.Error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Deployed = true
|
result.Deployed = true
|
||||||
@@ -604,11 +608,10 @@ func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig, dms
|
|||||||
return builder.String(), nil
|
return builder.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// deployHyprlandConfig handles Hyprland configuration deployment with backup and merging
|
|
||||||
func (cd *ConfigDeployer) deployHyprlandConfig(terminalCommand string, useSystemd bool) (DeploymentResult, error) {
|
func (cd *ConfigDeployer) deployHyprlandConfig(terminalCommand string, useSystemd bool) (DeploymentResult, error) {
|
||||||
result := DeploymentResult{
|
result := DeploymentResult{
|
||||||
ConfigType: "Hyprland",
|
ConfigType: "Hyprland",
|
||||||
Path: filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"),
|
Path: filepath.Join(HyprlandConfigDir(), "hyprland.lua"),
|
||||||
}
|
}
|
||||||
if err := hyprlandLuaUnsupported(cd.hyprlandVersion); err != nil {
|
if err := hyprlandLuaUnsupported(cd.hyprlandVersion); err != nil {
|
||||||
result.Error = err
|
result.Error = err
|
||||||
@@ -627,22 +630,14 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminalCommand string, useSystem
|
|||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
existing, err := os.ReadFile(result.Path)
|
||||||
backupDir := filepath.Join(configDir, hyprlandBackupDirName, timestamp)
|
if err != nil && !os.IsNotExist(err) {
|
||||||
var existingConfig string
|
|
||||||
existingData, existingPath, err := readExistingHyprlandConfig(configDir)
|
|
||||||
if err != nil {
|
|
||||||
result.Error = err
|
result.Error = err
|
||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
if existingData != "" {
|
if err == nil {
|
||||||
existingConfig = existingData
|
backupDir := filepath.Join(configDir, hyprlandBackupDirName, time.Now().Format("2006-01-02_15-04-05"))
|
||||||
cd.log(fmt.Sprintf("Found existing Hyprland configuration at %s", existingPath))
|
result.BackupPath, err = backupHyprlandConfigFile(filepath.Join(backupDir, "hyprland.lua"), existing)
|
||||||
}
|
|
||||||
// A legacy hyprland.conf is only backed up once hyprland.lua is in place; Hyprland
|
|
||||||
// watches the live config and regenerates a stub one if it disappears first.
|
|
||||||
if existingData != "" && !strings.EqualFold(filepath.Ext(existingPath), ".conf") {
|
|
||||||
result.BackupPath, err = backupHyprlandConfigFile(filepath.Join(backupDir, filepath.Base(existingPath)), []byte(existingData))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Error = fmt.Errorf("failed to create backup: %w", err)
|
result.Error = fmt.Errorf("failed to create backup: %w", err)
|
||||||
return result, result.Error
|
return result, result.Error
|
||||||
@@ -651,19 +646,15 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminalCommand string, useSystem
|
|||||||
}
|
}
|
||||||
|
|
||||||
newConfig := strings.ReplaceAll(HyprlandLuaConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
|
newConfig := strings.ReplaceAll(HyprlandLuaConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
|
||||||
|
autostart := cd.systemdAutostart(deps.WindowManagerHyprland, useSystemd)
|
||||||
if !cd.systemdAutostart(deps.WindowManagerHyprland, useSystemd) {
|
if !autostart {
|
||||||
newConfig = transformHyprlandLuaForNonSystemd(newConfig, terminalCommand)
|
newConfig = transformHyprlandLuaForNonSystemd(newConfig, terminalCommand)
|
||||||
}
|
}
|
||||||
|
|
||||||
if existingConfig != "" {
|
// Hyprland reloads hyprland.lua as soon as it changes, so its required fragments must already exist.
|
||||||
mergedConfig, err := cd.mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir)
|
if err := cd.deployHyprlandDmsConfigs(dmsDir, terminalCommand); err != nil {
|
||||||
if err != nil {
|
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
|
||||||
cd.log(fmt.Sprintf("Warning: Failed to merge monitor sections: %v", err))
|
return result, result.Error
|
||||||
} else {
|
|
||||||
newConfig = mergedConfig
|
|
||||||
cd.log("Successfully merged existing monitor sections")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil {
|
if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil {
|
||||||
@@ -671,30 +662,9 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminalCommand string, useSystem
|
|||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
movedLegacy, mainConfBackup, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
|
if autostart {
|
||||||
if err != nil {
|
cd.startInLiveSession(deps.WindowManagerHyprland)
|
||||||
result.Error = fmt.Errorf("failed to back up legacy hyprlang configs: %w", err)
|
|
||||||
return result, result.Error
|
|
||||||
}
|
}
|
||||||
if mainConfBackup != "" && result.BackupPath == "" {
|
|
||||||
result.BackupPath = mainConfBackup
|
|
||||||
cd.log(fmt.Sprintf("Backed up existing config to %s", mainConfBackup))
|
|
||||||
}
|
|
||||||
if movedLegacy > 0 {
|
|
||||||
if result.BackupPath == "" {
|
|
||||||
result.BackupPath = backupDir
|
|
||||||
}
|
|
||||||
cd.log(fmt.Sprintf("Moved %d legacy hyprlang config(s) to %s", movedLegacy, backupDir))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cd.deployHyprlandDmsConfigs(dmsDir, terminalCommand); err != nil {
|
|
||||||
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
|
|
||||||
return result, result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
CleanupStrayHyprlandConfFile(func(format string, v ...any) {
|
|
||||||
cd.log(fmt.Sprintf(format, v...))
|
|
||||||
})
|
|
||||||
|
|
||||||
result.Deployed = true
|
result.Deployed = true
|
||||||
cd.log("Successfully deployed Hyprland configuration")
|
cd.log("Successfully deployed Hyprland configuration")
|
||||||
@@ -725,80 +695,6 @@ func backupHyprlandConfigFile(dst string, data []byte) (string, error) {
|
|||||||
return dst, nil
|
return dst, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir string) (int, string, error) {
|
|
||||||
mainConf := filepath.Join(configDir, "hyprland.conf")
|
|
||||||
legacyPaths := []string{mainConf}
|
|
||||||
dmsConfPaths, err := filepath.Glob(filepath.Join(dmsDir, "*.conf"))
|
|
||||||
if err != nil {
|
|
||||||
return 0, "", err
|
|
||||||
}
|
|
||||||
legacyPaths = append(legacyPaths, dmsConfPaths...)
|
|
||||||
backupPaths, err := adjacentHyprlandBackupFiles(configDir, dmsDir)
|
|
||||||
if err != nil {
|
|
||||||
return 0, "", err
|
|
||||||
}
|
|
||||||
legacyPaths = append(legacyPaths, backupPaths...)
|
|
||||||
|
|
||||||
moved := 0
|
|
||||||
mainConfBackup := ""
|
|
||||||
for _, src := range legacyPaths {
|
|
||||||
info, err := os.Lstat(src)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return moved, mainConfBackup, err
|
|
||||||
}
|
|
||||||
if info.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
rel, err := filepath.Rel(configDir, src)
|
|
||||||
if err != nil {
|
|
||||||
rel = filepath.Base(src)
|
|
||||||
}
|
|
||||||
dst, err := moveHyprlandConfigFile(src, filepath.Join(backupDir, rel))
|
|
||||||
if err != nil {
|
|
||||||
return moved, mainConfBackup, err
|
|
||||||
}
|
|
||||||
if src == mainConf {
|
|
||||||
mainConfBackup = dst
|
|
||||||
}
|
|
||||||
moved++
|
|
||||||
}
|
|
||||||
|
|
||||||
return moved, mainConfBackup, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func moveHyprlandConfigFile(src, dst string) (string, error) {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
dst = uniqueBackupPath(dst)
|
|
||||||
if err := os.Rename(src, dst); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return dst, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func adjacentHyprlandBackupFiles(configDir, dmsDir string) ([]string, error) {
|
|
||||||
var paths []string
|
|
||||||
patterns := []string{
|
|
||||||
filepath.Join(configDir, "hyprland.conf.backup.*"),
|
|
||||||
filepath.Join(configDir, "hyprland.lua.backup.*"),
|
|
||||||
filepath.Join(dmsDir, "*.conf.backup.*"),
|
|
||||||
filepath.Join(dmsDir, "*.lua.backup.*"),
|
|
||||||
}
|
|
||||||
for _, pattern := range patterns {
|
|
||||||
matches, err := filepath.Glob(pattern)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
paths = append(paths, matches...)
|
|
||||||
}
|
|
||||||
return paths, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalCommand string) error {
|
func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalCommand string) error {
|
||||||
configs := []struct {
|
configs := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -838,45 +734,6 @@ func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalComman
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir string) (string, error) {
|
|
||||||
_ = newConfig
|
|
||||||
lines := extractHyprlangMonitorLines(existingConfig)
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return newConfig, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
outputsPath := filepath.Join(dmsDir, "outputs.lua")
|
|
||||||
if info, err := os.Stat(outputsPath); err == nil && info.Size() > 0 {
|
|
||||||
cd.log("Skipping monitor migration: dms/outputs.lua already exists")
|
|
||||||
return newConfig, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("-- Migrated from existing hyprlang monitor lines\n\n")
|
|
||||||
ok := 0
|
|
||||||
for _, line := range lines {
|
|
||||||
lua, err := hyprlangMonitorLineToLua(line)
|
|
||||||
if err != nil {
|
|
||||||
cd.log(fmt.Sprintf("Warning: could not migrate monitor line %q: %v", line, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
b.WriteString(lua)
|
|
||||||
b.WriteByte('\n')
|
|
||||||
ok++
|
|
||||||
}
|
|
||||||
if ok == 0 {
|
|
||||||
return newConfig, nil
|
|
||||||
}
|
|
||||||
b.WriteByte('\n')
|
|
||||||
b.WriteString("-- Default fallback\n")
|
|
||||||
b.WriteString("hl.monitor({ output = \"\", mode = \"preferred\", position = \"auto\", scale = \"auto\" })\n")
|
|
||||||
if err := os.WriteFile(outputsPath, []byte(b.String()), 0o644); err != nil {
|
|
||||||
return newConfig, err
|
|
||||||
}
|
|
||||||
cd.log("Migrated monitor sections to dms/outputs.lua")
|
|
||||||
return newConfig, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalCommand string) string {
|
func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalCommand string) string {
|
||||||
envVars := fmt.Sprintf(`environment {
|
envVars := fmt.Sprintf(`environment {
|
||||||
XDG_CURRENT_DESKTOP "niri"
|
XDG_CURRENT_DESKTOP "niri"
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import (
|
|||||||
// Tests never reach the user's systemd; stubAddWants counts the add-wants calls a deploy makes.
|
// Tests never reach the user's systemd; stubAddWants counts the add-wants calls a deploy makes.
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
systemctlUser = func(...string) error { return nil }
|
systemctlUser = func(...string) error { return nil }
|
||||||
|
// Hyprland paths follow XDG_CONFIG_HOME; tests pin HOME and must not write a real config.
|
||||||
|
os.Unsetenv("XDG_CONFIG_HOME")
|
||||||
os.Exit(m.Run())
|
os.Exit(m.Run())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,55 +36,6 @@ func stubAddWants(t *testing.T, addWants error) *int {
|
|||||||
return &calls
|
return &calls
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanupStrayHyprlandConfFile(t *testing.T) {
|
|
||||||
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
|
|
||||||
t.Setenv("HYPRLAND_INSTANCE_SIGNATURE", "test-signature")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("leaves conf alone when no hyprland.lua present", func(t *testing.T) {
|
|
||||||
td := t.TempDir()
|
|
||||||
t.Setenv("HOME", td)
|
|
||||||
configDir := filepath.Join(td, ".config", "hypr")
|
|
||||||
dmsDir := filepath.Join(configDir, "dms")
|
|
||||||
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
|
|
||||||
confPath := filepath.Join(configDir, "hyprland.conf")
|
|
||||||
dmsConfPath := filepath.Join(dmsDir, "colors.conf")
|
|
||||||
require.NoError(t, os.WriteFile(confPath, []byte("# legacy user config\n"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(dmsConfPath, []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
|
|
||||||
|
|
||||||
CleanupStrayHyprlandConfFile(nil)
|
|
||||||
|
|
||||||
assert.FileExists(t, confPath, "must not touch hyprland.conf when user has not migrated")
|
|
||||||
assert.FileExists(t, dmsConfPath, "must not touch dms/*.conf when user has not migrated")
|
|
||||||
assert.NoDirExists(t, filepath.Join(configDir, hyprlandBackupDirName))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("moves stray conf into backup when hyprland.lua exists", func(t *testing.T) {
|
|
||||||
td := t.TempDir()
|
|
||||||
t.Setenv("HOME", td)
|
|
||||||
configDir := filepath.Join(td, ".config", "hypr")
|
|
||||||
dmsDir := filepath.Join(configDir, "dms")
|
|
||||||
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
|
|
||||||
luaPath := filepath.Join(configDir, "hyprland.lua")
|
|
||||||
require.NoError(t, os.WriteFile(luaPath, []byte("-- dms managed\n"), 0o644))
|
|
||||||
confPath := filepath.Join(configDir, "hyprland.conf")
|
|
||||||
dmsConfPath := filepath.Join(dmsDir, "colors.conf")
|
|
||||||
require.NoError(t, os.WriteFile(confPath, []byte("# autogen\n"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(dmsConfPath, []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
|
|
||||||
|
|
||||||
CleanupStrayHyprlandConfFile(nil)
|
|
||||||
|
|
||||||
assert.NoFileExists(t, confPath)
|
|
||||||
assert.NoFileExists(t, dmsConfPath)
|
|
||||||
assert.FileExists(t, luaPath)
|
|
||||||
entries, err := os.ReadDir(filepath.Join(configDir, hyprlandBackupDirName))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, entries, 1)
|
|
||||||
assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "hyprland.conf"))
|
|
||||||
assert.FileExists(t, filepath.Join(configDir, hyprlandBackupDirName, entries[0].Name(), "dms", "colors.conf"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeNiriOutputSections(t *testing.T) {
|
func TestMergeNiriOutputSections(t *testing.T) {
|
||||||
cd := &ConfigDeployer{}
|
cd := &ConfigDeployer{}
|
||||||
|
|
||||||
@@ -182,7 +135,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)
|
||||||
|
|
||||||
@@ -256,63 +209,8 @@ func getGhosttyPath() string {
|
|||||||
return filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config")
|
return filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMergeHyprlandMonitorSections(t *testing.T) {
|
|
||||||
cd := &ConfigDeployer{}
|
|
||||||
|
|
||||||
t.Run("no monitors in existing", func(t *testing.T) {
|
|
||||||
tmp := t.TempDir()
|
|
||||||
out, err := cd.mergeHyprlandMonitorSections(`hl.config({})`, `input { kb_layout = us }`, tmp)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, `hl.config({})`, out)
|
|
||||||
_, e := os.Stat(filepath.Join(tmp, "outputs.lua"))
|
|
||||||
assert.True(t, os.IsNotExist(e))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("writes outputs lua from hyprlang monitors", func(t *testing.T) {
|
|
||||||
tmp := t.TempDir()
|
|
||||||
existing := `monitor = DP-1, 1920x1080@144, 0x0, 1
|
|
||||||
# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1
|
|
||||||
monitor = eDP-1, 2560x1440@165, auto, 1.25`
|
|
||||||
out, err := cd.mergeHyprlandMonitorSections(`return`, existing, tmp)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, `return`, out)
|
|
||||||
b, err := os.ReadFile(filepath.Join(tmp, "outputs.lua"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
s := string(b)
|
|
||||||
assert.Contains(t, s, "hl.monitor")
|
|
||||||
assert.Contains(t, s, "DP-1")
|
|
||||||
assert.Contains(t, s, "HDMI-A-1")
|
|
||||||
assert.Contains(t, s, "eDP-1")
|
|
||||||
assert.Contains(t, s, "preferred") // fallback rule at end
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("skips when outputs lua already exists", func(t *testing.T) {
|
|
||||||
tmp := t.TempDir()
|
|
||||||
path := filepath.Join(tmp, "outputs.lua")
|
|
||||||
require.NoError(t, os.WriteFile(path, []byte("-- keep\n"), 0o644))
|
|
||||||
_, err := cd.mergeHyprlandMonitorSections(`x`, `monitor = DP-1, 1920x1080@144, 0x0, 1`, tmp)
|
|
||||||
require.NoError(t, err)
|
|
||||||
b, err := os.ReadFile(path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "-- keep\n", string(b))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHyprlangMonitorLineToLuaPreservesOptions(t *testing.T) {
|
|
||||||
got, err := hyprlangMonitorLineToLua(`monitor = DP-1, 1920x1080@144, 0x0, 1, transform, 1, vrr, 2, bitdepth, 10, cm, hdr, sdrbrightness, 1.2, sdrsaturation, 0.98`)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Contains(t, got, `output = "DP-1"`)
|
|
||||||
assert.Contains(t, got, `transform = 1`)
|
|
||||||
assert.Contains(t, got, `vrr = 2`)
|
|
||||||
assert.Contains(t, got, `bitdepth = 10`)
|
|
||||||
assert.Contains(t, got, `cm = "hdr"`)
|
|
||||||
assert.Contains(t, got, `sdrbrightness = 1.2`)
|
|
||||||
assert.Contains(t, got, `sdrsaturation = 0.98`)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 +222,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)
|
||||||
@@ -343,95 +241,31 @@ func TestHyprlandConfigDeployment(t *testing.T) {
|
|||||||
assert.Contains(t, string(content), "hl.config(")
|
assert.Contains(t, string(content), "hl.config(")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("deploy hyprland config with existing monitors", func(t *testing.T) {
|
t.Run("deploy hyprland config backs up the existing hyprland.lua", func(t *testing.T) {
|
||||||
td, err := os.MkdirTemp("", "dankinstall-hyprland-merge")
|
td, err := os.MkdirTemp("", "duxinstall-hyprland-backup")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(td)
|
defer os.RemoveAll(td)
|
||||||
os.Setenv("HOME", td)
|
os.Setenv("HOME", td)
|
||||||
existingContent := `# My existing Hyprland config
|
existing := "hl.config({ autogenerated = true })\n"
|
||||||
monitor = DP-1, 1920x1080@144, 0x0, 1
|
luaPath := filepath.Join(td, ".config", "hypr", "hyprland.lua")
|
||||||
monitor = HDMI-A-1, 3840x2160@60, 1920x0, 1.5
|
require.NoError(t, os.MkdirAll(filepath.Dir(luaPath), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(luaPath, []byte(existing), 0o644))
|
||||||
general {
|
|
||||||
gaps_in = 10
|
|
||||||
}
|
|
||||||
`
|
|
||||||
hyprPath := filepath.Join(td, ".config", "hypr", "hyprland.conf")
|
|
||||||
err = os.MkdirAll(filepath.Dir(hyprPath), 0o755)
|
|
||||||
require.NoError(t, err)
|
|
||||||
err = os.WriteFile(hyprPath, []byte(existingContent), 0o644)
|
|
||||||
require.NoError(t, err)
|
|
||||||
dmsDir := filepath.Join(td, ".config", "hypr", "dms")
|
|
||||||
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf"), []byte("bind = SUPER, T, exec, foot\n"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "colors.conf"), []byte("$primary = rgba(d0bcffFF)\n"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "cursor.conf"), []byte("env = XCURSOR_SIZE,24\n"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old"), []byte("old backup\n"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dmsDir, "binds.conf.backup.old"), []byte("old dms backup\n"), 0o644))
|
|
||||||
|
|
||||||
result, err := cd.deployHyprlandConfig("kitty", true)
|
result, err := cd.deployHyprlandConfig("kitty", true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, "Hyprland", result.ConfigType)
|
|
||||||
assert.True(t, result.Deployed)
|
|
||||||
assert.NotEmpty(t, result.BackupPath)
|
|
||||||
assert.FileExists(t, result.Path)
|
|
||||||
assert.FileExists(t, result.BackupPath)
|
|
||||||
|
|
||||||
backupContent, err := os.ReadFile(result.BackupPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, existingContent, string(backupContent))
|
|
||||||
assert.Contains(t, result.BackupPath, hyprlandBackupDirName)
|
assert.Contains(t, result.BackupPath, hyprlandBackupDirName)
|
||||||
assert.NoFileExists(t, hyprPath)
|
backup, err := os.ReadFile(result.BackupPath)
|
||||||
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf"))
|
|
||||||
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "colors.conf"))
|
|
||||||
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "cursor.conf"))
|
|
||||||
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf.backup.old"))
|
|
||||||
assert.FileExists(t, filepath.Join(filepath.Dir(result.BackupPath), "dms", "binds.conf.backup.old"))
|
|
||||||
assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf"))
|
|
||||||
assert.NoFileExists(t, filepath.Join(dmsDir, "colors.conf"))
|
|
||||||
assert.NoFileExists(t, filepath.Join(dmsDir, "cursor.conf"))
|
|
||||||
assert.NoFileExists(t, filepath.Join(filepath.Dir(hyprPath), "hyprland.conf.backup.old"))
|
|
||||||
assert.NoFileExists(t, filepath.Join(dmsDir, "binds.conf.backup.old"))
|
|
||||||
|
|
||||||
newContent, err := os.ReadFile(result.Path)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, string(newContent), `require("dms.binds")`)
|
assert.Equal(t, existing, string(backup))
|
||||||
|
content, err := os.ReadFile(result.Path)
|
||||||
outputsPath := filepath.Join(td, ".config", "hypr", "dms", "outputs.lua")
|
|
||||||
outBytes, err := os.ReadFile(outputsPath)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
outs := string(outBytes)
|
assert.Contains(t, string(content), `require("dms.binds")`)
|
||||||
assert.Contains(t, outs, `hl.monitor`)
|
assert.NotContains(t, string(content), "autogenerated")
|
||||||
assert.Contains(t, outs, "DP-1")
|
|
||||||
assert.Contains(t, outs, "HDMI-A-1")
|
|
||||||
})
|
|
||||||
|
|
||||||
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")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer os.RemoveAll(td)
|
|
||||||
os.Setenv("HOME", td)
|
|
||||||
|
|
||||||
configDir := filepath.Join(td, ".config", "hypr")
|
|
||||||
require.NoError(t, os.MkdirAll(configDir, 0o755))
|
|
||||||
luaPath := filepath.Join(configDir, "hyprland.lua")
|
|
||||||
confPath := filepath.Join(configDir, "hyprland.conf")
|
|
||||||
require.NoError(t, os.WriteFile(luaPath, []byte(`require("dms.binds")`+"\n"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(filepath.Join(configDir, "missing-legacy.conf"), confPath))
|
|
||||||
|
|
||||||
result, err := cd.deployHyprlandConfig("kitty", true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, luaPath, result.Path)
|
|
||||||
_, err = os.Lstat(confPath)
|
|
||||||
assert.True(t, os.IsNotExist(err), "root hyprland.conf symlink should be moved out of the live config directory")
|
|
||||||
_, err = os.Lstat(filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf"))
|
|
||||||
assert.NoError(t, err)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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 +291,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 +329,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 +404,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 +437,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 +470,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 +512,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)
|
||||||
|
|
||||||
@@ -724,66 +558,6 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
|
|||||||
assert.True(t, foundGhostty, "expected Ghostty config to be deployed when replaceConfigs is true")
|
assert.True(t, foundGhostty, "expected Ghostty config to be deployed when replaceConfigs is true")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("hyprland legacy config exists skips when replace false", func(t *testing.T) {
|
|
||||||
tempDir, err := os.MkdirTemp("", "dankinstall-hyprland-legacy-skip-test")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer os.RemoveAll(tempDir)
|
|
||||||
|
|
||||||
originalHome := os.Getenv("HOME")
|
|
||||||
os.Setenv("HOME", tempDir)
|
|
||||||
defer os.Setenv("HOME", originalHome)
|
|
||||||
|
|
||||||
hyprConf := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(hyprConf), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(hyprConf, []byte("monitor = , preferred, auto, 1\n"), 0o644))
|
|
||||||
|
|
||||||
logChan := make(chan string, 100)
|
|
||||||
cd := NewConfigDeployer(logChan)
|
|
||||||
results, err := cd.deployConfigurationsInternal(
|
|
||||||
context.Background(),
|
|
||||||
deps.WindowManagerHyprland,
|
|
||||||
deps.TerminalGhostty,
|
|
||||||
nil,
|
|
||||||
allFalse,
|
|
||||||
nil,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
for _, r := range results {
|
|
||||||
if r.ConfigType == "Hyprland" && r.Deployed {
|
|
||||||
t.Fatalf("expected Hyprland deployment to be skipped when legacy config exists and replace=false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBackupLegacyHyprlandConfFilesKeepsExistingBackups(t *testing.T) {
|
|
||||||
td := t.TempDir()
|
|
||||||
configDir := filepath.Join(td, ".config", "hypr")
|
|
||||||
dmsDir := filepath.Join(configDir, "dms")
|
|
||||||
require.NoError(t, os.MkdirAll(dmsDir, 0o755))
|
|
||||||
|
|
||||||
userConfig := "# the config the user actually wrote\nmonitor = DP-1, 1920x1080@144, 0x0, 1\n"
|
|
||||||
backupDir := filepath.Join(configDir, hyprlandBackupDirName, "2026-01-02_03-04-05")
|
|
||||||
require.NoError(t, os.MkdirAll(backupDir, 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(backupDir, "hyprland.conf"), []byte(userConfig), 0o644))
|
|
||||||
|
|
||||||
autogenerated := "autogenerated = 1\n$mainMod = SUPER\n"
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(configDir, "hyprland.conf"), []byte(autogenerated), 0o644))
|
|
||||||
|
|
||||||
moved, mainConfBackup, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, 1, moved)
|
|
||||||
assert.Equal(t, filepath.Join(backupDir, "hyprland.conf.1"), mainConfBackup)
|
|
||||||
|
|
||||||
kept, err := os.ReadFile(filepath.Join(backupDir, "hyprland.conf"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, userConfig, string(kept))
|
|
||||||
|
|
||||||
stray, err := os.ReadFile(mainConfBackup)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, autogenerated, string(stray))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBackupHyprlandConfigFileKeepsExistingBackups(t *testing.T) {
|
func TestBackupHyprlandConfigFileKeepsExistingBackups(t *testing.T) {
|
||||||
@@ -800,33 +574,6 @@ func TestBackupHyprlandConfigFileKeepsExistingBackups(t *testing.T) {
|
|||||||
assert.Equal(t, "-- first\n", string(first))
|
assert.Equal(t, "-- first\n", string(first))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDeployHyprlandConfigWritesLuaBeforeRemovingLegacyConf(t *testing.T) {
|
|
||||||
td := t.TempDir()
|
|
||||||
t.Setenv("HOME", td)
|
|
||||||
configDir := filepath.Join(td, ".config", "hypr")
|
|
||||||
require.NoError(t, os.MkdirAll(configDir, 0o755))
|
|
||||||
confPath := filepath.Join(configDir, "hyprland.conf")
|
|
||||||
userConfig := "# user config\nmonitor = DP-1, 1920x1080@144, 0x0, 1\n"
|
|
||||||
require.NoError(t, os.WriteFile(confPath, []byte(userConfig), 0o644))
|
|
||||||
|
|
||||||
logChan := make(chan string, 100)
|
|
||||||
cd := NewConfigDeployer(logChan)
|
|
||||||
go func() {
|
|
||||||
for range logChan {
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
defer close(logChan)
|
|
||||||
|
|
||||||
result, err := cd.deployHyprlandConfig("ghostty", true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.NoFileExists(t, confPath)
|
|
||||||
backup, err := os.ReadFile(result.BackupPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, userConfig, string(backup))
|
|
||||||
assert.Equal(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf"), result.BackupPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeployHyprlandConfigSystemdAutostart(t *testing.T) {
|
func TestDeployHyprlandConfigSystemdAutostart(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -862,6 +609,47 @@ func TestDeployHyprlandConfigSystemdAutostart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeployHyprlandStartsShellInLiveSession(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
instance string
|
||||||
|
unitActive bool
|
||||||
|
useSystemd bool
|
||||||
|
wantStarted bool
|
||||||
|
}{
|
||||||
|
{"live session", "sig", true, true, true},
|
||||||
|
{"session unit inactive", "sig", false, true, false},
|
||||||
|
{"other session", "", true, true, false},
|
||||||
|
{"no systemd", "sig", true, false, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
t.Setenv("HYPRLAND_INSTANCE_SIGNATURE", tc.instance)
|
||||||
|
t.Setenv("NIRI_SOCKET", "")
|
||||||
|
t.Setenv("MANGO_INSTANCE_SIGNATURE", "")
|
||||||
|
started := false
|
||||||
|
prev := systemctlUser
|
||||||
|
systemctlUser = func(args ...string) error {
|
||||||
|
switch args[0] {
|
||||||
|
case "is-active":
|
||||||
|
if !tc.unitActive {
|
||||||
|
return errors.New("inactive")
|
||||||
|
}
|
||||||
|
case "start":
|
||||||
|
started = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { systemctlUser = prev })
|
||||||
|
|
||||||
|
_, err := NewConfigDeployer(nil).deployHyprlandConfig("kitty", tc.useSystemd)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.wantStarted, started)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeployNiriConfigSystemdAutostart(t *testing.T) {
|
func TestDeployNiriConfigSystemdAutostart(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
-- Per-output monitor rules — embedded sibling of the legacy outputs.conf fragment. Deploy writes ~/.config/hypr/dms/outputs.lua
|
-- Per-output monitor rules. Deploy writes ~/.config/hypr/dms/outputs.lua
|
||||||
|
|
||||||
hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" })
|
hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" })
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
-- Hyprland configuration (Lua) — https://wiki.hypr.land/Configuring/Start/
|
-- Hyprland configuration (Lua) — https://wiki.hypr.land/configuring/
|
||||||
|
|
||||||
-- DMS_STARTUP_BEGIN
|
-- DMS_STARTUP_BEGIN
|
||||||
hl.on("hyprland.start", function()
|
hl.on("hyprland.start", function()
|
||||||
hl.exec_cmd("dbus-update-activation-environment --systemd --all")
|
hl.exec_cmd("dbus-update-activation-environment --systemd --all")
|
||||||
|
if not os.getenv("HYPRLAND_NO_SD_TARGET") then
|
||||||
hl.exec_cmd("systemctl --user start hyprland-session.target")
|
hl.exec_cmd("systemctl --user start hyprland-session.target")
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
-- DMS_STARTUP_END
|
-- DMS_STARTUP_END
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -15,93 +14,6 @@ const (
|
|||||||
hyprlandStartupEnd = "-- DMS_STARTUP_END"
|
hyprlandStartupEnd = "-- DMS_STARTUP_END"
|
||||||
)
|
)
|
||||||
|
|
||||||
func extractHyprlangMonitorLines(hyprlang string) []string {
|
|
||||||
re := regexp.MustCompile(`(?m)^\s*#?\s*monitor\s*=.*$`)
|
|
||||||
return re.FindAllString(hyprlang, -1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func hyprlangMonitorLineToLua(line string) (string, error) {
|
|
||||||
re := regexp.MustCompile(`(?i)^\s*#?\s*monitor\s*=\s*(.*)\s*$`)
|
|
||||||
m := re.FindStringSubmatch(line)
|
|
||||||
if m == nil {
|
|
||||||
return "", fmt.Errorf("not a monitor line")
|
|
||||||
}
|
|
||||||
rest := strings.TrimSpace(m[1])
|
|
||||||
parts := strings.Split(rest, ",")
|
|
||||||
for i := range parts {
|
|
||||||
parts[i] = strings.TrimSpace(parts[i])
|
|
||||||
}
|
|
||||||
if len(parts) < 4 {
|
|
||||||
if len(parts) == 2 && strings.EqualFold(parts[1], "disable") {
|
|
||||||
return fmt.Sprintf(`hl.monitor({ output = %s, disabled = true })`, strconv.Quote(parts[0])), nil
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("expected at least 4 comma-separated fields")
|
|
||||||
}
|
|
||||||
out := parts[0]
|
|
||||||
mode := parts[1]
|
|
||||||
pos := parts[2]
|
|
||||||
scaleStr := parts[3]
|
|
||||||
|
|
||||||
scaleField := formatMonitorScaleLua(scaleStr)
|
|
||||||
fields := []string{
|
|
||||||
fmt.Sprintf("output = %s", strconv.Quote(out)),
|
|
||||||
fmt.Sprintf("mode = %s", strconv.Quote(mode)),
|
|
||||||
fmt.Sprintf("position = %s", strconv.Quote(pos)),
|
|
||||||
scaleField,
|
|
||||||
}
|
|
||||||
for i := 4; i < len(parts); i += 2 {
|
|
||||||
key := strings.ToLower(strings.TrimSpace(parts[i]))
|
|
||||||
if key == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if i+1 >= len(parts) {
|
|
||||||
fields = append(fields, fmt.Sprintf("%s = true", hyprlangMonitorOptionToLuaKey(key)))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val := strings.TrimSpace(parts[i+1])
|
|
||||||
if converted, ok := formatMonitorOptionLua(key, val); ok {
|
|
||||||
fields = append(fields, converted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Sprintf(`hl.monitor({ %s })`, strings.Join(fields, ", ")), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatMonitorScaleLua(scaleStr string) string {
|
|
||||||
if scaleStr == "auto" {
|
|
||||||
return `scale = "auto"`
|
|
||||||
}
|
|
||||||
if f, err := strconv.ParseFloat(scaleStr, 64); err == nil {
|
|
||||||
return fmt.Sprintf(`scale = %g`, f)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf(`scale = %s`, strconv.Quote(scaleStr))
|
|
||||||
}
|
|
||||||
|
|
||||||
func hyprlangMonitorOptionToLuaKey(key string) string {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(key)) {
|
|
||||||
case "10bit":
|
|
||||||
return "bitdepth"
|
|
||||||
default:
|
|
||||||
return strings.ReplaceAll(strings.ToLower(strings.TrimSpace(key)), "-", "_")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatMonitorOptionLua(key, val string) (string, bool) {
|
|
||||||
luaKey := hyprlangMonitorOptionToLuaKey(key)
|
|
||||||
switch luaKey {
|
|
||||||
case "transform", "vrr", "bitdepth", "supports_wide_color", "supports_hdr", "sdr_max_luminance", "max_luminance", "max_avg_luminance":
|
|
||||||
if _, err := strconv.Atoi(val); err == nil {
|
|
||||||
return fmt.Sprintf("%s = %s", luaKey, val), true
|
|
||||||
}
|
|
||||||
case "sdrbrightness", "sdrsaturation", "sdr_min_luminance", "min_luminance":
|
|
||||||
if _, err := strconv.ParseFloat(val, 64); err == nil {
|
|
||||||
return fmt.Sprintf("%s = %s", luaKey, val), true
|
|
||||||
}
|
|
||||||
case "cm", "sdr_eotf", "icc", "mirror":
|
|
||||||
return fmt.Sprintf("%s = %s", luaKey, strconv.Quote(val)), true
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
func transformHyprlandLuaForNonSystemd(config, terminalCommand string) string {
|
func transformHyprlandLuaForNonSystemd(config, terminalCommand string) string {
|
||||||
start := strings.Index(config, hyprlandStartupBegin)
|
start := strings.Index(config, hyprlandStartupBegin)
|
||||||
end := strings.Index(config, hyprlandStartupEnd)
|
end := strings.Index(config, hyprlandStartupEnd)
|
||||||
@@ -122,76 +34,6 @@ func transformHyprlandLuaForNonSystemd(config, terminalCommand string) string {
|
|||||||
return config[:start] + replacement + config[endClose:]
|
return config[:start] + replacement + config[endClose:]
|
||||||
}
|
}
|
||||||
|
|
||||||
func readExistingHyprlandConfig(configDir string) (data string, sourcePath string, err error) {
|
func HyprlandConfigDir() string {
|
||||||
luaPath := filepath.Join(configDir, "hyprland.lua")
|
return filepath.Join(utils.XDGConfigHome(), "hypr")
|
||||||
if b, e := os.ReadFile(luaPath); e == nil {
|
|
||||||
return string(b), luaPath, nil
|
|
||||||
} else if !os.IsNotExist(e) {
|
|
||||||
return "", "", e
|
|
||||||
}
|
|
||||||
confPath := filepath.Join(configDir, "hyprland.conf")
|
|
||||||
if b, e := os.ReadFile(confPath); e == nil {
|
|
||||||
return string(b), confPath, nil
|
|
||||||
} else if !os.IsNotExist(e) {
|
|
||||||
return "", "", e
|
|
||||||
}
|
|
||||||
return "", "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CleanupStrayHyprlandConfFile moves stray ~/.config/hypr/hyprland.conf and
|
|
||||||
// top-level ~/.config/hypr/dms/*.conf files into .dms-backups/<timestamp>/ only
|
|
||||||
// when hyprland.lua also exists as the live config.
|
|
||||||
func CleanupStrayHyprlandConfFile(logFn func(format string, v ...any)) {
|
|
||||||
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
home := os.Getenv("HOME")
|
|
||||||
if home == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
configDir := filepath.Join(home, ".config", "hypr")
|
|
||||||
luaPath := filepath.Join(configDir, "hyprland.lua")
|
|
||||||
if _, err := os.Stat(luaPath); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var strayPaths []string
|
|
||||||
confPath := filepath.Join(configDir, "hyprland.conf")
|
|
||||||
if info, err := os.Lstat(confPath); err == nil && !info.IsDir() {
|
|
||||||
strayPaths = append(strayPaths, confPath)
|
|
||||||
}
|
|
||||||
dmsConfPaths, err := filepath.Glob(filepath.Join(configDir, "dms", "*.conf"))
|
|
||||||
if err == nil {
|
|
||||||
for _, p := range dmsConfPaths {
|
|
||||||
if info, err := os.Lstat(p); err == nil && !info.IsDir() {
|
|
||||||
strayPaths = append(strayPaths, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(strayPaths) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ts := time.Now().Format("2006-01-02_15-04-05")
|
|
||||||
moved := 0
|
|
||||||
for _, src := range strayPaths {
|
|
||||||
rel, err := filepath.Rel(configDir, src)
|
|
||||||
if err != nil {
|
|
||||||
rel = filepath.Base(src)
|
|
||||||
}
|
|
||||||
dst, err := moveHyprlandConfigFile(src, filepath.Join(configDir, hyprlandBackupDirName, ts, rel))
|
|
||||||
if err != nil {
|
|
||||||
if logFn != nil {
|
|
||||||
logFn("Could not move stray Hyprland conf file %s: %v", src, err)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
moved++
|
|
||||||
if logFn != nil {
|
|
||||||
logFn("Moved stray Hyprland conf file to %s", dst)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if moved > 0 && logFn != nil {
|
|
||||||
logFn("Moved %d stray Hyprland conf file(s) out of the active Lua config tree", moved)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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,19 +109,24 @@ 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) {
|
autostart := cd.systemdAutostart(deps.WindowManagerMango, useSystemd)
|
||||||
|
if autostart {
|
||||||
newConfig = mangoExecOnceDMS.ReplaceAllString(newConfig, "")
|
newConfig = mangoExecOnceDMS.ReplaceAllString(newConfig, "")
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(result.Path, []byte(dialect.Translate(newConfig)), 0o644); err != nil {
|
|
||||||
|
if err := cd.deployMangoDmsConfigs(dmsDir, terminalCommand, existing, timestamp); err != nil {
|
||||||
|
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
|
||||||
|
return result, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
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 autostart {
|
||||||
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
|
cd.startInLiveSession(deps.WindowManagerMango)
|
||||||
return result, result.Error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Deployed = true
|
result.Deployed = true
|
||||||
@@ -132,7 +136,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 +146,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 +164,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 +186,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")
|
||||||
|
|||||||
@@ -150,3 +150,19 @@ func (cd *ConfigDeployer) systemdAutostart(wm deps.WindowManager, useSystemd boo
|
|||||||
cd.log("DMS starts as dms.service with " + unit)
|
cd.log("DMS starts as dms.service with " + unit)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An already active session unit ignores a new wants link until next login, so a deploy
|
||||||
|
// from inside that session starts dms.service itself.
|
||||||
|
func (cd *ConfigDeployer) startInLiveSession(wm deps.WindowManager) {
|
||||||
|
if running, ok := RunningCompositor(); !ok || running != wm {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if systemctlUser("is-active", "--quiet", SessionUnitName(wm)) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := systemctlUser("start", "--no-block", "dms.service"); err != nil {
|
||||||
|
cd.log(fmt.Sprintf("Warning: failed to start dms.service: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cd.log("Started dms.service in the running session")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -16,19 +16,15 @@ type Status struct {
|
|||||||
Effective bool
|
Effective bool
|
||||||
OverriddenBy int
|
OverriddenBy int
|
||||||
StatusMessage string
|
StatusMessage string
|
||||||
ConfigFormat string
|
|
||||||
ReadOnly bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildStatus(scan IncludeScan, exists bool, entriesAfter int, format string, readOnly bool, msgs Messages) Status {
|
func BuildStatus(scan IncludeScan, exists bool, entriesAfter int, msgs Messages) Status {
|
||||||
status := Status{
|
status := Status{
|
||||||
Exists: exists,
|
Exists: exists,
|
||||||
Included: scan.DMSSeen,
|
Included: scan.DMSSeen,
|
||||||
IncludePosition: scan.DMSPosition,
|
IncludePosition: scan.DMSPosition,
|
||||||
TotalIncludes: scan.Count,
|
TotalIncludes: scan.Count,
|
||||||
EntriesAfterDMS: entriesAfter,
|
EntriesAfterDMS: entriesAfter,
|
||||||
ConfigFormat: format,
|
|
||||||
ReadOnly: readOnly,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ var allDomains = map[string]Messages{
|
|||||||
|
|
||||||
func TestMissingFragmentIsNotEffective(t *testing.T) {
|
func TestMissingFragmentIsNotEffective(t *testing.T) {
|
||||||
for domain, msgs := range allDomains {
|
for domain, msgs := range allDomains {
|
||||||
status := BuildStatus(NewScan(), false, 0, "", false, msgs)
|
status := BuildStatus(NewScan(), false, 0, msgs)
|
||||||
if status.Effective {
|
if status.Effective {
|
||||||
t.Errorf("%s: a missing fragment must not be effective", domain)
|
t.Errorf("%s: a missing fragment must not be effective", domain)
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ func TestExistingButNotIncludedIsNotEffective(t *testing.T) {
|
|||||||
for domain, msgs := range allDomains {
|
for domain, msgs := range allDomains {
|
||||||
scan := NewScan()
|
scan := NewScan()
|
||||||
scan.Count = 2
|
scan.Count = 2
|
||||||
status := BuildStatus(scan, true, 0, "", false, msgs)
|
status := BuildStatus(scan, true, 0, msgs)
|
||||||
if status.Effective {
|
if status.Effective {
|
||||||
t.Errorf("%s: an unincluded fragment must not be effective", domain)
|
t.Errorf("%s: an unincluded fragment must not be effective", domain)
|
||||||
}
|
}
|
||||||
@@ -77,7 +77,7 @@ func TestExistingButNotIncludedIsNotEffective(t *testing.T) {
|
|||||||
func TestIncludedWithEntriesAfterReportsOverride(t *testing.T) {
|
func TestIncludedWithEntriesAfterReportsOverride(t *testing.T) {
|
||||||
for domain, msgs := range allDomains {
|
for domain, msgs := range allDomains {
|
||||||
scan := IncludeScan{Count: 3, DMSPosition: 2, DMSSeen: true}
|
scan := IncludeScan{Count: 3, DMSPosition: 2, DMSSeen: true}
|
||||||
status := BuildStatus(scan, true, 5, "", false, msgs)
|
status := BuildStatus(scan, true, 5, msgs)
|
||||||
if !status.Effective {
|
if !status.Effective {
|
||||||
t.Errorf("%s: an overridden fragment is still effective", domain)
|
t.Errorf("%s: an overridden fragment is still effective", domain)
|
||||||
}
|
}
|
||||||
@@ -93,7 +93,7 @@ func TestIncludedWithEntriesAfterReportsOverride(t *testing.T) {
|
|||||||
func TestIncludedWithNothingAfterIsActive(t *testing.T) {
|
func TestIncludedWithNothingAfterIsActive(t *testing.T) {
|
||||||
for domain, msgs := range allDomains {
|
for domain, msgs := range allDomains {
|
||||||
scan := IncludeScan{Count: 1, DMSPosition: 1, DMSSeen: true}
|
scan := IncludeScan{Count: 1, DMSPosition: 1, DMSSeen: true}
|
||||||
status := BuildStatus(scan, true, 0, "", false, msgs)
|
status := BuildStatus(scan, true, 0, msgs)
|
||||||
if !status.Effective {
|
if !status.Effective {
|
||||||
t.Errorf("%s: an included fragment with nothing after it is effective", domain)
|
t.Errorf("%s: an included fragment with nothing after it is effective", domain)
|
||||||
}
|
}
|
||||||
@@ -106,9 +106,9 @@ func TestIncludedWithNothingAfterIsActive(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScanAndFormatCarryThrough(t *testing.T) {
|
func TestScanCarriesThrough(t *testing.T) {
|
||||||
scan := IncludeScan{Count: 4, DMSPosition: 3, DMSSeen: true}
|
scan := IncludeScan{Count: 4, DMSPosition: 3, DMSSeen: true}
|
||||||
status := BuildStatus(scan, true, 0, "lua", true, hyprlandRules)
|
status := BuildStatus(scan, true, 0, hyprlandRules)
|
||||||
|
|
||||||
if status.TotalIncludes != 4 || status.IncludePosition != 3 {
|
if status.TotalIncludes != 4 || status.IncludePosition != 3 {
|
||||||
t.Errorf("includes = %d/%d, want 3/4", status.IncludePosition, status.TotalIncludes)
|
t.Errorf("includes = %d/%d, want 3/4", status.IncludePosition, status.TotalIncludes)
|
||||||
@@ -116,7 +116,4 @@ func TestScanAndFormatCarryThrough(t *testing.T) {
|
|||||||
if !status.Included {
|
if !status.Included {
|
||||||
t.Error("DMSSeen must land on Included")
|
t.Error("DMSSeen must land on Included")
|
||||||
}
|
}
|
||||||
if status.ConfigFormat != "lua" || !status.ReadOnly {
|
|
||||||
t.Errorf("format/readOnly = %q/%v, want lua/true", status.ConfigFormat, status.ReadOnly)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,39 @@ func (b *BaseDistribution) detectDankCalendar() deps.Dependency {
|
|||||||
return b.detectOptionalPackage("dankcalendar", "Calendar application", b.commandExists("dcal") || b.commandExists("dankcalendar"))
|
return b.detectOptionalPackage("dankcalendar", "Calendar application", b.commandExists("dcal") || b.commandExists("dankcalendar"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renamedPackages maps distro packages to their pre-rename names. rpm -q, dpkg-query and xbps-query
|
||||||
|
// ignore Provides, so hosts that have not upgraded since the rename only match the old name.
|
||||||
|
var renamedPackages = map[string]string{
|
||||||
|
"dux-greeter": "dms-greeter",
|
||||||
|
"dux-greeter-git": "dms-greeter-git",
|
||||||
|
"dux-search": "danksearch",
|
||||||
|
"dux-calendar": "dankcalendar",
|
||||||
|
"dux-calendar-git": "dankcalendar-git",
|
||||||
|
"dux-gop": "dgop",
|
||||||
|
}
|
||||||
|
|
||||||
|
func installedName(installed func(string) bool, pkg string) (string, bool) {
|
||||||
|
if installed(pkg) {
|
||||||
|
return pkg, true
|
||||||
|
}
|
||||||
|
if old, ok := renamedPackages[pkg]; ok && installed(old) {
|
||||||
|
return old, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BaseDistribution) detectGreeterPackage(installed func(string) bool) deps.Dependency {
|
||||||
|
_, gitInstalled := installedName(installed, "dux-greeter-git")
|
||||||
|
_, stableInstalled := installedName(installed, "dux-greeter")
|
||||||
|
dep := b.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", gitInstalled || stableInstalled)
|
||||||
|
return withGitVariant(dep, gitInstalled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BaseDistribution) detectCalendarPackage(installed func(string) bool) deps.Dependency {
|
||||||
|
_, gitInstalled := installedName(installed, "dux-calendar-git")
|
||||||
|
return withGitVariant(b.detectDankCalendar(), gitInstalled)
|
||||||
|
}
|
||||||
|
|
||||||
func withGitVariant(dep deps.Dependency, gitInstalled bool) deps.Dependency {
|
func withGitVariant(dep deps.Dependency, gitInstalled bool) deps.Dependency {
|
||||||
dep.CanToggle = true
|
dep.CanToggle = true
|
||||||
if gitInstalled {
|
if gitInstalled {
|
||||||
@@ -502,7 +535,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 +574,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 +692,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package distros
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
|
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
|
||||||
@@ -73,3 +74,56 @@ func TestParseHyprlandVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDetectGreeterPackageAcceptsOldNames(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
installed string
|
||||||
|
wantInstalled bool
|
||||||
|
wantVariant deps.PackageVariant
|
||||||
|
}{
|
||||||
|
{"", false, deps.VariantStable},
|
||||||
|
{"dms-greeter", true, deps.VariantStable},
|
||||||
|
{"dux-greeter", true, deps.VariantStable},
|
||||||
|
{"dms-greeter-git", true, deps.VariantGit},
|
||||||
|
{"dux-greeter-git", true, deps.VariantGit},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
dep := NewBaseDistribution(nil).detectGreeterPackage(func(p string) bool { return p == tc.installed })
|
||||||
|
if (dep.Status == deps.StatusInstalled) != tc.wantInstalled || dep.Variant != tc.wantVariant {
|
||||||
|
t.Errorf("%q installed: status %v variant %v, want installed=%v variant %v", tc.installed, dep.Status, dep.Variant, tc.wantInstalled, tc.wantVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectCalendarPackageAcceptsOldNames(t *testing.T) {
|
||||||
|
for installed, want := range map[string]deps.PackageVariant{
|
||||||
|
"": deps.VariantStable,
|
||||||
|
"dankcalendar-git": deps.VariantGit,
|
||||||
|
"dux-calendar-git": deps.VariantGit,
|
||||||
|
} {
|
||||||
|
dep := NewBaseDistribution(nil).detectCalendarPackage(func(p string) bool { return p == installed })
|
||||||
|
if dep.Variant != want {
|
||||||
|
t.Errorf("%q installed: variant %v, want %v", installed, dep.Variant, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenamedPackagesInstallNewNames(t *testing.T) {
|
||||||
|
cases := map[string]Distribution{
|
||||||
|
"fedora": NewFedoraDistribution(Registry["fedora"], nil),
|
||||||
|
"opensuse": NewOpenSUSEDistribution(Registry["opensuse-tumbleweed"], nil),
|
||||||
|
"debian": NewDebianDistribution(Registry["debian"], nil),
|
||||||
|
"ubuntu": NewUbuntuDistribution(Registry["ubuntu"], nil),
|
||||||
|
"void": NewVoidDistribution(Registry["void"], nil),
|
||||||
|
}
|
||||||
|
for id, d := range cases {
|
||||||
|
for _, variant := range []deps.PackageVariant{deps.VariantStable, deps.VariantGit} {
|
||||||
|
packages := d.GetPackageMappingWithVariants(deps.WindowManagerNiri, map[string]deps.PackageVariant{"dms-greeter": variant, "dankcalendar": variant})
|
||||||
|
for dep, want := range map[string]string{"dms-greeter": "dux-greeter", "danksearch": "dux-search", "dankcalendar": "dux-calendar"} {
|
||||||
|
if got := strings.TrimSuffix(packages[dep].Name, "-git"); got != want {
|
||||||
|
t.Errorf("%s %s (%v) installs %q, want %q", id, dep, variant, packages[dep].Name, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,7 +62,7 @@ func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
|||||||
dependencies = append(dependencies, d.detectGit())
|
dependencies = append(dependencies, d.detectGit())
|
||||||
dependencies = append(dependencies, d.detectWindowManager(wm))
|
dependencies = append(dependencies, d.detectWindowManager(wm))
|
||||||
dependencies = append(dependencies, d.detectQuickshell())
|
dependencies = append(dependencies, d.detectQuickshell())
|
||||||
dependencies = append(dependencies, d.detectDMSGreeter())
|
dependencies = append(dependencies, d.detectGreeterPackage(d.packageInstalled))
|
||||||
dependencies = append(dependencies, d.detectXDGPortal())
|
dependencies = append(dependencies, d.detectXDGPortal())
|
||||||
dependencies = append(dependencies, d.detectAccountsService())
|
dependencies = append(dependencies, d.detectAccountsService())
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
|||||||
|
|
||||||
dependencies = append(dependencies, d.detectMatugen())
|
dependencies = append(dependencies, d.detectMatugen())
|
||||||
dependencies = append(dependencies, d.detectDanksearch())
|
dependencies = append(dependencies, d.detectDanksearch())
|
||||||
dependencies = append(dependencies, withGitVariant(d.detectDankCalendar(), d.packageInstalled("dankcalendar-git")))
|
dependencies = append(dependencies, d.detectCalendarPackage(d.packageInstalled))
|
||||||
|
|
||||||
return dependencies, nil
|
return dependencies, nil
|
||||||
}
|
}
|
||||||
@@ -94,12 +94,6 @@ func (d *DebianDistribution) detectAccountsService() deps.Dependency {
|
|||||||
return d.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", d.packageInstalled("accountsservice"))
|
return d.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", d.packageInstalled("accountsservice"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DebianDistribution) detectDMSGreeter() deps.Dependency {
|
|
||||||
gitInstalled := d.packageInstalled("dms-greeter-git")
|
|
||||||
dep := d.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", gitInstalled || d.packageInstalled("dms-greeter"))
|
|
||||||
return withGitVariant(dep, gitInstalled)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DebianDistribution) packageInstalled(pkg string) bool {
|
func (d *DebianDistribution) packageInstalled(pkg string) bool {
|
||||||
return debianPackageInstalledPrecisely(pkg)
|
return debianPackageInstalledPrecisely(pkg)
|
||||||
}
|
}
|
||||||
@@ -159,11 +153,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("dux-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: "dux-search", Repository: RepoTypeOBS, RepoURL: obsDeps},
|
||||||
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeOBS, "home:AvengeMedia:danklinux"),
|
"dankcalendar": variantMapping("dux-calendar", variants["dankcalendar"], RepoTypeOBS, obsDeps),
|
||||||
}
|
}
|
||||||
|
|
||||||
if wm == deps.WindowManagerNiri {
|
if wm == deps.WindowManagerNiri {
|
||||||
@@ -177,14 +171,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 +238,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 +485,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 +556,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")
|
||||||
|
|||||||
+230
-37
@@ -2,9 +2,13 @@ package distros
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"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"
|
||||||
@@ -23,9 +27,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 +55,9 @@ type FedoraDistribution struct {
|
|||||||
*BaseDistribution
|
*BaseDistribution
|
||||||
*ManualPackageInstaller
|
*ManualPackageInstaller
|
||||||
config DistroConfig
|
config DistroConfig
|
||||||
|
|
||||||
|
rpmInstalled func(pkg string) bool
|
||||||
|
ostreeRequested func() []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFedoraDistribution(config DistroConfig, logChan chan<- string) *FedoraDistribution {
|
func NewFedoraDistribution(config DistroConfig, logChan chan<- string) *FedoraDistribution {
|
||||||
@@ -106,7 +110,7 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
|||||||
}
|
}
|
||||||
dependencies = append(dependencies, wmDep)
|
dependencies = append(dependencies, wmDep)
|
||||||
dependencies = append(dependencies, f.detectQuickshell())
|
dependencies = append(dependencies, f.detectQuickshell())
|
||||||
dependencies = append(dependencies, f.detectDMSGreeter())
|
dependencies = append(dependencies, f.detectGreeterPackage(f.packageInstalled))
|
||||||
dependencies = append(dependencies, f.detectXDGPortal())
|
dependencies = append(dependencies, f.detectXDGPortal())
|
||||||
dependencies = append(dependencies, f.detectAccountsService())
|
dependencies = append(dependencies, f.detectAccountsService())
|
||||||
|
|
||||||
@@ -128,7 +132,7 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
|||||||
|
|
||||||
dependencies = append(dependencies, f.detectMatugen())
|
dependencies = append(dependencies, f.detectMatugen())
|
||||||
dependencies = append(dependencies, f.detectDanksearch())
|
dependencies = append(dependencies, f.detectDanksearch())
|
||||||
dependencies = append(dependencies, withGitVariant(f.detectDankCalendar(), f.packageInstalled("dankcalendar-git")))
|
dependencies = append(dependencies, f.detectCalendarPackage(f.packageInstalled))
|
||||||
|
|
||||||
return dependencies, nil
|
return dependencies, nil
|
||||||
}
|
}
|
||||||
@@ -138,6 +142,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 +157,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 +191,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 +216,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 +228,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 +290,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 +298,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("dux-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: "dux-search", Repository: RepoTypeCOPR, RepoURL: coprDeps},
|
||||||
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeCOPR, "avengemedia/danklinux"),
|
"dankcalendar": variantMapping("dux-calendar", variants["dankcalendar"], RepoTypeCOPR, coprDeps),
|
||||||
}
|
}
|
||||||
|
|
||||||
switch wm {
|
switch wm {
|
||||||
@@ -244,10 +321,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 +334,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 {
|
||||||
@@ -308,12 +385,6 @@ func (f *FedoraDistribution) detectAccountsService() deps.Dependency {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *FedoraDistribution) detectDMSGreeter() deps.Dependency {
|
|
||||||
gitInstalled := f.packageInstalled("dms-greeter-git")
|
|
||||||
dep := f.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", gitInstalled || f.packageInstalled("dms-greeter"))
|
|
||||||
return withGitVariant(dep, gitInstalled)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *FedoraDistribution) getPrerequisites() []string {
|
func (f *FedoraDistribution) getPrerequisites() []string {
|
||||||
return []string{
|
return []string{
|
||||||
"dnf-plugins-core",
|
"dnf-plugins-core",
|
||||||
@@ -633,7 +704,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 +718,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 +728,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 +815,125 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rpm-ostree status runs over D-Bus and the layer view rebuilds its commands on every render.
|
||||||
|
var rpmOstreeRequested = sync.OnceValue(func() []string {
|
||||||
|
out, err := exec.Command("rpm-ostree", "status", "--json").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var status struct {
|
||||||
|
Deployments []struct {
|
||||||
|
Requested []string `json:"requested-packages"`
|
||||||
|
} `json:"deployments"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(out, &status) != nil || len(status.Deployments) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return status.Deployments[0].Requested
|
||||||
|
})
|
||||||
|
|
||||||
|
func (f *FedoraDistribution) layeredRequests() map[string]bool {
|
||||||
|
list := f.ostreeRequested
|
||||||
|
if list == nil {
|
||||||
|
list = rpmOstreeRequested
|
||||||
|
}
|
||||||
|
requested := map[string]bool{}
|
||||||
|
for _, name := range list() {
|
||||||
|
requested[name] = true
|
||||||
|
}
|
||||||
|
return requested
|
||||||
|
}
|
||||||
|
|
||||||
|
// layeredName is what --uninstall must name: the request, which may still be the pre-rename name
|
||||||
|
// even when its Provides already resolved to the renamed package.
|
||||||
|
func layeredName(requested map[string]bool, pkg string) string {
|
||||||
|
if old, ok := renamedPackages[pkg]; ok && !requested[pkg] && requested[old] {
|
||||||
|
return old
|
||||||
|
}
|
||||||
|
return pkg
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{}
|
||||||
|
}
|
||||||
|
requested := f.layeredRequests()
|
||||||
|
// 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, layeredName(requested, 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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A request keeps its old name forever and resolves by name before Provides, ignoring Obsoletes.
|
||||||
|
for _, name := range slices.Sorted(maps.Keys(renamedPackages)) {
|
||||||
|
old := renamedPackages[name]
|
||||||
|
if !requested[old] || slices.Contains(uninstall, old) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uninstall = append(uninstall, old)
|
||||||
|
if !requested[name] && !slices.Contains(pkgs, name) {
|
||||||
|
pkgs = append(pkgs, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,245 @@
|
|||||||
|
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)
|
||||||
|
f.ostreeRequested = func() []string { return 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] }
|
||||||
|
f.ostreeRequested = func() []string { return []string{"dms", "dms-greeter"} }
|
||||||
|
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 dux-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 TestFedoraLayerCommandsRenamedRequests(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
rpm string
|
||||||
|
requested []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"old package and request", "dms-greeter", []string{"dms-greeter", "danksearch"}, "sudo rpm-ostree install dux-greeter-git dux-search --uninstall dms-greeter danksearch"},
|
||||||
|
{"old request resolved through Provides", "dux-greeter", []string{"dms-greeter"}, "sudo rpm-ostree install dux-greeter-git --uninstall dms-greeter"},
|
||||||
|
{"new request", "dux-greeter", []string{"dux-greeter"}, "sudo rpm-ostree install dux-greeter-git --uninstall dux-greeter"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
f := NewFedoraDistribution(Registry["fedora"], nil)
|
||||||
|
f.rpmInstalled = func(pkg string) bool { return pkg == tc.rpm }
|
||||||
|
f.ostreeRequested = func() []string { return tc.requested }
|
||||||
|
dependencies := []deps.Dependency{{Name: "dms-greeter", Status: deps.StatusInstalled, Variant: deps.VariantGit, CanToggle: true}}
|
||||||
|
got := strings.Join(f.LayerCommands(dependencies, deps.WindowManagerNiri, map[string]bool{"dms-greeter": true}, nil), "\n")
|
||||||
|
if !strings.Contains(got, tc.want) {
|
||||||
|
t.Fatalf("missing %q in:\n%s", tc.want, 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Contex
|
|||||||
dependencies = append(dependencies, o.detectGit())
|
dependencies = append(dependencies, o.detectGit())
|
||||||
dependencies = append(dependencies, o.detectWindowManager(wm))
|
dependencies = append(dependencies, o.detectWindowManager(wm))
|
||||||
dependencies = append(dependencies, o.detectQuickshell())
|
dependencies = append(dependencies, o.detectQuickshell())
|
||||||
dependencies = append(dependencies, o.detectDMSGreeter())
|
dependencies = append(dependencies, o.detectGreeterPackage(o.packageInstalled))
|
||||||
dependencies = append(dependencies, o.detectXDGPortal())
|
dependencies = append(dependencies, o.detectXDGPortal())
|
||||||
dependencies = append(dependencies, o.detectAccountsService())
|
dependencies = append(dependencies, o.detectAccountsService())
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Contex
|
|||||||
|
|
||||||
dependencies = append(dependencies, o.detectMatugen())
|
dependencies = append(dependencies, o.detectMatugen())
|
||||||
dependencies = append(dependencies, o.detectDanksearch())
|
dependencies = append(dependencies, o.detectDanksearch())
|
||||||
dependencies = append(dependencies, withGitVariant(o.detectDankCalendar(), o.packageInstalled("dankcalendar-git")))
|
dependencies = append(dependencies, o.detectCalendarPackage(o.packageInstalled))
|
||||||
|
|
||||||
return dependencies, nil
|
return dependencies, nil
|
||||||
}
|
}
|
||||||
@@ -132,10 +132,68 @@ 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) detectDMSGreeter() deps.Dependency {
|
func (o *OpenSUSEDistribution) RepoMoves() []RepoMove {
|
||||||
gitInstalled := o.packageInstalled("dms-greeter-git")
|
if !obsMigrationReady {
|
||||||
dep := o.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", gitInstalled || o.packageInstalled("dms-greeter"))
|
return nil
|
||||||
return withGitVariant(dep, gitInstalled)
|
}
|
||||||
|
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) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
|
func (o *OpenSUSEDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
|
||||||
@@ -154,11 +212,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("dux-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: "dux-search", Repository: RepoTypeOBS, RepoURL: obsDeps},
|
||||||
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeOBS, "home:AvengeMedia:danklinux"),
|
"dankcalendar": variantMapping("dux-calendar", variants["dankcalendar"], RepoTypeOBS, obsDeps),
|
||||||
}
|
}
|
||||||
|
|
||||||
switch wm {
|
switch wm {
|
||||||
@@ -179,28 +237,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 +548,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 +751,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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := os.Open("/etc/os-release")
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
-24
@@ -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)
|
||||||
@@ -64,7 +70,7 @@ func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
|||||||
dependencies = append(dependencies, u.detectGit())
|
dependencies = append(dependencies, u.detectGit())
|
||||||
dependencies = append(dependencies, u.detectWindowManager(wm))
|
dependencies = append(dependencies, u.detectWindowManager(wm))
|
||||||
dependencies = append(dependencies, u.detectQuickshell())
|
dependencies = append(dependencies, u.detectQuickshell())
|
||||||
dependencies = append(dependencies, u.detectDMSGreeter())
|
dependencies = append(dependencies, u.detectGreeterPackage(u.packageInstalled))
|
||||||
dependencies = append(dependencies, u.detectXDGPortal())
|
dependencies = append(dependencies, u.detectXDGPortal())
|
||||||
dependencies = append(dependencies, u.detectAccountsService())
|
dependencies = append(dependencies, u.detectAccountsService())
|
||||||
|
|
||||||
@@ -81,7 +87,7 @@ func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
|||||||
|
|
||||||
dependencies = append(dependencies, u.detectMatugen())
|
dependencies = append(dependencies, u.detectMatugen())
|
||||||
dependencies = append(dependencies, u.detectDanksearch())
|
dependencies = append(dependencies, u.detectDanksearch())
|
||||||
dependencies = append(dependencies, withGitVariant(u.detectDankCalendar(), u.packageInstalled("dankcalendar-git")))
|
dependencies = append(dependencies, u.detectCalendarPackage(u.packageInstalled))
|
||||||
|
|
||||||
return dependencies, nil
|
return dependencies, nil
|
||||||
}
|
}
|
||||||
@@ -98,12 +104,6 @@ func (u *UbuntuDistribution) detectAccountsService() deps.Dependency {
|
|||||||
return u.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", u.packageInstalled("accountsservice"))
|
return u.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", u.packageInstalled("accountsservice"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UbuntuDistribution) detectDMSGreeter() deps.Dependency {
|
|
||||||
gitInstalled := u.packageInstalled("dms-greeter-git")
|
|
||||||
dep := u.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", gitInstalled || u.packageInstalled("dms-greeter"))
|
|
||||||
return withGitVariant(dep, gitInstalled)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UbuntuDistribution) packageInstalled(pkg string) bool {
|
func (u *UbuntuDistribution) packageInstalled(pkg string) bool {
|
||||||
return debianPackageInstalledPrecisely(pkg)
|
return debianPackageInstalledPrecisely(pkg)
|
||||||
}
|
}
|
||||||
@@ -123,6 +123,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 +184,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("dux-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: "dux-search", Repository: RepoTypePPA, RepoURL: ppaDeps},
|
||||||
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypePPA, "ppa:avengemedia/danklinux"),
|
"dankcalendar": variantMapping("dux-calendar", 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 +209,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 +310,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 +463,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)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type VariantSwap struct {
|
|||||||
type VariantSwapper interface {
|
type VariantSwapper interface {
|
||||||
InstalledVariant(stable, git PackageMapping) (deps.PackageVariant, bool)
|
InstalledVariant(stable, git PackageMapping) (deps.PackageVariant, bool)
|
||||||
SwapVariants(ctx context.Context, swaps []VariantSwap, sudoPassword string, progressChan chan<- InstallProgressMsg) error
|
SwapVariants(ctx context.Context, swaps []VariantSwap, sudoPassword string, progressChan chan<- InstallProgressMsg) error
|
||||||
|
packageInstalled(pkg string) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type SwapStatus int
|
type SwapStatus int
|
||||||
@@ -53,18 +54,26 @@ func PlanVariantSwap(d Distribution, wm deps.WindowManager, dep string, target d
|
|||||||
case current == target:
|
case current == target:
|
||||||
return VariantSwap{}, SwapAlreadyOnTarget
|
return VariantSwap{}, SwapAlreadyOnTarget
|
||||||
case target == deps.VariantGit:
|
case target == deps.VariantGit:
|
||||||
return VariantSwap{Dep: dep, From: stable, To: git}, SwapNeeded
|
return VariantSwap{Dep: dep, From: installedAs(swapper, stable), To: git}, SwapNeeded
|
||||||
default:
|
default:
|
||||||
return VariantSwap{Dep: dep, From: git, To: stable}, SwapNeeded
|
return VariantSwap{Dep: dep, From: installedAs(swapper, git), To: stable}, SwapNeeded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// installedAs names the package the remove step must target, which is the old name on hosts not yet upgraded past a rename.
|
||||||
|
func installedAs(swapper VariantSwapper, m PackageMapping) PackageMapping {
|
||||||
|
if name, ok := installedName(swapper.packageInstalled, m.Name); ok {
|
||||||
|
m.Name = name
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
// installedVariantByName works when the two variants ship under different package names.
|
// installedVariantByName works when the two variants ship under different package names.
|
||||||
func installedVariantByName(installed func(string) bool, stable, git PackageMapping) (deps.PackageVariant, bool) {
|
func installedVariantByName(installed func(string) bool, stable, git PackageMapping) (deps.PackageVariant, bool) {
|
||||||
switch {
|
if _, ok := installedName(installed, packageBaseName(git.Name)); ok {
|
||||||
case installed(packageBaseName(git.Name)):
|
|
||||||
return deps.VariantGit, true
|
return deps.VariantGit, true
|
||||||
case installed(packageBaseName(stable.Name)):
|
}
|
||||||
|
if _, ok := installedName(installed, packageBaseName(stable.Name)); ok {
|
||||||
return deps.VariantStable, true
|
return deps.VariantStable, true
|
||||||
}
|
}
|
||||||
return deps.VariantStable, false
|
return deps.VariantStable, false
|
||||||
|
|||||||
@@ -15,13 +15,15 @@ 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("dux-greeter", variants["dms-greeter"], RepoTypeCOPR, "duxlabs/dux-repo"),
|
||||||
"matugen": {Name: "matugen", Repository: RepoTypeSystem},
|
"matugen": {Name: "matugen", Repository: RepoTypeSystem},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *swapFake) packageInstalled(pkg string) bool { return f.installed[pkg] }
|
||||||
|
|
||||||
func (f *swapFake) InstalledVariant(stable, git PackageMapping) (deps.PackageVariant, bool) {
|
func (f *swapFake) InstalledVariant(stable, git PackageMapping) (deps.PackageVariant, bool) {
|
||||||
return installedVariantByName(func(p string) bool { return f.installed[p] }, stable, git)
|
return installedVariantByName(f.packageInstalled, stable, git)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *swapFake) SwapVariants(_ context.Context, swaps []VariantSwap, _ string, _ chan<- InstallProgressMsg) error {
|
func (f *swapFake) SwapVariants(_ context.Context, swaps []VariantSwap, _ string, _ chan<- InstallProgressMsg) error {
|
||||||
@@ -38,9 +40,12 @@ func TestPlanVariantSwap(t *testing.T) {
|
|||||||
want SwapStatus
|
want SwapStatus
|
||||||
from, to string
|
from, to string
|
||||||
}{
|
}{
|
||||||
{"stable to git", "dms-greeter", "dms-greeter", deps.VariantGit, SwapNeeded, "dms-greeter", "dms-greeter-git"},
|
{"stable to git", "dms-greeter", "dux-greeter", deps.VariantGit, SwapNeeded, "dux-greeter", "dux-greeter-git"},
|
||||||
{"git to stable", "dms-greeter", "dms-greeter-git", deps.VariantStable, SwapNeeded, "dms-greeter-git", "dms-greeter"},
|
{"git to stable", "dms-greeter", "dux-greeter-git", deps.VariantStable, SwapNeeded, "dux-greeter-git", "dux-greeter"},
|
||||||
{"already on target", "dms-greeter", "dms-greeter-git", deps.VariantGit, SwapAlreadyOnTarget, "", ""},
|
{"stable to git from the pre-rename package", "dms-greeter", "dms-greeter", deps.VariantGit, SwapNeeded, "dms-greeter", "dux-greeter-git"},
|
||||||
|
{"git to stable from the pre-rename package", "dms-greeter", "dms-greeter-git", deps.VariantStable, SwapNeeded, "dms-greeter-git", "dux-greeter"},
|
||||||
|
{"already on target", "dms-greeter", "dux-greeter-git", deps.VariantGit, SwapAlreadyOnTarget, "", ""},
|
||||||
|
{"already on target under the pre-rename name", "dms-greeter", "dms-greeter-git", deps.VariantGit, SwapAlreadyOnTarget, "", ""},
|
||||||
{"not installed", "dms-greeter", "", deps.VariantGit, SwapNotInstalled, "", ""},
|
{"not installed", "dms-greeter", "", deps.VariantGit, SwapNotInstalled, "", ""},
|
||||||
{"same mapping both ways", "matugen", "matugen", deps.VariantGit, SwapNoVariant, "", ""},
|
{"same mapping both ways", "matugen", "matugen", deps.VariantGit, SwapNoVariant, "", ""},
|
||||||
}
|
}
|
||||||
@@ -56,7 +61,7 @@ func TestPlanVariantSwap(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSwapReinstalledVariantsSkipsSwappedDeps(t *testing.T) {
|
func TestSwapReinstalledVariantsSkipsSwappedDeps(t *testing.T) {
|
||||||
d := &swapFake{installed: map[string]bool{"dms-greeter": true}}
|
d := &swapFake{installed: map[string]bool{"dux-greeter": true}}
|
||||||
dependencies := []deps.Dependency{
|
dependencies := []deps.Dependency{
|
||||||
{Name: "dms-greeter", Status: deps.StatusInstalled, Variant: deps.VariantGit},
|
{Name: "dms-greeter", Status: deps.StatusInstalled, Variant: deps.VariantGit},
|
||||||
{Name: "matugen", Status: deps.StatusInstalled},
|
{Name: "matugen", Status: deps.StatusInstalled},
|
||||||
@@ -68,8 +73,8 @@ func TestSwapReinstalledVariantsSkipsSwappedDeps(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(d.swapped) != 1 || d.swapped[0].To.Name != "dms-greeter-git" {
|
if len(d.swapped) != 1 || d.swapped[0].To.Name != "dux-greeter-git" {
|
||||||
t.Fatalf("swapped = %+v, want only dms-greeter → dms-greeter-git", d.swapped)
|
t.Fatalf("swapped = %+v, want only dux-greeter → dux-greeter-git", d.swapped)
|
||||||
}
|
}
|
||||||
if !skip["dms-greeter"] || skip["matugen"] {
|
if !skip["dms-greeter"] || skip["matugen"] {
|
||||||
t.Fatalf("skip = %v, want the swapped greeter skipped and matugen reinstalled", skip)
|
t.Fatalf("skip = %v, want the swapped greeter skipped and matugen reinstalled", skip)
|
||||||
|
|||||||
+107
-15
@@ -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"
|
||||||
)
|
)
|
||||||
@@ -68,7 +69,7 @@ func (v *VoidDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
|
|||||||
dependencies = append(dependencies, v.detectGit())
|
dependencies = append(dependencies, v.detectGit())
|
||||||
dependencies = append(dependencies, v.detectWindowManager(wm))
|
dependencies = append(dependencies, v.detectWindowManager(wm))
|
||||||
dependencies = append(dependencies, v.detectQuickshell())
|
dependencies = append(dependencies, v.detectQuickshell())
|
||||||
dependencies = append(dependencies, v.detectDMSGreeter())
|
dependencies = append(dependencies, v.detectGreeterPackage(v.packageInstalled))
|
||||||
dependencies = append(dependencies, v.detectXDGPortal())
|
dependencies = append(dependencies, v.detectXDGPortal())
|
||||||
dependencies = append(dependencies, v.detectAccountsService())
|
dependencies = append(dependencies, v.detectAccountsService())
|
||||||
dependencies = append(dependencies, v.detectDBus())
|
dependencies = append(dependencies, v.detectDBus())
|
||||||
@@ -128,12 +129,6 @@ func (v *VoidDistribution) detectXDGPortal() deps.Dependency {
|
|||||||
return v.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", v.packageInstalled("xdg-desktop-portal-gtk"))
|
return v.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", v.packageInstalled("xdg-desktop-portal-gtk"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *VoidDistribution) detectDMSGreeter() deps.Dependency {
|
|
||||||
gitInstalled := v.packageInstalled("dms-greeter-git")
|
|
||||||
dep := v.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", gitInstalled || v.packageInstalled("dms-greeter"))
|
|
||||||
return withGitVariant(dep, gitInstalled)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *VoidDistribution) detectAccountsService() deps.Dependency {
|
func (v *VoidDistribution) detectAccountsService() deps.Dependency {
|
||||||
return v.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", v.packageInstalled("accountsservice"))
|
return v.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", v.packageInstalled("accountsservice"))
|
||||||
}
|
}
|
||||||
@@ -193,6 +188,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 +312,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("dux-greeter", variants["dms-greeter"], RepoTypeXBPS, VoidDepsRepo),
|
||||||
"danksearch": {Name: "danksearch", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
|
"danksearch": {Name: "dux-search", Repository: RepoTypeXBPS, RepoURL: VoidDepsRepo},
|
||||||
"dankcalendar": {Name: "dankcalendar", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
|
"dankcalendar": {Name: "dux-calendar", Repository: RepoTypeXBPS, RepoURL: VoidDepsRepo},
|
||||||
}
|
}
|
||||||
|
|
||||||
switch wm {
|
switch wm {
|
||||||
@@ -521,8 +613,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:
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package gitssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-git/go-git/v6/plumbing/client"
|
||||||
|
"github.com/go-git/go-git/v6/plumbing/transport"
|
||||||
|
xssh "github.com/go-git/go-git/v6/plumbing/transport/ssh"
|
||||||
|
"github.com/go-git/go-git/v6/plumbing/transport/ssh/knownhosts"
|
||||||
|
"github.com/kevinburke/ssh_config"
|
||||||
|
gossh "golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ClientOptions() []client.Option {
|
||||||
|
return []client.Option{client.WithSSHAuth(agentAuth{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
type agentAuth struct{}
|
||||||
|
|
||||||
|
func (agentAuth) ClientConfig(ctx context.Context, req *transport.Request) (*gossh.ClientConfig, error) {
|
||||||
|
auth, err := xssh.NewSSHAgentAuth(username(req.URL))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db, err := knownHostsDB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
auth.HostKeyCallback = db.HostKeyCallback()
|
||||||
|
cfg, err := auth.ClientConfig(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.HostKeyAlgorithms = db.HostKeyAlgorithms(hostWithPort(req.URL))
|
||||||
|
if len(cfg.HostKeyAlgorithms) == 0 {
|
||||||
|
cfg.HostKeyAlgorithms = gossh.SupportedAlgorithms().HostKeys
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func username(u *url.URL) string {
|
||||||
|
if u.User == nil || u.User.Username() == "" {
|
||||||
|
return xssh.DefaultUsername
|
||||||
|
}
|
||||||
|
return u.User.Username()
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostWithPort(u *url.URL) string {
|
||||||
|
hostname := u.Hostname()
|
||||||
|
port := u.Port()
|
||||||
|
if configHost := ssh_config.Get(hostname, "Hostname"); configHost != "" {
|
||||||
|
hostname = configHost
|
||||||
|
}
|
||||||
|
if port == "" {
|
||||||
|
if configPort := ssh_config.Get(u.Hostname(), "Port"); configPort != "" {
|
||||||
|
if _, err := strconv.Atoi(configPort); err == nil {
|
||||||
|
port = configPort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if port == "" {
|
||||||
|
port = strconv.Itoa(xssh.DefaultPort)
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(hostname, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// x/crypto rejects a whole known_hosts file over one key type it cannot parse,
|
||||||
|
// so entries like ssh-mldsa44-ed25519 are blanked before the file is loaded.
|
||||||
|
func knownHostsDB() (*knownhosts.HostKeyDB, error) {
|
||||||
|
dir, err := os.MkdirTemp("", "dms-known-hosts-")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
|
var filtered []string
|
||||||
|
for i, file := range knownHostsFiles() {
|
||||||
|
content, err := os.ReadFile(file)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, fmt.Sprintf("%d-%s", i, filepath.Base(file)))
|
||||||
|
if err := os.WriteFile(path, blankUnparseableLines(content), 0o600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
filtered = append(filtered, path)
|
||||||
|
}
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
return nil, errors.New("unable to find any valid known_hosts file, set SSH_KNOWN_HOSTS env variable")
|
||||||
|
}
|
||||||
|
return knownhosts.NewDB(filtered...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func knownHostsFiles() []string {
|
||||||
|
if files := filepath.SplitList(os.Getenv("SSH_KNOWN_HOSTS")); len(files) != 0 {
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return []string{"/etc/ssh/ssh_known_hosts"}
|
||||||
|
}
|
||||||
|
return []string{filepath.Join(home, ".ssh", "known_hosts"), "/etc/ssh/ssh_known_hosts"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func blankUnparseableLines(content []byte) []byte {
|
||||||
|
lines := bytes.Split(content, []byte("\n"))
|
||||||
|
for i, line := range lines {
|
||||||
|
trimmed := bytes.TrimSpace(line)
|
||||||
|
if len(trimmed) == 0 || trimmed[0] == '#' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, _, _, _, _, err := gossh.ParseKnownHosts(trimmed); err != nil {
|
||||||
|
lines[i] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes.Join(lines, []byte("\n"))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package providers
|
package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -17,8 +18,6 @@ type HyprlandProvider struct {
|
|||||||
configPath string
|
configPath string
|
||||||
dmsBindsIncluded bool
|
dmsBindsIncluded bool
|
||||||
parsed bool
|
parsed bool
|
||||||
// hyprlang configs (0.55/0.56) reject hl.dsp text, so binds carry no LuaAction there
|
|
||||||
luaConfig bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHyprlandProvider(configPath string) *HyprlandProvider {
|
func NewHyprlandProvider(configPath string) *HyprlandProvider {
|
||||||
@@ -50,7 +49,6 @@ func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
|
|||||||
|
|
||||||
h.dmsBindsIncluded = result.DMSBindsIncluded
|
h.dmsBindsIncluded = result.DMSBindsIncluded
|
||||||
h.parsed = true
|
h.parsed = true
|
||||||
h.luaConfig = result.ConfigFormat == "lua"
|
|
||||||
|
|
||||||
categorizedBinds := make(map[string][]keybinds.Keybind)
|
categorizedBinds := make(map[string][]keybinds.Keybind)
|
||||||
h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs, result.DefaultDMSKeys)
|
h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs, result.DefaultDMSKeys)
|
||||||
@@ -92,7 +90,7 @@ func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, kb := range section.Keybinds {
|
for _, kb := range section.Keybinds {
|
||||||
category := h.categorizeByDispatcher(kb.Dispatcher)
|
category := h.categorizeBind(&kb)
|
||||||
bind := h.convertKeybind(&kb, currentSubcat, conflicts, defaultKeys)
|
bind := h.convertKeybind(&kb, currentSubcat, conflicts, defaultKeys)
|
||||||
categorizedBinds[category] = append(categorizedBinds[category], bind)
|
categorizedBinds[category] = append(categorizedBinds[category], bind)
|
||||||
}
|
}
|
||||||
@@ -102,6 +100,19 @@ func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var luaFunctionActionPattern = regexp.MustCompile(`^(function\s*\(|[A-Za-z_][A-Za-z0-9_.]*$)`)
|
||||||
|
|
||||||
|
// A function or named-function action says nothing about what it does; its description does.
|
||||||
|
func (h *HyprlandProvider) categorizeBind(kb *HyprlandKeyBinding) string {
|
||||||
|
if kb.Comment == "" || kb.Params != "" || !luaFunctionActionPattern.MatchString(kb.Dispatcher) {
|
||||||
|
return h.categorizeByDispatcher(kb.Dispatcher)
|
||||||
|
}
|
||||||
|
if _, known := luaActionStringFromHyprlangAction(kb.Dispatcher); known {
|
||||||
|
return h.categorizeByDispatcher(kb.Dispatcher)
|
||||||
|
}
|
||||||
|
return h.categorizeByDispatcher(strings.ToLower(kb.Comment))
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string {
|
func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string {
|
||||||
switch {
|
switch {
|
||||||
case dispatcher == "exec" || strings.HasPrefix(dispatcher, "hl.dsp.exec_cmd("):
|
case dispatcher == "exec" || strings.HasPrefix(dispatcher, "hl.dsp.exec_cmd("):
|
||||||
@@ -122,7 +133,7 @@ func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string {
|
|||||||
dispatcher == "splitratio" ||
|
dispatcher == "splitratio" ||
|
||||||
dispatcher == "resizeactive":
|
dispatcher == "resizeactive":
|
||||||
return "Window"
|
return "Window"
|
||||||
case dispatcher == "exit" || strings.Contains(dispatcher, "dpms"):
|
case dispatcher == "exit" || dispatcher == "reloadconfig" || strings.Contains(dispatcher, "dpms"):
|
||||||
return "System"
|
return "System"
|
||||||
default:
|
default:
|
||||||
return "Other"
|
return "Other"
|
||||||
@@ -159,11 +170,9 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st
|
|||||||
Flags: kb.Flags,
|
Flags: kb.Flags,
|
||||||
HasDefault: hasDefault,
|
HasDefault: hasDefault,
|
||||||
}
|
}
|
||||||
if h.luaConfig {
|
|
||||||
if expr, ok := luaActionStringFromHyprlangAction(rawAction); ok {
|
if expr, ok := luaActionStringFromHyprlangAction(rawAction); ok {
|
||||||
bind.LuaAction = expr
|
bind.LuaAction = expr
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (source == "dms" || source == "dms-default") && conflicts != nil {
|
if (source == "dms" || source == "dms-default") && conflicts != nil {
|
||||||
normalizedKey := strings.ToLower(keyStr)
|
normalizedKey := strings.ToLower(keyStr)
|
||||||
@@ -374,31 +383,21 @@ type hyprlandOverrideBind struct {
|
|||||||
RawLuaAction bool
|
RawLuaAction bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include repair would write a stub hyprland.lua, replacing a conf-only user's whole config.
|
||||||
func (h *HyprlandProvider) ensureWritableConfig() error {
|
func (h *HyprlandProvider) ensureWritableConfig() error {
|
||||||
if h.isLegacyConfigReadOnly() {
|
_, err := hyprlandMainConfigPath(h.configPath)
|
||||||
return fmt.Errorf("hyprland legacy conf configs are read-only; run dms setup to migrate to Lua before editing keybinds")
|
switch {
|
||||||
}
|
case err == nil:
|
||||||
return nil
|
return nil
|
||||||
|
case errors.Is(err, os.ErrNotExist):
|
||||||
|
return fmt.Errorf("hyprland.lua not found in %s; run `dms setup` to migrate before editing keybinds: %w", h.configPath, err)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("cannot read hyprland.lua in %s: %w", h.configPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HyprlandProvider) isLegacyConfigReadOnly() bool {
|
|
||||||
expanded, err := utils.ExpandPath(h.configPath)
|
|
||||||
if err != nil {
|
|
||||||
expanded = h.configPath
|
|
||||||
}
|
|
||||||
luaPath := filepath.Join(expanded, "hyprland.lua")
|
|
||||||
if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
confPath := filepath.Join(expanded, "hyprland.conf")
|
|
||||||
if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) {
|
func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) {
|
||||||
return readLuaOrHyprlangOverride(h.GetOverridePath())
|
return readLuaOverride(h.GetOverridePath())
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalHyprlandOverrideKey(key string) string {
|
func canonicalHyprlandOverrideKey(key string) string {
|
||||||
@@ -1098,13 +1097,22 @@ func luaActionStringFromHyprlangAction(action string) (string, bool) {
|
|||||||
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("direction", params), luaBoolField("group_aware", true)), true
|
return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("direction", params), luaBoolField("group_aware", true)), true
|
||||||
}
|
}
|
||||||
case "lockgroups":
|
case "lockgroups":
|
||||||
return dispatcherToggleTableCall("hl.dsp.group.lock", params), true
|
// 0.57 locks only the active group without all; 0.56 ignores the field
|
||||||
|
return luaDispatcherTableCall("hl.dsp.group.lock", luaBoolField("all", true), luaStringField("action", luaToggleActionValue(params))), true
|
||||||
case "lockactivegroup":
|
case "lockactivegroup":
|
||||||
|
lockArg, window := firstParam(params)
|
||||||
|
if window != "" {
|
||||||
|
return luaDispatcherTableCall("hl.dsp.group.lock", luaStringField("action", luaToggleActionValue(lockArg)), luaStringField("window", window)), true
|
||||||
|
}
|
||||||
|
// lock_active is deprecated on 0.57 but still the only active-group lock on 0.56
|
||||||
return dispatcherToggleTableCall("hl.dsp.group.lock_active", params), true
|
return dispatcherToggleTableCall("hl.dsp.group.lock_active", params), true
|
||||||
case "denywindowfromgroup":
|
case "denywindowfromgroup":
|
||||||
return dispatcherToggleTableCall("hl.dsp.window.deny_from_group", params), true
|
return dispatcherToggleTableCall("hl.dsp.window.deny_from_group", params), true
|
||||||
case "forcerendererreload":
|
case "forcerendererreload":
|
||||||
return `hl.dsp.force_renderer_reload()`, true
|
return `hl.dsp.force_renderer_reload()`, true
|
||||||
|
case "reloadconfig":
|
||||||
|
// hl.dsp.reload_config is 0.57-only; calling nil on 0.55/0.56 aborts loading the file
|
||||||
|
return `hl.dsp.exec_cmd("hyprctl reload")`, true
|
||||||
case "forceidle":
|
case "forceidle":
|
||||||
if params != "" && isBareLuaNumber(params) {
|
if params != "" && isBareLuaNumber(params) {
|
||||||
return fmt.Sprintf(`hl.dsp.force_idle(%s)`, params), true
|
return fmt.Sprintf(`hl.dsp.force_idle(%s)`, params), true
|
||||||
@@ -1231,7 +1239,7 @@ func luaKeyComboToInternalKey(combo string) string {
|
|||||||
return strings.Join(parts, "+")
|
return strings.Join(parts, "+")
|
||||||
}
|
}
|
||||||
|
|
||||||
func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, error) {
|
func readLuaOverride(path string) (map[string]*hyprlandOverrideBind, error) {
|
||||||
binds := make(map[string]*hyprlandOverrideBind)
|
binds := make(map[string]*hyprlandOverrideBind)
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -1241,7 +1249,6 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
lines := expandLuaConfigLines(strings.Split(string(data), "\n"))
|
lines := expandLuaConfigLines(strings.Split(string(data), "\n"))
|
||||||
parser := NewHyprlandParser("")
|
|
||||||
pendingUnbinds := make(map[string]string)
|
pendingUnbinds := make(map[string]string)
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
@@ -1257,28 +1264,7 @@ func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, e
|
|||||||
normalizedKey := hyprlandOverrideMapKey(kb.Key)
|
normalizedKey := hyprlandOverrideMapKey(kb.Key)
|
||||||
binds[normalizedKey] = kb
|
binds[normalizedKey] = kb
|
||||||
delete(pendingUnbinds, normalizedKey)
|
delete(pendingUnbinds, normalizedKey)
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(line, "bind") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kb := parser.parseBindLine(line)
|
|
||||||
if kb == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
action := kb.Dispatcher
|
|
||||||
if kb.Params != "" {
|
|
||||||
action = kb.Dispatcher + " " + kb.Params
|
|
||||||
}
|
|
||||||
keyStr := canonicalHyprlandOverrideKey(parser.formatBindKey(kb))
|
|
||||||
normalizedKey := hyprlandOverrideMapKey(keyStr)
|
|
||||||
binds[normalizedKey] = &hyprlandOverrideBind{
|
|
||||||
Key: keyStr,
|
|
||||||
Action: action,
|
|
||||||
Description: kb.Comment,
|
|
||||||
Flags: kb.Flags,
|
|
||||||
}
|
|
||||||
delete(pendingUnbinds, normalizedKey)
|
|
||||||
}
|
}
|
||||||
for normKey, origKey := range pendingUnbinds {
|
for normKey, origKey := range pendingUnbinds {
|
||||||
binds[normKey] = &hyprlandOverrideBind{Key: origKey, Unbind: true}
|
binds[normKey] = &hyprlandOverrideBind{Key: origKey, Unbind: true}
|
||||||
|
|||||||
@@ -39,7 +39,43 @@ func expandLuaConfigLines(lines []string) []string {
|
|||||||
|
|
||||||
func expandLuaConfigLinesEnv(lines []string) ([]string, luaVarEnv) {
|
func expandLuaConfigLinesEnv(lines []string) ([]string, luaVarEnv) {
|
||||||
env := luaVarEnv{}
|
env := luaVarEnv{}
|
||||||
return expandLuaBlock(lines, env), env
|
return expandLuaBlock(joinLuaBindSpans(lines), env), env
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bind parser is line based; fold wrapped hl.bind( calls, keeping only the closing line's comment.
|
||||||
|
func joinLuaBindSpans(lines []string) []string {
|
||||||
|
out := make([]string, 0, len(lines))
|
||||||
|
for i := 0; i < len(lines); i++ {
|
||||||
|
masked := luaMaskStrings(lines[i])
|
||||||
|
depth := luaParenDelta(masked)
|
||||||
|
if depth <= 0 || !strings.Contains(masked, "hl.bind") {
|
||||||
|
out = append(out, lines[i])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := []string{strings.TrimSpace(luaStripLineComment(lines[i]))}
|
||||||
|
j := i + 1
|
||||||
|
for ; j < len(lines) && depth > 0; j++ {
|
||||||
|
depth += luaParenDelta(luaMaskStrings(lines[j]))
|
||||||
|
part := lines[j]
|
||||||
|
if depth > 0 {
|
||||||
|
part = luaStripLineComment(part)
|
||||||
|
}
|
||||||
|
if part = strings.TrimSpace(part); part != "" {
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if depth > 0 {
|
||||||
|
out = append(out, lines[i])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, strings.Join(parts, " "))
|
||||||
|
i = j - 1
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func luaParenDelta(masked string) int {
|
||||||
|
return strings.Count(masked, "(") - strings.Count(masked, ")")
|
||||||
}
|
}
|
||||||
|
|
||||||
func expandLuaBlock(lines []string, env luaVarEnv) []string {
|
func expandLuaBlock(lines []string, env luaVarEnv) []string {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -13,14 +12,6 @@ import (
|
|||||||
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
TitleRegex = "#+!"
|
|
||||||
HideComment = "[hidden]"
|
|
||||||
CommentBindPattern = "#/#"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ModSeparators = []rune{'+', ' '}
|
|
||||||
|
|
||||||
type HyprlandKeyBinding struct {
|
type HyprlandKeyBinding struct {
|
||||||
Mods []string `json:"mods"`
|
Mods []string `json:"mods"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
@@ -39,8 +30,6 @@ type HyprlandSection struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type HyprlandParser struct {
|
type HyprlandParser struct {
|
||||||
contentLines []string
|
|
||||||
readingLine int
|
|
||||||
configDir string
|
configDir string
|
||||||
currentSource string
|
currentSource string
|
||||||
dmsBindsExists bool
|
dmsBindsExists bool
|
||||||
@@ -54,20 +43,15 @@ type HyprlandParser struct {
|
|||||||
processedFiles map[string]bool
|
processedFiles map[string]bool
|
||||||
dmsProcessed bool
|
dmsProcessed bool
|
||||||
removedKeys map[string]bool // hl.unbind targets
|
removedKeys map[string]bool // hl.unbind targets
|
||||||
// rebindSources maps a removed key to the files that unbind and then rebind
|
// Files that unbind then rebind a removed key; only their own bind survives the removal.
|
||||||
// it. Their own bind survives the removal; binds from anywhere else do not.
|
|
||||||
rebindSources map[string]map[string]bool
|
rebindSources map[string]map[string]bool
|
||||||
fileUnbinds map[string]bool // hl.unbind targets seen so far in the file being parsed
|
fileUnbinds map[string]bool // hl.unbind targets seen so far in the file being parsed
|
||||||
defaultDMSKeys map[string]bool // keys present in dms/binds.{lua,conf}
|
defaultDMSKeys map[string]bool // keys present in dms/binds.lua
|
||||||
configFormat string
|
|
||||||
readOnly bool
|
|
||||||
mainMod string
|
mainMod string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHyprlandParser(configDir string) *HyprlandParser {
|
func NewHyprlandParser(configDir string) *HyprlandParser {
|
||||||
return &HyprlandParser{
|
return &HyprlandParser{
|
||||||
contentLines: []string{},
|
|
||||||
readingLine: 0,
|
|
||||||
configDir: configDir,
|
configDir: configDir,
|
||||||
walker: configfrag.NewWalker(isDMSBindsPrimarySourcePath),
|
walker: configfrag.NewWalker(isDMSBindsPrimarySourcePath),
|
||||||
dmsBindKeys: make(map[string]bool),
|
dmsBindKeys: make(map[string]bool),
|
||||||
@@ -82,47 +66,6 @@ func NewHyprlandParser(configDir string) *HyprlandParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HyprlandParser) ReadContent(directory string) error {
|
|
||||||
expandedDir, err := utils.ExpandPath(directory)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Stat(expandedDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !info.IsDir() {
|
|
||||||
return os.ErrNotExist
|
|
||||||
}
|
|
||||||
|
|
||||||
confFiles, err := filepath.Glob(filepath.Join(expandedDir, "*.conf"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(confFiles) == 0 {
|
|
||||||
return os.ErrNotExist
|
|
||||||
}
|
|
||||||
|
|
||||||
var combinedContent []string
|
|
||||||
for _, confFile := range confFiles {
|
|
||||||
if fileInfo, err := os.Stat(confFile); err == nil && fileInfo.Mode().IsRegular() {
|
|
||||||
data, err := os.ReadFile(confFile)
|
|
||||||
if err == nil {
|
|
||||||
combinedContent = append(combinedContent, string(data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(combinedContent) == 0 {
|
|
||||||
return os.ErrNotExist
|
|
||||||
}
|
|
||||||
|
|
||||||
fullContent := strings.Join(combinedContent, "\n")
|
|
||||||
p.contentLines = strings.Split(fullContent, "\n")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var hyprlandDirectionWords = map[string]string{
|
var hyprlandDirectionWords = map[string]string{
|
||||||
"l": "left", "left": "left",
|
"l": "left", "left": "left",
|
||||||
"r": "right", "right": "right",
|
"r": "right", "right": "right",
|
||||||
@@ -401,95 +344,24 @@ func describeHyprlandMove(params string) string {
|
|||||||
return "Move window " + strings.Join(parts, ", ")
|
return "Move window " + strings.Join(parts, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HyprlandParser) getKeybindAtLine(lineNumber int) *HyprlandKeyBinding {
|
|
||||||
line := p.contentLines[lineNumber]
|
|
||||||
return p.parseBindLine(line)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HyprlandParser) getBindsRecursive(currentContent *HyprlandSection, scope int) *HyprlandSection {
|
|
||||||
titleRegex := regexp.MustCompile(TitleRegex)
|
|
||||||
|
|
||||||
for p.readingLine < len(p.contentLines) {
|
|
||||||
line := p.contentLines[p.readingLine]
|
|
||||||
|
|
||||||
loc := titleRegex.FindStringIndex(line)
|
|
||||||
if loc != nil && loc[0] == 0 {
|
|
||||||
headingScope := strings.Index(line, "!")
|
|
||||||
|
|
||||||
if headingScope <= scope {
|
|
||||||
p.readingLine--
|
|
||||||
return currentContent
|
|
||||||
}
|
|
||||||
|
|
||||||
sectionName := strings.TrimSpace(line[headingScope+1:])
|
|
||||||
p.readingLine++
|
|
||||||
|
|
||||||
childSection := &HyprlandSection{
|
|
||||||
Children: []HyprlandSection{},
|
|
||||||
Keybinds: []HyprlandKeyBinding{},
|
|
||||||
Name: sectionName,
|
|
||||||
}
|
|
||||||
result := p.getBindsRecursive(childSection, headingScope)
|
|
||||||
currentContent.Children = append(currentContent.Children, *result)
|
|
||||||
|
|
||||||
} else if strings.HasPrefix(line, CommentBindPattern) {
|
|
||||||
keybind := p.getKeybindAtLine(p.readingLine)
|
|
||||||
if keybind != nil {
|
|
||||||
currentContent.Keybinds = append(currentContent.Keybinds, *keybind)
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if line == "" || !strings.HasPrefix(strings.TrimSpace(line), "bind") {
|
|
||||||
|
|
||||||
} else {
|
|
||||||
keybind := p.getKeybindAtLine(p.readingLine)
|
|
||||||
if keybind != nil {
|
|
||||||
currentContent.Keybinds = append(currentContent.Keybinds, *keybind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p.readingLine++
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentContent
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HyprlandParser) ParseKeys() *HyprlandSection {
|
|
||||||
p.readingLine = 0
|
|
||||||
rootSection := &HyprlandSection{
|
|
||||||
Children: []HyprlandSection{},
|
|
||||||
Keybinds: []HyprlandKeyBinding{},
|
|
||||||
Name: "",
|
|
||||||
}
|
|
||||||
return p.getBindsRecursive(rootSection, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ParseHyprlandKeys(path string) (*HyprlandSection, error) {
|
|
||||||
parser := NewHyprlandParser(path)
|
|
||||||
if err := parser.ReadContent(path); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return parser.ParseKeys(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type HyprlandParseResult struct {
|
type HyprlandParseResult struct {
|
||||||
Section *HyprlandSection
|
Section *HyprlandSection
|
||||||
DMSBindsIncluded bool
|
DMSBindsIncluded bool
|
||||||
DMSStatus *configfrag.Status
|
DMSStatus *configfrag.Status
|
||||||
ConflictingConfigs map[string]*HyprlandKeyBinding
|
ConflictingConfigs map[string]*HyprlandKeyBinding
|
||||||
DefaultDMSKeys map[string]bool // keys with a DMS default in binds.{lua,conf}
|
DefaultDMSKeys map[string]bool // keys with a DMS default in binds.lua
|
||||||
ConfigFormat string // "lua" or "hyprlang"
|
|
||||||
MainMod string // value of the mainMod Lua variable when the config defines one
|
MainMod string // value of the mainMod Lua variable when the config defines one
|
||||||
}
|
}
|
||||||
|
|
||||||
var hyprlandBindsMessages = configfrag.Messages{
|
var hyprlandBindsMessages = configfrag.Messages{
|
||||||
Missing: "dms/binds.lua (or legacy binds.conf) does not exist",
|
Missing: "dms/binds.lua does not exist",
|
||||||
NotIncluded: "dms binds are not loaded from Hyprland config (require / source)",
|
NotIncluded: "dms binds are not loaded from Hyprland config (require)",
|
||||||
Overridden: "Some DMS binds may be overridden by config binds",
|
Overridden: "Some DMS binds may be overridden by config binds",
|
||||||
Active: "DMS binds are active",
|
Active: "DMS binds are active",
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HyprlandParser) buildDMSStatus() *configfrag.Status {
|
func (p *HyprlandParser) buildDMSStatus() *configfrag.Status {
|
||||||
status := configfrag.BuildStatus(p.walker.Scan(), p.dmsBindsExists, p.bindsAfterDMS, p.configFormat, p.readOnly, hyprlandBindsMessages)
|
status := configfrag.BuildStatus(p.walker.Scan(), p.dmsBindsExists, p.bindsAfterDMS, hyprlandBindsMessages)
|
||||||
return &status
|
return &status
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,28 +415,15 @@ func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
dmsBindsLua := filepath.Join(expandedDir, "dms", "binds.lua")
|
dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.lua")
|
||||||
dmsBindsConf := filepath.Join(expandedDir, "dms", "binds.conf")
|
if _, err := os.Stat(dmsBindsPath); err == nil {
|
||||||
dmsBindsPath := ""
|
|
||||||
if _, err := os.Stat(dmsBindsLua); err == nil {
|
|
||||||
p.dmsBindsExists = true
|
p.dmsBindsExists = true
|
||||||
dmsBindsPath = dmsBindsLua
|
|
||||||
} else if _, err := os.Stat(dmsBindsConf); err == nil {
|
|
||||||
p.dmsBindsExists = true
|
|
||||||
dmsBindsPath = dmsBindsConf
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mainConfig, err := hyprlandMainConfigPath(p.configDir)
|
mainConfig, err := hyprlandMainConfigPath(p.configDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if strings.EqualFold(filepath.Ext(mainConfig), ".lua") {
|
|
||||||
p.configFormat = "lua"
|
|
||||||
p.readOnly = false
|
|
||||||
} else {
|
|
||||||
p.configFormat = "hyprlang"
|
|
||||||
p.readOnly = true
|
|
||||||
}
|
|
||||||
section, err := p.parseFileWithSource(mainConfig, "")
|
section, err := p.parseFileWithSource(mainConfig, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -649,58 +508,10 @@ func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*Hyp
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.EqualFold(filepath.Ext(absPath), ".lua") {
|
|
||||||
return p.parseLuaLines(string(data), filepath.Dir(absPath), absPath, sectionName)
|
return p.parseLuaLines(string(data), filepath.Dir(absPath), absPath, sectionName)
|
||||||
}
|
}
|
||||||
|
|
||||||
prevSource := p.currentSource
|
|
||||||
p.currentSource = absPath
|
|
||||||
|
|
||||||
section := &HyprlandSection{Name: sectionName}
|
|
||||||
lines := strings.SplitSeq(string(data), "\n")
|
|
||||||
|
|
||||||
for line := range lines {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
|
|
||||||
if strings.HasPrefix(trimmed, "source") {
|
|
||||||
p.handleSource(trimmed, section, filepath.Dir(absPath))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(trimmed, "bind") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
kb := p.parseBindLine(line)
|
|
||||||
if kb == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kb.Source = p.currentSource
|
|
||||||
if p.addBind(kb) {
|
|
||||||
section.Keybinds = append(section.Keybinds, *kb)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p.currentSource = prevSource
|
|
||||||
return section, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, baseDir string) {
|
|
||||||
matched := p.walker.IncludeAssignment(baseDir, line, func(absPath string) error {
|
|
||||||
includedSection, err := p.parseFileWithSource(absPath, "")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
section.Children = append(section.Children, *includedSection)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if matched {
|
|
||||||
p.dmsProcessed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *HyprlandSection) {
|
func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *HyprlandSection) {
|
||||||
if strings.EqualFold(filepath.Ext(dmsBindsPath), ".lua") {
|
|
||||||
sub, err := p.parseLuaLinesFromPath(dmsBindsPath)
|
sub, err := p.parseLuaLinesFromPath(dmsBindsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -708,36 +519,6 @@ func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *Hyp
|
|||||||
section.Keybinds = append(section.Keybinds, sub.Keybinds...)
|
section.Keybinds = append(section.Keybinds, sub.Keybinds...)
|
||||||
section.Children = append(section.Children, sub.Children...)
|
section.Children = append(section.Children, sub.Children...)
|
||||||
p.dmsProcessed = true
|
p.dmsProcessed = true
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(dmsBindsPath)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
prevSource := p.currentSource
|
|
||||||
p.currentSource = dmsBindsPath
|
|
||||||
|
|
||||||
lines := strings.SplitSeq(string(data), "\n")
|
|
||||||
for line := range lines {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
if !strings.HasPrefix(trimmed, "bind") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
kb := p.parseBindLine(line)
|
|
||||||
if kb == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kb.Source = dmsBindsPath
|
|
||||||
if p.addBind(kb) {
|
|
||||||
section.Keybinds = append(section.Keybinds, *kb)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p.currentSource = prevSource
|
|
||||||
p.dmsProcessed = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HyprlandParser) parseLuaLinesFromPath(absPath string) (*HyprlandSection, error) {
|
func (p *HyprlandParser) parseLuaLinesFromPath(absPath string) (*HyprlandSection, error) {
|
||||||
@@ -930,110 +711,6 @@ func luaBindOptDescription(optSuffix string) string {
|
|||||||
return luaTableStringField(optSuffix, "description")
|
return luaTableStringField(optSuffix, "description")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hyprland 0.56 field order: `bind[flags] = MODS, key, [device,] [description,] dispatcher, params`.
|
|
||||||
func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding {
|
|
||||||
parts := strings.SplitN(line, "=", 2)
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var flags string
|
|
||||||
if bindType := strings.TrimSpace(parts[0]); bindType != CommentBindPattern {
|
|
||||||
var ok bool
|
|
||||||
if flags, ok = extractBindFlags(bindType); !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
keys, comment, _ := strings.Cut(parts[1], "#")
|
|
||||||
comment = strings.TrimSpace(comment)
|
|
||||||
|
|
||||||
dispatcherIndex := 2
|
|
||||||
descIndex := -1
|
|
||||||
if strings.Contains(flags, "k") {
|
|
||||||
dispatcherIndex++
|
|
||||||
}
|
|
||||||
if strings.Contains(flags, "d") {
|
|
||||||
descIndex = dispatcherIndex
|
|
||||||
dispatcherIndex++
|
|
||||||
}
|
|
||||||
|
|
||||||
keyFields := strings.SplitN(keys, ",", dispatcherIndex+2)
|
|
||||||
if len(keyFields) <= dispatcherIndex {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
mods := strings.TrimSpace(keyFields[0])
|
|
||||||
key := strings.TrimSpace(keyFields[1])
|
|
||||||
dispatcher := strings.TrimSpace(keyFields[dispatcherIndex])
|
|
||||||
var params string
|
|
||||||
if len(keyFields) > dispatcherIndex+1 {
|
|
||||||
params = strings.TrimSpace(keyFields[dispatcherIndex+1])
|
|
||||||
}
|
|
||||||
if descIndex >= 0 && comment == "" {
|
|
||||||
comment = strings.TrimSpace(keyFields[descIndex])
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(comment, HideComment) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if comment == "" {
|
|
||||||
comment = hyprlandAutogenerateComment(dispatcher, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
var modList []string
|
|
||||||
if mods != "" {
|
|
||||||
modstring := mods + string(ModSeparators[0])
|
|
||||||
idx := 0
|
|
||||||
for index, char := range modstring {
|
|
||||||
if slices.Contains(ModSeparators, char) {
|
|
||||||
if index-idx > 1 {
|
|
||||||
modList = append(modList, modstring[idx:index])
|
|
||||||
}
|
|
||||||
idx = index + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &HyprlandKeyBinding{
|
|
||||||
Mods: modList,
|
|
||||||
Key: key,
|
|
||||||
Dispatcher: dispatcher,
|
|
||||||
Params: params,
|
|
||||||
Comment: comment,
|
|
||||||
Flags: flags,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ok is false for `binds:...` and for any letter Hyprland rejects, since that drops the whole bind.
|
|
||||||
func extractBindFlags(bindType string) (string, bool) {
|
|
||||||
flags, ok := strings.CutPrefix(strings.TrimSpace(bindType), "bind")
|
|
||||||
if !ok {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
for i := 0; i < len(flags); i++ {
|
|
||||||
if !isHyprlangBindFlag(flags[i]) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return flags, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// s (multi-key), k (device field) and d (description field) exist only in hyprlang.
|
|
||||||
func isHyprlangBindFlag(c byte) bool {
|
|
||||||
switch c {
|
|
||||||
case 's', 'k', 'd':
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, fo := range hyprlandBindFlagOptions {
|
|
||||||
if fo.flag == c {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) {
|
func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) {
|
||||||
parser := NewHyprlandParser(path)
|
parser := NewHyprlandParser(path)
|
||||||
section, err := parser.ParseWithDMS()
|
section, err := parser.ParseWithDMS()
|
||||||
@@ -1047,7 +724,6 @@ func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) {
|
|||||||
DMSStatus: parser.buildDMSStatus(),
|
DMSStatus: parser.buildDMSStatus(),
|
||||||
ConflictingConfigs: parser.conflictingConfigs,
|
ConflictingConfigs: parser.conflictingConfigs,
|
||||||
DefaultDMSKeys: parser.defaultDMSKeys,
|
DefaultDMSKeys: parser.defaultDMSKeys,
|
||||||
ConfigFormat: parser.configFormat,
|
|
||||||
MainMod: parser.mainMod,
|
MainMod: parser.mainMod,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -1261,6 +937,9 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
|
|||||||
if after, ok := strings.CutPrefix(u, "hyprctl dispatch "); ok {
|
if after, ok := strings.CutPrefix(u, "hyprctl dispatch "); ok {
|
||||||
return splitDispatchCommand(strings.TrimSpace(after))
|
return splitDispatchCommand(strings.TrimSpace(after))
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(u) == "hyprctl reload" {
|
||||||
|
return "reloadconfig", ""
|
||||||
|
}
|
||||||
return "exec", u
|
return "exec", u
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1355,7 +1034,13 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
|
|||||||
case strings.HasPrefix(expr, "hl.dsp.group.lock_active("):
|
case strings.HasPrefix(expr, "hl.dsp.group.lock_active("):
|
||||||
return "lockactivegroup", luaToggleActionToLockArg(luaTableStringField(expr, "action"))
|
return "lockactivegroup", luaToggleActionToLockArg(luaTableStringField(expr, "action"))
|
||||||
case strings.HasPrefix(expr, "hl.dsp.group.lock("):
|
case strings.HasPrefix(expr, "hl.dsp.group.lock("):
|
||||||
return "lockgroups", luaToggleActionToLockArg(luaTableStringField(expr, "action"))
|
lockArg := luaToggleActionToLockArg(luaTableStringField(expr, "action"))
|
||||||
|
window := luaTableStringField(expr, "window")
|
||||||
|
// Before 0.57 group.lock locked every group and ignored all; 0.57 locks only the active one without it
|
||||||
|
if all, _ := luaTableBoolField(expr, "all"); window == "" && (all || !luaconfig.HyprlandAtLeast(0, 57)) {
|
||||||
|
return "lockgroups", lockArg
|
||||||
|
}
|
||||||
|
return joinDispatcherParams("lockactivegroup", lockArg, window)
|
||||||
case strings.HasPrefix(expr, "hl.dsp.window.deny_from_group("):
|
case strings.HasPrefix(expr, "hl.dsp.window.deny_from_group("):
|
||||||
return "denywindowfromgroup", luaToggleActionToLegacy(luaTableStringField(expr, "action"))
|
return "denywindowfromgroup", luaToggleActionToLegacy(luaTableStringField(expr, "action"))
|
||||||
case strings.HasPrefix(expr, "hl.dsp.focus("):
|
case strings.HasPrefix(expr, "hl.dsp.focus("):
|
||||||
@@ -1536,6 +1221,8 @@ func luaExprToDispatcherParams(expr string) (dispatcher, params string) {
|
|||||||
return "releaseinputcapture", ""
|
return "releaseinputcapture", ""
|
||||||
case strings.Contains(expr, "hl.dsp.force_renderer_reload()"):
|
case strings.Contains(expr, "hl.dsp.force_renderer_reload()"):
|
||||||
return "forcerendererreload", ""
|
return "forcerendererreload", ""
|
||||||
|
case strings.Contains(expr, "hl.dsp.reload_config()"):
|
||||||
|
return "reloadconfig", ""
|
||||||
case strings.HasPrefix(expr, "hl.dsp.force_idle("):
|
case strings.HasPrefix(expr, "hl.dsp.force_idle("):
|
||||||
return "forceidle", luaCallScalarArgValue(expr, "hl.dsp.force_idle")
|
return "forceidle", luaCallScalarArgValue(expr, "hl.dsp.force_idle")
|
||||||
case strings.Contains(expr, "hl.dsp.exit()"):
|
case strings.Contains(expr, "hl.dsp.exit()"):
|
||||||
@@ -1767,13 +1454,7 @@ func isDMSBindsUserOverridePath(p string) bool {
|
|||||||
|
|
||||||
func isDMSBindsPrimarySourcePath(p string) bool {
|
func isDMSBindsPrimarySourcePath(p string) bool {
|
||||||
p = filepath.ToSlash(strings.TrimSpace(p))
|
p = filepath.ToSlash(strings.TrimSpace(p))
|
||||||
if strings.Contains(p, "/dms/binds.lua") || strings.HasSuffix(p, "dms/binds.lua") || p == "dms/binds.lua" || p == "./dms/binds.lua" {
|
return strings.Contains(p, "/dms/binds.lua") || strings.HasSuffix(p, "dms/binds.lua")
|
||||||
return true
|
|
||||||
}
|
|
||||||
if strings.Contains(p, "/dms/binds.conf") || strings.HasSuffix(p, "dms/binds.conf") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return p == "dms/binds.conf" || p == "./dms/binds.conf"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func hyprlandMainConfigPath(dir string) (string, error) {
|
func hyprlandMainConfigPath(dir string) (string, error) {
|
||||||
@@ -1782,12 +1463,12 @@ func hyprlandMainConfigPath(dir string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
luaPath := filepath.Join(expandedDir, "hyprland.lua")
|
luaPath := filepath.Join(expandedDir, "hyprland.lua")
|
||||||
if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() {
|
st, err := os.Stat(luaPath)
|
||||||
return luaPath, nil
|
if err != nil {
|
||||||
}
|
return "", err
|
||||||
confPath := filepath.Join(expandedDir, "hyprland.conf")
|
|
||||||
if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() {
|
|
||||||
return confPath, nil
|
|
||||||
}
|
}
|
||||||
|
if !st.Mode().IsRegular() {
|
||||||
return "", os.ErrNotExist
|
return "", os.ErrNotExist
|
||||||
}
|
}
|
||||||
|
return luaPath, nil
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package providers
|
package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/luaconfig"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -96,8 +97,11 @@ func TestHyprlandLuaBindRoundTripHelpers(t *testing.T) {
|
|||||||
{`hl.dsp.group.prev()`, "changegroupactive", "b"},
|
{`hl.dsp.group.prev()`, "changegroupactive", "b"},
|
||||||
{`hl.dsp.group.active({ index = 2 })`, "changegroupactive", "2"},
|
{`hl.dsp.group.active({ index = 2 })`, "changegroupactive", "2"},
|
||||||
{`hl.dsp.group.move_window({ forward = false })`, "movegroupwindow", "b"},
|
{`hl.dsp.group.move_window({ forward = false })`, "movegroupwindow", "b"},
|
||||||
{`hl.dsp.group.lock({ action = "on" })`, "lockgroups", "lock"},
|
{`hl.dsp.group.lock({ all = true, action = "on" })`, "lockgroups", "lock"},
|
||||||
|
{`hl.dsp.group.lock({ action = "off", window = "class:^(kitty)$" })`, "lockactivegroup", "unlock class:^(kitty)$"},
|
||||||
{`hl.dsp.group.lock_active({ action = "off" })`, "lockactivegroup", "unlock"},
|
{`hl.dsp.group.lock_active({ action = "off" })`, "lockactivegroup", "unlock"},
|
||||||
|
{`hl.dsp.reload_config()`, "reloadconfig", ""},
|
||||||
|
{`hl.dsp.exec_cmd("hyprctl reload")`, "reloadconfig", ""},
|
||||||
{`hl.dsp.window.deny_from_group({ action = "toggle" })`, "denywindowfromgroup", "toggle"},
|
{`hl.dsp.window.deny_from_group({ action = "toggle" })`, "denywindowfromgroup", "toggle"},
|
||||||
{`function() hl.exec_cmd("hyprctl dispatch splitratio +0.1") end`, "splitratio", "+0.1"},
|
{`function() hl.exec_cmd("hyprctl dispatch splitratio +0.1") end`, "splitratio", "+0.1"},
|
||||||
{`hl.dsp.layout("togglesplit")`, "layoutmsg", "togglesplit"},
|
{`hl.dsp.layout("togglesplit")`, "layoutmsg", "togglesplit"},
|
||||||
@@ -202,8 +206,10 @@ func TestLuaActionStringFromHyprlangActionUsesNativeDispatchers(t *testing.T) {
|
|||||||
{"moveoutofgroup", `hl.dsp.window.move({ out_of_group = true })`},
|
{"moveoutofgroup", `hl.dsp.window.move({ out_of_group = true })`},
|
||||||
{"movewindoworgroup r", `hl.dsp.window.move({ direction = "r", group_aware = true })`},
|
{"movewindoworgroup r", `hl.dsp.window.move({ direction = "r", group_aware = true })`},
|
||||||
{"movegroupwindow b", `hl.dsp.group.move_window({ forward = false })`},
|
{"movegroupwindow b", `hl.dsp.group.move_window({ forward = false })`},
|
||||||
{"lockgroups lock", `hl.dsp.group.lock({ action = "on" })`},
|
{"lockgroups lock", `hl.dsp.group.lock({ all = true, action = "on" })`},
|
||||||
{"lockactivegroup unlock", `hl.dsp.group.lock_active({ action = "off" })`},
|
{"lockactivegroup unlock", `hl.dsp.group.lock_active({ action = "off" })`},
|
||||||
|
{"lockactivegroup lock class:^(kitty)$", `hl.dsp.group.lock({ action = "on", window = "class:^(kitty)$" })`},
|
||||||
|
{"reloadconfig", `hl.dsp.exec_cmd("hyprctl reload")`},
|
||||||
{"denywindowfromgroup toggle", `hl.dsp.window.deny_from_group({ action = "toggle" })`},
|
{"denywindowfromgroup toggle", `hl.dsp.window.deny_from_group({ action = "toggle" })`},
|
||||||
{"cyclenext prev", `hl.dsp.window.cycle_next({ next = false })`},
|
{"cyclenext prev", `hl.dsp.window.cycle_next({ next = false })`},
|
||||||
{"setfloating", `hl.dsp.window.float({ action = "on" })`},
|
{"setfloating", `hl.dsp.window.float({ action = "on" })`},
|
||||||
@@ -306,6 +312,34 @@ func TestParseLuaBindLineUpgradesMappableDispatchWrapper(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseLuaBindLineBareGroupLockFollowsHyprlandVersion(t *testing.T) {
|
||||||
|
prev := luaconfig.HyprlandVersion
|
||||||
|
t.Cleanup(func() { luaconfig.HyprlandVersion = prev })
|
||||||
|
for _, tc := range []struct {
|
||||||
|
minor int
|
||||||
|
action string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{56, "lockgroups toggle", `hl.dsp.group.lock({ all = true, action = "toggle" })`},
|
||||||
|
{57, "lockactivegroup toggle", `hl.dsp.group.lock_active({ action = "toggle" })`},
|
||||||
|
} {
|
||||||
|
minor := tc.minor
|
||||||
|
luaconfig.HyprlandVersion = func() (int, int) { return 0, minor }
|
||||||
|
got, ok := parseLuaBindOverrideLine(`hl.bind("SUPER + G", hl.dsp.group.lock({ action = "toggle" }))`)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("0.%d: expected line to parse", minor)
|
||||||
|
}
|
||||||
|
if got.Action != tc.action {
|
||||||
|
t.Fatalf("0.%d: action = %q, want %q", minor, got.Action, tc.action)
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
writeLuaBindLine(&sb, got)
|
||||||
|
if !strings.Contains(sb.String(), tc.want) {
|
||||||
|
t.Fatalf("0.%d: expected %q, got %q", minor, tc.want, sb.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReadLuaOverrideMigratesTrailingCommentToDescription(t *testing.T) {
|
func TestReadLuaOverrideMigratesTrailingCommentToDescription(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
overridePath := filepath.Join(tmpDir, "binds-user.lua")
|
overridePath := filepath.Join(tmpDir, "binds-user.lua")
|
||||||
@@ -317,7 +351,7 @@ hl.bind("SUPER + H", hl.dsp.exec_cmd("app --help"))
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
binds, err := readLuaOrHyprlangOverride(overridePath)
|
binds, err := readLuaOverride(overridePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -401,7 +435,7 @@ hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
binds, err := readLuaOrHyprlangOverride(overridePath)
|
binds, err := readLuaOverride(overridePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -474,7 +508,7 @@ hl.bind("SUPER + T", hl.dsp.exec_cmd("kitty"))`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHyprlandRemoveBindWritesNegativeOverrideForDefault(t *testing.T) {
|
func TestHyprlandRemoveBindWritesNegativeOverrideForDefault(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := hyprLuaTestDir(t)
|
||||||
dmsDir := filepath.Join(tmpDir, "dms")
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -519,7 +553,7 @@ func TestIsRawLuaActionText(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHyprlandSetBindPreservesRawLuaAction(t *testing.T) {
|
func TestHyprlandSetBindPreservesRawLuaAction(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := hyprLuaTestDir(t)
|
||||||
dmsDir := filepath.Join(tmpDir, "dms")
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -548,7 +582,7 @@ func TestHyprlandSetBindPreservesRawLuaAction(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHyprlandSetBindUpdatesSpacedLuaOverrideWithoutDuplicates(t *testing.T) {
|
func TestHyprlandSetBindUpdatesSpacedLuaOverrideWithoutDuplicates(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := hyprLuaTestDir(t)
|
||||||
dmsDir := filepath.Join(tmpDir, "dms")
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -587,7 +621,7 @@ hl.bind("SUPER + 1", hl.dsp.exec_cmd("hyprctl dispatch workspace 1"))
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHyprlandSetBindTranslatesScrollWheelToMouse(t *testing.T) {
|
func TestHyprlandSetBindTranslatesScrollWheelToMouse(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := hyprLuaTestDir(t)
|
||||||
dmsDir := filepath.Join(tmpDir, "dms")
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -642,7 +676,7 @@ func readFile(t *testing.T, path string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) {
|
func TestHyprlandRemoveBindReplacesExistingOverrideWithNegativeOverride(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := hyprLuaTestDir(t)
|
||||||
dmsDir := filepath.Join(tmpDir, "dms")
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -672,7 +706,7 @@ hl.bind("SUPER + N", hl.dsp.exec_cmd("dms ipc call notepad toggle"))
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHyprlandResetBindRevertsExistingOverrideToDefault(t *testing.T) {
|
func TestHyprlandResetBindRevertsExistingOverrideToDefault(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := hyprLuaTestDir(t)
|
||||||
dmsDir := filepath.Join(tmpDir, "dms")
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -797,7 +831,7 @@ func TestHyprlandBindFlagsRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHyprlandSetBindKeepsDeviceTable(t *testing.T) {
|
func TestHyprlandSetBindKeepsDeviceTable(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := hyprLuaTestDir(t)
|
||||||
dmsDir := filepath.Join(tmpDir, "dms")
|
dmsDir := filepath.Join(tmpDir, "dms")
|
||||||
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -840,420 +874,19 @@ func TestHyprlandSetBindLeavesConfOnlyInstallReadOnly(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected SetBind to reject conf-only Hyprland config")
|
t.Fatal("expected SetBind to reject conf-only Hyprland config")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "read-only") {
|
if !strings.Contains(err.Error(), "dms setup") {
|
||||||
t.Fatalf("expected read-only error, got %v", err)
|
t.Fatalf("expected a run-dms-setup error, got %v", err)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(tmpDir, "dms", "binds-user.lua")); !os.IsNotExist(err) {
|
if _, err := os.Stat(filepath.Join(tmpDir, "dms", "binds-user.lua")); !os.IsNotExist(err) {
|
||||||
t.Fatalf("expected no Lua override to be created for conf-only config, stat err=%v", err)
|
t.Fatalf("expected no Lua override to be created for conf-only config, stat err=%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHyprlandGetKeybindAtLine(t *testing.T) {
|
func hyprLuaTestDir(t *testing.T) string {
|
||||||
tests := []struct {
|
t.Helper()
|
||||||
name string
|
dir := t.TempDir()
|
||||||
line string
|
if err := os.WriteFile(filepath.Join(dir, "hyprland.lua"), nil, 0o644); err != nil {
|
||||||
expected *HyprlandKeyBinding
|
t.Fatal(err)
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "basic_keybind",
|
|
||||||
line: "bind = SUPER, Q, killactive",
|
|
||||||
expected: &HyprlandKeyBinding{
|
|
||||||
Mods: []string{"SUPER"},
|
|
||||||
Key: "Q",
|
|
||||||
Dispatcher: "killactive",
|
|
||||||
Params: "",
|
|
||||||
Comment: "Close window",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "keybind_with_params",
|
|
||||||
line: "bind = SUPER, left, movefocus, l",
|
|
||||||
expected: &HyprlandKeyBinding{
|
|
||||||
Mods: []string{"SUPER"},
|
|
||||||
Key: "left",
|
|
||||||
Dispatcher: "movefocus",
|
|
||||||
Params: "l",
|
|
||||||
Comment: "move focus left",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "keybind_with_comment",
|
|
||||||
line: "bind = SUPER, T, exec, kitty # Open terminal",
|
|
||||||
expected: &HyprlandKeyBinding{
|
|
||||||
Mods: []string{"SUPER"},
|
|
||||||
Key: "T",
|
|
||||||
Dispatcher: "exec",
|
|
||||||
Params: "kitty",
|
|
||||||
Comment: "Open terminal",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "keybind_hidden",
|
|
||||||
line: "bind = SUPER, H, exec, secret # [hidden]",
|
|
||||||
expected: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "keybind_multiple_mods",
|
|
||||||
line: "bind = SUPER+SHIFT, F, fullscreen, 0",
|
|
||||||
expected: &HyprlandKeyBinding{
|
|
||||||
Mods: []string{"SUPER", "SHIFT"},
|
|
||||||
Key: "F",
|
|
||||||
Dispatcher: "fullscreen",
|
|
||||||
Params: "0",
|
|
||||||
Comment: "Toggle fullscreen",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "keybind_no_mods",
|
|
||||||
line: "bind = , Print, exec, screenshot",
|
|
||||||
expected: &HyprlandKeyBinding{
|
|
||||||
Mods: []string{},
|
|
||||||
Key: "Print",
|
|
||||||
Dispatcher: "exec",
|
|
||||||
Params: "screenshot",
|
|
||||||
Comment: "screenshot",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
parser := NewHyprlandParser("")
|
|
||||||
parser.contentLines = []string{tt.line}
|
|
||||||
result := parser.getKeybindAtLine(0)
|
|
||||||
|
|
||||||
if tt.expected == nil {
|
|
||||||
if result != nil {
|
|
||||||
t.Errorf("expected nil, got %+v", result)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if result == nil {
|
|
||||||
t.Errorf("expected %+v, got nil", tt.expected)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Key != tt.expected.Key {
|
|
||||||
t.Errorf("Key = %q, want %q", result.Key, tt.expected.Key)
|
|
||||||
}
|
|
||||||
if result.Dispatcher != tt.expected.Dispatcher {
|
|
||||||
t.Errorf("Dispatcher = %q, want %q", result.Dispatcher, tt.expected.Dispatcher)
|
|
||||||
}
|
|
||||||
if result.Params != tt.expected.Params {
|
|
||||||
t.Errorf("Params = %q, want %q", result.Params, tt.expected.Params)
|
|
||||||
}
|
|
||||||
if result.Comment != tt.expected.Comment {
|
|
||||||
t.Errorf("Comment = %q, want %q", result.Comment, tt.expected.Comment)
|
|
||||||
}
|
|
||||||
if len(result.Mods) != len(tt.expected.Mods) {
|
|
||||||
t.Errorf("Mods length = %d, want %d", len(result.Mods), len(tt.expected.Mods))
|
|
||||||
} else {
|
|
||||||
for i := range result.Mods {
|
|
||||||
if result.Mods[i] != tt.expected.Mods[i] {
|
|
||||||
t.Errorf("Mods[%d] = %q, want %q", i, result.Mods[i], tt.expected.Mods[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHyprlandParseKeysWithSections(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
configFile := filepath.Join(tmpDir, "hyprland.conf")
|
|
||||||
|
|
||||||
content := `##! Window Management
|
|
||||||
bind = SUPER, Q, killactive
|
|
||||||
bind = SUPER, F, fullscreen, 0
|
|
||||||
|
|
||||||
###! Movement
|
|
||||||
bind = SUPER, left, movefocus, l
|
|
||||||
bind = SUPER, right, movefocus, r
|
|
||||||
|
|
||||||
##! Applications
|
|
||||||
bind = SUPER, T, exec, kitty # Terminal
|
|
||||||
`
|
|
||||||
|
|
||||||
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
|
|
||||||
t.Fatalf("Failed to write test config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
section, err := ParseHyprlandKeys(tmpDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseHyprlandKeys failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(section.Children) != 2 {
|
|
||||||
t.Errorf("Expected 2 top-level sections, got %d", len(section.Children))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(section.Children) >= 1 {
|
|
||||||
windowMgmt := section.Children[0]
|
|
||||||
if windowMgmt.Name != "Window Management" {
|
|
||||||
t.Errorf("First section name = %q, want %q", windowMgmt.Name, "Window Management")
|
|
||||||
}
|
|
||||||
if len(windowMgmt.Keybinds) != 2 {
|
|
||||||
t.Errorf("Window Management keybinds = %d, want 2", len(windowMgmt.Keybinds))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(windowMgmt.Children) != 1 {
|
|
||||||
t.Errorf("Window Management children = %d, want 1", len(windowMgmt.Children))
|
|
||||||
} else {
|
|
||||||
movement := windowMgmt.Children[0]
|
|
||||||
if movement.Name != "Movement" {
|
|
||||||
t.Errorf("Movement section name = %q, want %q", movement.Name, "Movement")
|
|
||||||
}
|
|
||||||
if len(movement.Keybinds) != 2 {
|
|
||||||
t.Errorf("Movement keybinds = %d, want 2", len(movement.Keybinds))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(section.Children) >= 2 {
|
|
||||||
apps := section.Children[1]
|
|
||||||
if apps.Name != "Applications" {
|
|
||||||
t.Errorf("Second section name = %q, want %q", apps.Name, "Applications")
|
|
||||||
}
|
|
||||||
if len(apps.Keybinds) != 1 {
|
|
||||||
t.Errorf("Applications keybinds = %d, want 1", len(apps.Keybinds))
|
|
||||||
}
|
|
||||||
if len(apps.Keybinds) > 0 && apps.Keybinds[0].Comment != "Terminal" {
|
|
||||||
t.Errorf("Applications keybind comment = %q, want %q", apps.Keybinds[0].Comment, "Terminal")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHyprlandParseKeysWithCommentBinds(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
configFile := filepath.Join(tmpDir, "test.conf")
|
|
||||||
|
|
||||||
content := `#/# = SUPER, A, exec, app1
|
|
||||||
bind = SUPER, B, exec, app2
|
|
||||||
#/# = SUPER, C, exec, app3 # Custom comment
|
|
||||||
`
|
|
||||||
|
|
||||||
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
|
|
||||||
t.Fatalf("Failed to write test config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
section, err := ParseHyprlandKeys(tmpDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseHyprlandKeys failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(section.Keybinds) != 3 {
|
|
||||||
t.Errorf("Expected 3 keybinds, got %d", len(section.Keybinds))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(section.Keybinds) > 0 && section.Keybinds[0].Key != "A" {
|
|
||||||
t.Errorf("First keybind key = %q, want %q", section.Keybinds[0].Key, "A")
|
|
||||||
}
|
|
||||||
if len(section.Keybinds) > 1 && section.Keybinds[1].Key != "B" {
|
|
||||||
t.Errorf("Second keybind key = %q, want %q", section.Keybinds[1].Key, "B")
|
|
||||||
}
|
|
||||||
if len(section.Keybinds) > 2 && section.Keybinds[2].Comment != "Custom comment" {
|
|
||||||
t.Errorf("Third keybind comment = %q, want %q", section.Keybinds[2].Comment, "Custom comment")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHyprlandReadContentMultipleFiles(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
|
|
||||||
file1 := filepath.Join(tmpDir, "a.conf")
|
|
||||||
file2 := filepath.Join(tmpDir, "b.conf")
|
|
||||||
|
|
||||||
content1 := "bind = SUPER, Q, killactive\n"
|
|
||||||
content2 := "bind = SUPER, T, exec, kitty\n"
|
|
||||||
|
|
||||||
if err := os.WriteFile(file1, []byte(content1), 0o644); err != nil {
|
|
||||||
t.Fatalf("Failed to write file1: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(file2, []byte(content2), 0o644); err != nil {
|
|
||||||
t.Fatalf("Failed to write file2: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
parser := NewHyprlandParser("")
|
|
||||||
if err := parser.ReadContent(tmpDir); err != nil {
|
|
||||||
t.Fatalf("ReadContent failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
section := parser.ParseKeys()
|
|
||||||
if len(section.Keybinds) != 2 {
|
|
||||||
t.Errorf("Expected 2 keybinds from multiple files, got %d", len(section.Keybinds))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHyprlandReadContentErrors(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
path string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "nonexistent_directory",
|
|
||||||
path: "/nonexistent/path/that/does/not/exist",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty_directory",
|
|
||||||
path: t.TempDir(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
_, err := ParseHyprlandKeys(tt.path)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Expected error, got nil")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHyprlandKeybindWithParamsContainingCommas(t *testing.T) {
|
|
||||||
parser := NewHyprlandParser("")
|
|
||||||
parser.contentLines = []string{"bind = SUPER, R, exec, notify-send 'Title' 'Message, with comma'"}
|
|
||||||
|
|
||||||
result := parser.getKeybindAtLine(0)
|
|
||||||
|
|
||||||
if result == nil {
|
|
||||||
t.Fatal("Expected keybind, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
expected := "notify-send 'Title' 'Message, with comma'"
|
|
||||||
if result.Params != expected {
|
|
||||||
t.Errorf("Params = %q, want %q", result.Params, expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHyprlandEmptyAndCommentLines(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
configFile := filepath.Join(tmpDir, "test.conf")
|
|
||||||
|
|
||||||
content := `
|
|
||||||
# This is a comment
|
|
||||||
bind = SUPER, Q, killactive
|
|
||||||
|
|
||||||
# Another comment
|
|
||||||
|
|
||||||
bind = SUPER, T, exec, kitty
|
|
||||||
`
|
|
||||||
|
|
||||||
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
|
|
||||||
t.Fatalf("Failed to write test config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
section, err := ParseHyprlandKeys(tmpDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseHyprlandKeys failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(section.Keybinds) != 2 {
|
|
||||||
t.Errorf("Expected 2 keybinds (comments ignored), got %d", len(section.Keybinds))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractBindFlags(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
bindType string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{"bind", ""},
|
|
||||||
{"binde", "e"},
|
|
||||||
{"bindel", "el"},
|
|
||||||
{" binde ", "e"},
|
|
||||||
{"bindkd", "kd"},
|
|
||||||
{"bindpcg", "pcg"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.bindType, func(t *testing.T) {
|
|
||||||
result, ok := extractBindFlags(tt.bindType)
|
|
||||||
if !ok || result != tt.expected {
|
|
||||||
t.Errorf("extractBindFlags(%q) = %q, %v, want %q", tt.bindType, result, ok, tt.expected)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, bindType := range []string{"notbind", "", "bindq", "binds:allow_workspace_cycles"} {
|
|
||||||
if flags, ok := extractBindFlags(bindType); ok {
|
|
||||||
t.Errorf("extractBindFlags(%q) = %q, want rejected", bindType, flags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHyprlandBindFlags(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
line string
|
|
||||||
expectedFlags string
|
|
||||||
expectedKey string
|
|
||||||
expectedDisp string
|
|
||||||
expectedDesc string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "binde (repeat on hold)",
|
|
||||||
line: "binde = , XF86AudioRaiseVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+",
|
|
||||||
expectedFlags: "e",
|
|
||||||
expectedKey: "XF86AudioRaiseVolume",
|
|
||||||
expectedDisp: "exec",
|
|
||||||
expectedDesc: "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "bindd (description)",
|
|
||||||
line: "bindd = SUPER, Q, Open my favourite terminal, exec, kitty",
|
|
||||||
expectedFlags: "d",
|
|
||||||
expectedKey: "Q",
|
|
||||||
expectedDisp: "exec",
|
|
||||||
expectedDesc: "Open my favourite terminal",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "bindk (per-device)",
|
|
||||||
line: "bindk = SUPER, Q, at-translated-set-2-keyboard, killactive",
|
|
||||||
expectedFlags: "k",
|
|
||||||
expectedKey: "Q",
|
|
||||||
expectedDisp: "killactive",
|
|
||||||
expectedDesc: "Close window",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "binddk (device before description)",
|
|
||||||
line: "binddk = SUPER, T, !my-macropad, Terminal, exec, kitty",
|
|
||||||
expectedFlags: "dk",
|
|
||||||
expectedKey: "T",
|
|
||||||
expectedDisp: "exec",
|
|
||||||
expectedDesc: "Terminal",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "bindux (submap-universal, input capture)",
|
|
||||||
line: "bindux = SUPER, Escape, submap, reset",
|
|
||||||
expectedFlags: "ux",
|
|
||||||
expectedKey: "Escape",
|
|
||||||
expectedDisp: "submap",
|
|
||||||
expectedDesc: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
parser := NewHyprlandParser("")
|
|
||||||
parser.contentLines = []string{tt.line}
|
|
||||||
result := parser.getKeybindAtLine(0)
|
|
||||||
|
|
||||||
if result == nil {
|
|
||||||
t.Fatal("Expected keybind, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Flags != tt.expectedFlags {
|
|
||||||
t.Errorf("Flags = %q, want %q", result.Flags, tt.expectedFlags)
|
|
||||||
}
|
|
||||||
if result.Key != tt.expectedKey {
|
|
||||||
t.Errorf("Key = %q, want %q", result.Key, tt.expectedKey)
|
|
||||||
}
|
|
||||||
if result.Dispatcher != tt.expectedDisp {
|
|
||||||
t.Errorf("Dispatcher = %q, want %q", result.Dispatcher, tt.expectedDisp)
|
|
||||||
}
|
|
||||||
if result.Comment != tt.expectedDesc {
|
|
||||||
t.Errorf("Comment = %q, want %q", result.Comment, tt.expectedDesc)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
return dir
|
||||||
}
|
}
|
||||||
@@ -186,7 +186,6 @@ func TestHyprlandConvertKeybindLuaAction(t *testing.T) {
|
|||||||
{"hl.dsp.no_op()", "", ""},
|
{"hl.dsp.no_op()", "", ""},
|
||||||
}
|
}
|
||||||
p := NewHyprlandProvider(t.TempDir())
|
p := NewHyprlandProvider(t.TempDir())
|
||||||
p.luaConfig = true
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.dispatcher+" "+tt.params, func(t *testing.T) {
|
t.Run(tt.dispatcher+" "+tt.params, func(t *testing.T) {
|
||||||
kb := &HyprlandKeyBinding{Mods: []string{"SUPER"}, Key: "X", Dispatcher: tt.dispatcher, Params: tt.params}
|
kb := &HyprlandKeyBinding{Mods: []string{"SUPER"}, Key: "X", Dispatcher: tt.dispatcher, Params: tt.params}
|
||||||
@@ -217,45 +216,41 @@ func TestHyprlandLuaBindLabels(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHyprlandCheatSheetConfigFormat(t *testing.T) {
|
func TestHyprlandCheatSheetIgnoresLegacyConf(t *testing.T) {
|
||||||
const conf = "bind = SUPER, Q, killactive\n"
|
|
||||||
const lua = `hl.bind("SUPER + Q", hl.dsp.window.close())` + "\n"
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
files map[string]string
|
|
||||||
wantFormat string
|
|
||||||
wantReadOnly bool
|
|
||||||
wantLua string
|
|
||||||
}{
|
|
||||||
{"hyprlang only", map[string]string{"hyprland.conf": conf}, "hyprlang", true, ""},
|
|
||||||
{"lua only", map[string]string{"hyprland.lua": lua}, "lua", false, `hl.dsp.window.close()`},
|
|
||||||
{"lua wins over leftover conf", map[string]string{"hyprland.conf": conf, "hyprland.lua": lua}, "lua", false, `hl.dsp.window.close()`},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
if err := os.MkdirAll(filepath.Join(dir, "dms"), 0o755); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "hyprland.conf"), []byte("bind = SUPER, Q, killactive\n"), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(dir, "dms", "binds.lua"), nil, 0o644); err != nil {
|
if sheet, err := NewHyprlandProvider(dir).GetCheatSheet(); err == nil {
|
||||||
t.Fatal(err)
|
t.Fatalf("GetCheatSheet read hyprland.conf: %+v", sheet.Binds)
|
||||||
}
|
}
|
||||||
for name, content := range tt.files {
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHyprlandCheatSheetReadsWrappedBinds(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
config := `local function cycle_ws()
|
||||||
|
hl.dispatch("workspace e+1")
|
||||||
|
end
|
||||||
|
|
||||||
|
hl.bind("SUPER + T",
|
||||||
|
hl.dsp.exec_cmd("kitty"), -- launcher
|
||||||
|
{ description = "Terminal", repeating = true })
|
||||||
|
hl.bind("SUPER + C", cycle_ws, { description = "Next workspace" })
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "hyprland.lua"), []byte(config), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
sheet, err := NewHyprlandProvider(dir).GetCheatSheet()
|
sheet, err := NewHyprlandProvider(dir).GetCheatSheet()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetCheatSheet: %v", err)
|
t.Fatalf("GetCheatSheet: %v", err)
|
||||||
}
|
}
|
||||||
if sheet.DMSStatus == nil || sheet.DMSStatus.ConfigFormat != tt.wantFormat || sheet.DMSStatus.ReadOnly != tt.wantReadOnly {
|
|
||||||
t.Fatalf("DMSStatus = %+v, want format %q readOnly %v", sheet.DMSStatus, tt.wantFormat, tt.wantReadOnly)
|
exec := sheet.Binds["Execute"]
|
||||||
|
if len(exec) != 1 || exec[0].Key != "SUPER+T" || exec[0].Description != "Terminal" || exec[0].Flags != "ed" {
|
||||||
|
t.Fatalf("Execute binds = %+v, want the wrapped SUPER+T bind with its options", exec)
|
||||||
}
|
}
|
||||||
binds := sheet.Binds["Window"]
|
ws := sheet.Binds["Workspace"]
|
||||||
if len(binds) != 1 || binds[0].Action != "killactive" || binds[0].LuaAction != tt.wantLua {
|
if len(ws) != 1 || ws[0].Key != "SUPER+C" || ws[0].Description != "Next workspace" {
|
||||||
t.Fatalf("Window binds = %+v, want killactive with LuaAction %q", binds, tt.wantLua)
|
t.Fatalf("Workspace binds = %+v, want the named-function bind filed by its description", ws)
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,7 +290,7 @@ var mangowcBindsMessages = configfrag.Messages{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *MangoWCParser) buildDMSStatus() *configfrag.Status {
|
func (p *MangoWCParser) buildDMSStatus() *configfrag.Status {
|
||||||
status := configfrag.BuildStatus(p.walker.Scan(), p.dmsBindsExists, p.bindsAfterDMS, "", false, mangowcBindsMessages)
|
status := configfrag.BuildStatus(p.walker.Scan(), p.dmsBindsExists, p.bindsAfterDMS, mangowcBindsMessages)
|
||||||
return &status
|
return &status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -569,7 +569,7 @@ var niriBindsMessages = configfrag.Messages{
|
|||||||
|
|
||||||
func (p *NiriParser) buildDMSStatus() *configfrag.Status {
|
func (p *NiriParser) buildDMSStatus() *configfrag.Status {
|
||||||
scan := configfrag.IncludeScan{Count: p.includeCount, DMSPosition: p.dmsIncludePos, DMSSeen: p.dmsBindsIncluded}
|
scan := configfrag.IncludeScan{Count: p.includeCount, DMSPosition: p.dmsIncludePos, DMSSeen: p.dmsBindsIncluded}
|
||||||
status := configfrag.BuildStatus(scan, p.dmsBindsExists, p.bindsAfterDMS, "", false, niriBindsMessages)
|
status := configfrag.BuildStatus(scan, p.dmsBindsExists, p.bindsAfterDMS, niriBindsMessages)
|
||||||
return &status
|
return &status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,9 @@ func TestDMSBindsStatusKeySpellings(t *testing.T) {
|
|||||||
Effective: true,
|
Effective: true,
|
||||||
OverriddenBy: 4,
|
OverriddenBy: 4,
|
||||||
StatusMessage: "DMS binds are active",
|
StatusMessage: "DMS binds are active",
|
||||||
ConfigFormat: "lua",
|
|
||||||
ReadOnly: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
want := []string{"exists", "included", "includePosition", "totalIncludes", "bindsAfterDms", "effective", "overriddenBy", "statusMessage", "configFormat", "readOnly"}
|
want := []string{"exists", "included", "includePosition", "totalIncludes", "bindsAfterDms", "effective", "overriddenBy", "statusMessage"}
|
||||||
if len(keys) != len(want) {
|
if len(keys) != len(want) {
|
||||||
t.Fatalf("key count = %d, want %d: %v", len(keys), len(want), keys)
|
t.Fatalf("key count = %d, want %d: %v", len(keys), len(want), keys)
|
||||||
}
|
}
|
||||||
@@ -55,8 +53,6 @@ func TestDMSBindsStatusFromCarriesEveryField(t *testing.T) {
|
|||||||
Effective: true,
|
Effective: true,
|
||||||
OverriddenBy: 7,
|
OverriddenBy: 7,
|
||||||
StatusMessage: "DMS binds are active",
|
StatusMessage: "DMS binds are active",
|
||||||
ConfigFormat: "lua",
|
|
||||||
ReadOnly: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
want := DMSBindsStatus{
|
want := DMSBindsStatus{
|
||||||
@@ -68,8 +64,6 @@ func TestDMSBindsStatusFromCarriesEveryField(t *testing.T) {
|
|||||||
Effective: true,
|
Effective: true,
|
||||||
OverriddenBy: 7,
|
OverriddenBy: 7,
|
||||||
StatusMessage: "DMS binds are active",
|
StatusMessage: "DMS binds are active",
|
||||||
ConfigFormat: "lua",
|
|
||||||
ReadOnly: true,
|
|
||||||
}
|
}
|
||||||
if *got != want {
|
if *got != want {
|
||||||
t.Errorf("DMSBindsStatusFrom = %+v, want %+v", *got, want)
|
t.Errorf("DMSBindsStatusFrom = %+v, want %+v", *got, want)
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ type DMSBindsStatus struct {
|
|||||||
Effective bool `json:"effective"`
|
Effective bool `json:"effective"`
|
||||||
OverriddenBy int `json:"overriddenBy"`
|
OverriddenBy int `json:"overriddenBy"`
|
||||||
StatusMessage string `json:"statusMessage"`
|
StatusMessage string `json:"statusMessage"`
|
||||||
ConfigFormat string `json:"configFormat,omitempty"`
|
|
||||||
ReadOnly bool `json:"readOnly,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -127,7 +125,5 @@ func DMSBindsStatusFrom(s configfrag.Status) *DMSBindsStatus {
|
|||||||
Effective: s.Effective,
|
Effective: s.Effective,
|
||||||
OverriddenBy: s.OverriddenBy,
|
OverriddenBy: s.OverriddenBy,
|
||||||
StatusMessage: s.StatusMessage,
|
StatusMessage: s.StatusMessage,
|
||||||
ConfigFormat: s.ConfigFormat,
|
|
||||||
ReadOnly: s.ReadOnly,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,33 @@
|
|||||||
|
package luaconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HyprlandVersion is the running compositor's major.minor, 0.0 when hyprctl is unavailable. Tests replace it.
|
||||||
|
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 HyprlandAtLeast(major, minor int) bool {
|
||||||
|
haveMajor, haveMinor := HyprlandVersion()
|
||||||
|
if haveMajor != major {
|
||||||
|
return haveMajor > major
|
||||||
|
}
|
||||||
|
return haveMinor >= minor
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -3,14 +3,72 @@ package netfetch
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const latestReleaseURL = "https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest"
|
const (
|
||||||
|
latestReleaseRedirectURL = "https://github.com/duxlabs-dev/dux-shell/releases/latest"
|
||||||
|
latestReleaseAPIURL = "https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest"
|
||||||
|
releaseLookupTimeout = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrGitHubRateLimited = errors.New("github api rate limit exceeded (60 unauthenticated requests per hour per ip)")
|
||||||
|
|
||||||
func LatestReleaseTag(ctx context.Context) (string, error) {
|
func LatestReleaseTag(ctx context.Context) (string, error) {
|
||||||
body, err := Bytes(ctx, latestReleaseURL, Options{Timeout: 10 * time.Second})
|
tag, redirectErr := latestTagFromRedirect(ctx, latestReleaseRedirectURL)
|
||||||
|
if redirectErr == nil {
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, apiErr := latestTagFromAPI(ctx, latestReleaseAPIURL)
|
||||||
|
if apiErr == nil {
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("release redirect: %w; release api: %w", redirectErr, apiErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestTagFromRedirect(ctx context.Context, url string) (string, error) {
|
||||||
|
opts := Options{
|
||||||
|
Timeout: releaseLookupTimeout,
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req, err := opts.request(ctx, url)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Method = http.MethodHead
|
||||||
|
|
||||||
|
resp, err := opts.client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 300 || resp.StatusCode >= 400 {
|
||||||
|
return "", &StatusError{Code: resp.StatusCode}
|
||||||
|
}
|
||||||
|
|
||||||
|
location := resp.Header.Get("Location")
|
||||||
|
tag := path.Base(location)
|
||||||
|
if tag == "" || tag == "." || tag == "/" || !strings.Contains(location, "/releases/tag/") {
|
||||||
|
return "", fmt.Errorf("unexpected release redirect: %q", location)
|
||||||
|
}
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestTagFromAPI(ctx context.Context, url string) (string, error) {
|
||||||
|
body, err := Bytes(ctx, url, Options{Timeout: releaseLookupTimeout})
|
||||||
|
var status *StatusError
|
||||||
|
if errors.As(err, &status) && (status.Code == http.StatusForbidden || status.Code == http.StatusTooManyRequests) {
|
||||||
|
return "", ErrGitHubRateLimited
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
Loaded 100 of 619 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user