mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-29 22:47:47 -04:00
Compare commits
143
Commits
v2.0.0
...
stable-2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e758c935f | ||
|
|
1905f2135b | ||
|
|
974c4d3167 | ||
|
|
3df95c8f4f | ||
|
|
ad05bdf70b | ||
|
|
d22b5c38c9 | ||
|
|
5b52ccad7c | ||
|
|
9b762b8ec0 | ||
|
|
2ba4f1355c | ||
|
|
08f7acad06 | ||
|
|
c25da363ec | ||
|
|
74b4175876 | ||
|
|
2b5bf15e76 | ||
|
|
2c42fb8492 | ||
|
|
bee384abbd | ||
|
|
ee249838f5 | ||
|
|
3c365533c7 | ||
|
|
47744d34b7 | ||
|
|
c813f65581 | ||
|
|
527bb0613e | ||
|
|
41e0f1d434 | ||
|
|
9be1bc03e9 | ||
|
|
57dd939d85 | ||
|
|
5fea906c9f | ||
|
|
b528af8469 | ||
|
|
957e87afa5 | ||
|
|
8c5bfac638 | ||
|
|
c504ebcb95 | ||
|
|
e36ef50482 | ||
|
|
8aac4c767a | ||
|
|
7a7f67bc24 | ||
|
|
9fd55f6f5e | ||
|
|
4a1fadc349 | ||
|
|
12f8b67d4f | ||
|
|
ff1bbe807d | ||
|
|
d90ff54ab9 | ||
|
|
15ddcf2ae1 | ||
|
|
8e10f620c9 | ||
|
|
e038d76fb6 | ||
|
|
7ae25d1d32 | ||
|
|
dba4d5a114 | ||
|
|
0d7298b996 | ||
|
|
d29daf8fdd | ||
|
|
43cb8c70b7 | ||
|
|
1efbf723f8 | ||
|
|
d694107f36 | ||
|
|
3eab6a52e0 | ||
|
|
bb06467edf | ||
|
|
726d9525c0 | ||
|
|
10008104ce | ||
|
|
0acaaa4b44 | ||
|
|
7ed525a679 | ||
|
|
463df0167f | ||
|
|
e6f0a5f219 | ||
|
|
d2ad778e6f | ||
|
|
69fc822ffd | ||
|
|
93d36ac7cd | ||
|
|
3f75012b7c | ||
|
|
433a84d824 | ||
|
|
b34a80b78f | ||
|
|
3dc1aed318 | ||
|
|
f9c73a77cf | ||
|
|
a10ee7d3ed | ||
|
|
a2a18ed7db | ||
|
|
471318575f | ||
|
|
7d337602d2 | ||
|
|
88fd192b0d | ||
|
|
523ef916b9 | ||
|
|
181e624c0d | ||
|
|
7ea6f5f7e0 | ||
|
|
28b9bb246b | ||
|
|
2f371015c4 | ||
|
|
3f02a44f5e | ||
|
|
ef4b3ae53f | ||
|
|
3f0769f169 | ||
|
|
6da523875f | ||
|
|
994d5bb150 | ||
|
|
3d95e0e8b8 | ||
|
|
d9e9cddae6 | ||
|
|
fdb4f88aec | ||
|
|
2c0998f1dc | ||
|
|
5ef5a7391b | ||
|
|
4e0985fbbb | ||
|
|
1979ccf886 | ||
|
|
2d7b65a8c3 | ||
|
|
5a210112aa | ||
|
|
260d4fda73 | ||
|
|
290d04c511 | ||
|
|
6d316539d7 | ||
|
|
defab4fbc8 | ||
|
|
d2e191d642 | ||
|
|
fe1241bf91 | ||
|
|
6d00fd7295 | ||
|
|
1c54262b9f | ||
|
|
7622a6cd3c | ||
|
|
50ba96a279 | ||
|
|
148c7f6240 | ||
|
|
53b9bfa2a3 | ||
|
|
17f32f6c55 | ||
|
|
7c2b4228f6 | ||
|
|
c831a9926a | ||
|
|
514c3e3eb8 | ||
|
|
09449c0b37 | ||
|
|
6c3a646dc4 | ||
|
|
a680e1bd39 | ||
|
|
36ebe0e3ef | ||
|
|
0102bf6d40 | ||
|
|
dbf07ea644 | ||
|
|
58d741ec07 | ||
|
|
2befd45974 | ||
|
|
99eb2a06d0 | ||
|
|
163c4d5a1f | ||
|
|
d4ecaf82d4 | ||
|
|
ec4e04f06f | ||
|
|
5aa2e3f0d4 | ||
|
|
11f99cc13f | ||
|
|
0ae0b96885 | ||
|
|
f0826a953f | ||
|
|
b5da117206 | ||
|
|
430db4e493 | ||
|
|
2a636c7092 | ||
|
|
8848ad82f1 | ||
|
|
404d5362e5 | ||
|
|
4ab57f14b8 | ||
|
|
ce81dfc95a | ||
|
|
a5511fe8d7 | ||
|
|
7d930f8f6c | ||
|
|
11ef76b9d0 | ||
|
|
e48de4cc2a | ||
|
|
1683fceac5 | ||
|
|
2a83c5c69b | ||
|
|
1acfae1755 | ||
|
|
1e1dbebb70 | ||
|
|
53412d1f22 | ||
|
|
9ece8f26fa | ||
|
|
fd1295f959 | ||
|
|
1e3f16ac9d | ||
|
|
4fa6f58720 | ||
|
|
ff45b3b4b2 | ||
|
|
12e54e0225 | ||
|
|
f63d8bf4fb | ||
|
|
1e8423992d | ||
|
|
c4ecb988b4 |
No files matched your search
@@ -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,4 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
# Download / publish an XBPS repository prefix on Cloudflare R2.
|
||||||
|
# Shared by dux-shell and dux-repo (same prefix); keep both copies identical.
|
||||||
|
# ETag guard: publish aborts if another writer replaced x86_64-repodata mid-run.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -19,13 +22,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/" \
|
||||||
@@ -35,7 +79,7 @@ download() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 +102,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 +163,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 +181,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 +196,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 +217,7 @@ publish() {
|
|||||||
}
|
}
|
||||||
done
|
done
|
||||||
|
|
||||||
|
assert_repodata_unchanged "before publishing"
|
||||||
verify_immutable_packages
|
verify_immutable_packages
|
||||||
build_manifest
|
build_manifest
|
||||||
archive_retired
|
archive_retired
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ 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"
|
||||||
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,64 @@
|
|||||||
|
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
|
||||||
|
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/")],
|
||||||
|
];
|
||||||
|
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");
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -308,6 +308,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: Install OSC
|
- name: Install OSC
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -100,6 +100,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
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -49,7 +47,9 @@ jobs:
|
|||||||
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)"
|
# dms/current is shared with dux-repo; 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."
|
||||||
|
|||||||
+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."
|
|
||||||
}
|
|
||||||
@@ -55,32 +55,8 @@ var resolveIncludeCmd = &cobra.Command{
|
|||||||
Run: runResolveInclude,
|
Run: runResolveInclude,
|
||||||
}
|
}
|
||||||
|
|
||||||
var mangoMigrateMain bool
|
|
||||||
|
|
||||||
var mangoMigrateCmd = &cobra.Command{
|
|
||||||
Use: "mango-migrate",
|
|
||||||
Short: "Respell Mango config keys for the installed Mango",
|
|
||||||
Long: "Detects whether the installed Mango uses legacy or snake_case config keys, rewrites the DMS fragments in mango/dms to match, and prints the dialect on the last line. --main also rewrites config.conf after a backup.",
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
dialect := mangoconf.Detect()
|
|
||||||
changed, err := dialect.Migrate(mangoconf.Dir(), mangoMigrateMain)
|
|
||||||
for _, path := range changed {
|
|
||||||
fmt.Fprintf(os.Stderr, "migrated %s\n", path)
|
|
||||||
}
|
|
||||||
// Printed before any error: the shell needs the dialect even when a fragment failed.
|
|
||||||
fmt.Println(dialect)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
configCmd.AddCommand(resolveIncludeCmd)
|
configCmd.AddCommand(resolveIncludeCmd)
|
||||||
mangoMigrateCmd.Flags().BoolVar(&mangoMigrateMain, "main", false, "also rewrite config.conf (a backup is kept)")
|
|
||||||
configCmd.AddCommand(mangoMigrateCmd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type IncludeResult struct {
|
type IncludeResult struct {
|
||||||
|
|||||||
+118
-41
@@ -285,15 +285,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
|
||||||
@@ -858,6 +858,14 @@ func checkWindowManagers() []checkResult {
|
|||||||
if c.name == "Hyprland" {
|
if c.name == "Hyprland" {
|
||||||
results = append(results, checkHyprlandConfigFormat(filepath.Join(utils.XDGConfigHome(), "hypr"))...)
|
results = append(results, checkHyprlandConfigFormat(filepath.Join(utils.XDGConfigHome(), "hypr"))...)
|
||||||
}
|
}
|
||||||
|
if c.name == "mangowc" && mangoLacksSnakeCase(ver) {
|
||||||
|
results = append(results, checkResult{
|
||||||
|
catCompositor, "Mango config format", statusError,
|
||||||
|
fmt.Sprintf("Mango %s is older than 0.18", ver),
|
||||||
|
"DMS writes Mango config with the snake_case keys of Mango 0.18 and later, which this release rejects; upgrade Mango",
|
||||||
|
doctorDocsURL + "#compositor-checks",
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !foundAny {
|
if !foundAny {
|
||||||
@@ -878,7 +886,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 +894,70 @@ func checkWindowManagers() []checkResult {
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
var mangoLegacyOverviewBinds = regexp.MustCompile(`(?im)^\s*mousebind\s*=\s*none\s*,\s*(btn_left\s*,\s*toggleoverview\s*,\s*1|btn_right\s*,\s*killclient\s*,\s*0)\s*$`)
|
var (
|
||||||
|
mangoLegacyOverviewBinds = regexp.MustCompile(`(?im)^\s*mousebind\s*=\s*none\s*,\s*(btn_left\s*,\s*toggleoverview\s*,\s*1|btn_right\s*,\s*killclient\s*,\s*0)\s*$`)
|
||||||
|
mangoUnknownKeyword = regexp.MustCompile(`Unknown keyword:\s*(\S+)`)
|
||||||
|
ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||||
|
)
|
||||||
|
|
||||||
func checkMangoConfig(mangoDir string, dialect mangoconf.Dialect) []checkResult {
|
// Mango's parser follows source= and always exits 0; only its output tells.
|
||||||
|
var mangoParseConfig = func(path string) (string, error) {
|
||||||
|
if _, err := exec.LookPath("mango"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out, _ := exec.Command("mango", "-p", "-c", path).CombinedOutput()
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each "Unknown keyword" line is followed by a location line naming the file.
|
||||||
|
func mangoUnknownKeywords(output string) []string {
|
||||||
|
var found []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
key := ""
|
||||||
|
for line := range strings.SplitSeq(ansiEscape.ReplaceAllString(output, ""), "\n") {
|
||||||
|
if m := mangoUnknownKeyword.FindStringSubmatch(line); m != nil {
|
||||||
|
key = m[1]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slash := strings.LastIndex(line, "/")
|
||||||
|
if key == "" || slash < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
file, _, _ := strings.Cut(line[slash+1:], ":")
|
||||||
|
entry := key + " (" + file + ")"
|
||||||
|
if !seen[entry] {
|
||||||
|
seen[entry] = true
|
||||||
|
found = append(found, entry)
|
||||||
|
}
|
||||||
|
key = ""
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkMangoConfig(mangoDir string) []checkResult {
|
||||||
url := doctorDocsURL + "#compositor-checks"
|
url := doctorDocsURL + "#compositor-checks"
|
||||||
paths, _ := filepath.Glob(filepath.Join(mangoDir, "dms", "*.conf"))
|
mainPath := filepath.Join(mangoDir, "config.conf")
|
||||||
paths = append([]string{filepath.Join(mangoDir, "config.conf")}, paths...)
|
var results []checkResult
|
||||||
|
if output, err := mangoParseConfig(mainPath); err == nil {
|
||||||
|
if unknown := mangoUnknownKeywords(output); len(unknown) > 0 {
|
||||||
|
results = append(results, checkResult{catCompositor, "Mango config keys", statusError,
|
||||||
|
"Unknown keys: " + strings.Join(unknown, ", "),
|
||||||
|
"Mango 0.18 renamed its config keys to snake_case. Run migrate-config-keys.sh from Mango's 0.18.0 release over ~/.config/mango, or rename the keys by hand.", url})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var stale, overviewBinds []string
|
paths, _ := filepath.Glob(filepath.Join(mangoDir, "dms", "*.conf"))
|
||||||
|
paths = append([]string{mainPath}, paths...)
|
||||||
|
var overviewBinds []string
|
||||||
for _, path := range paths {
|
for _, path := range paths {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if dialect.Translate(string(data)) != string(data) {
|
|
||||||
stale = append(stale, filepath.Base(path))
|
|
||||||
}
|
|
||||||
if mangoLegacyOverviewBinds.Match(data) {
|
if mangoLegacyOverviewBinds.Match(data) {
|
||||||
overviewBinds = append(overviewBinds, filepath.Base(path))
|
overviewBinds = append(overviewBinds, filepath.Base(path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var results []checkResult
|
|
||||||
if len(stale) > 0 {
|
|
||||||
results = append(results, checkResult{catCompositor, "Mango config keys", statusError,
|
|
||||||
fmt.Sprintf("Not %s keys: %s", dialect, strings.Join(stale, ", ")),
|
|
||||||
"This Mango rejects those lines as unknown keywords. Run `dms config mango-migrate --main` (config.conf is backed up).", url})
|
|
||||||
}
|
|
||||||
if len(overviewBinds) > 0 {
|
if len(overviewBinds) > 0 {
|
||||||
results = append(results, checkResult{catCompositor, "Mango mouse binds", statusWarn,
|
results = append(results, checkResult{catCompositor, "Mango mouse binds", statusWarn,
|
||||||
"Obsolete overview binds in " + strings.Join(overviewBinds, ", "),
|
"Obsolete overview binds in " + strings.Join(overviewBinds, ", "),
|
||||||
@@ -967,6 +1012,10 @@ func hyprlandLacksLua(ver string) bool {
|
|||||||
return ver != "" && version.CompareVersions(ver, "0.55.0") < 0
|
return ver != "" && version.CompareVersions(ver, "0.55.0") < 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mangoLacksSnakeCase(ver string) bool {
|
||||||
|
return ver != "" && version.CompareVersions(ver, "0.18.0") < 0
|
||||||
|
}
|
||||||
|
|
||||||
func firstSubmatch(re *regexp.Regexp) func(string) string {
|
func firstSubmatch(re *regexp.Regexp) func(string) string {
|
||||||
return func(s string) string {
|
return func(s string) string {
|
||||||
if m := re.FindStringSubmatch(s); len(m) > 1 {
|
if m := re.FindStringSubmatch(s); len(m) > 1 {
|
||||||
@@ -1712,6 +1761,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 +1798,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
|
// 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 false
|
return families
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkConfiguredFont(label, family, shellPath, fcCache string, fcListAvailable bool, url string) checkResult {
|
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 +1873,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 +1882,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 +1928,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 +1947,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) {
|
||||||
shellPath := t.TempDir()
|
for family, relPaths := range bundledFontRelPaths {
|
||||||
fontPath := filepath.Join(shellPath, "DCommon", "assets", "fonts", "google-sans-flex", "GoogleSansFlex.ttf")
|
t.Run(family, func(t *testing.T) {
|
||||||
missing := checkConfiguredFont("UI font", "Google Sans Flex", shellPath, "", false, "")
|
shellPath := t.TempDir()
|
||||||
if missing.status != statusWarn {
|
if missing := checkConfiguredFont("Font", family, shellPath, nil, false, ""); 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 {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(fontPath, []byte("font"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{family, " " + strings.ToUpper(family) + " "} {
|
||||||
|
if result := checkConfiguredFont("Font", name, shellPath, nil, false, ""); result.status != statusOK {
|
||||||
|
t.Fatalf("bundled font %q status = %v, want %v", name, result.status, statusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(fontPath), 0o755); err != nil {
|
}
|
||||||
t.Fatal(err)
|
|
||||||
|
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,
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(fontPath, []byte("font"), 0o644); err != nil {
|
for name, want := range cases {
|
||||||
t.Fatal(err)
|
if got := fontInFcList(name, families); got != want {
|
||||||
}
|
t.Errorf("fontInFcList(%q) = %v, want %v", name, got, want)
|
||||||
for _, family := range []string{"Google Sans Flex", " google sans flex "} {
|
|
||||||
result := checkConfiguredFont("UI font", family, shellPath, "", false, "")
|
|
||||||
if result.status != statusOK {
|
|
||||||
t.Fatalf("bundled font %q status = %v, want %v", family, result.status, statusOK)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
if got := checkMangoConfig(dir, mangoconf.Snake); len(got) != 2 || got[0].message != "Not snake keys: config.conf" {
|
mangoParseConfig = func(string) (string, error) { return "", os.ErrNotExist }
|
||||||
t.Fatalf("snake: %+v", got)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func greeterPackageInstallHint() string {
|
|||||||
case distros.FamilyFedora:
|
case distros.FamilyFedora:
|
||||||
return "Install with 'sudo dnf install dms-greeter' (requires COPR: sudo dnf copr enable avengemedia/danklinux)"
|
return "Install with 'sudo dnf install dms-greeter' (requires COPR: sudo dnf copr enable avengemedia/danklinux)"
|
||||||
case distros.FamilyArch:
|
case distros.FamilyArch:
|
||||||
return "Install from AUR with 'paru -S greetd-dms-greeter-bin' or 'yay -S greetd-dms-greeter-bin'"
|
return "Install from AUR with 'paru -S dux-greeter-bin' or 'yay -S dux-greeter-bin'"
|
||||||
case distros.FamilyVoid:
|
case distros.FamilyVoid:
|
||||||
return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=" + distros.VoidDMSRepo + "' | sudo tee /etc/xbps.d/dms.conf)"
|
return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=" + distros.VoidDMSRepo + "' | sudo tee /etc/xbps.d/dms.conf)"
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
if commandBlockedByPolicy("setup", blocked) {
|
||||||
t.Fatal("expected greeter sync to remain available on immutable/image-based systems")
|
t.Fatal("setup only writes to the home directory and must stay available on image-based systems")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,17 +2,21 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/duxlabs-dev/dux-go/shellapp"
|
"github.com/duxlabs-dev/dux-go/shellapp"
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/config"
|
"github.com/duxlabs-dev/dux-shell/core/internal/config"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/gpu"
|
"github.com/duxlabs-dev/dux-shell/core/internal/gpu"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/server"
|
"github.com/duxlabs-dev/dux-shell/core/internal/server"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/shellembed"
|
"github.com/duxlabs-dev/dux-shell/core/internal/shellembed"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
||||||
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
var shellApp = shellapp.New(shellapp.Config{
|
var shellApp = shellapp.New(shellapp.Config{
|
||||||
@@ -83,18 +87,47 @@ func preLaunch() {
|
|||||||
ensureFontCache()
|
ensureFontCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The elapsed-timer driver keeps the scrolling wallpaper spring in step above 60 Hz,
|
||||||
|
// but it makes every other animation jump after a dropped frame (#3837).
|
||||||
|
func scrollingWallpaperConfigured() bool {
|
||||||
|
configDir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(configDir, "dms", "settings.json"))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return wantsSimpleAnimationDriver(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func wantsSimpleAnimationDriver(settings []byte) bool {
|
||||||
|
var parsed struct {
|
||||||
|
WallpaperFillMode string `json:"wallpaperFillMode"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(settings, &parsed) != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return parsed.WallpaperFillMode == "Scrolling"
|
||||||
|
}
|
||||||
|
|
||||||
func dmsExtraEnv(string) []string {
|
func dmsExtraEnv(string) []string {
|
||||||
var env []string
|
var env []string
|
||||||
if selfPath, err := os.Executable(); err == nil {
|
if selfPath, err := os.Executable(); err == nil {
|
||||||
env = append(env, "DMS_EXECUTABLE="+selfPath)
|
env = append(env, "DMS_EXECUTABLE="+selfPath)
|
||||||
}
|
}
|
||||||
if os.Getenv("QSG_USE_SIMPLE_ANIMATION_DRIVER") == "" {
|
if os.Getenv("QSG_USE_SIMPLE_ANIMATION_DRIVER") == "" && scrollingWallpaperConfigured() {
|
||||||
env = append(env, "QSG_USE_SIMPLE_ANIMATION_DRIVER=1")
|
env = append(env, "QSG_USE_SIMPLE_ANIMATION_DRIVER=1")
|
||||||
}
|
}
|
||||||
if _, set := os.LookupEnv("MALLOC_CONF"); !set {
|
if _, set := os.LookupEnv("MALLOC_CONF"); !set {
|
||||||
env = append(env, "MALLOC_CONF=thp:never,narenas:4,dirty_decay_ms:3000")
|
env = append(env, "MALLOC_CONF=thp:never,narenas:4,dirty_decay_ms:3000")
|
||||||
}
|
}
|
||||||
env = append(env, gpu.EGLVendorEnv()...)
|
env = append(env, gpu.EGLVendorEnv()...)
|
||||||
|
policyEnv, err := clipolicy.ShellEnv(afero.NewOsFs())
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("CLI policy unreadable, enforcing every shell switch and ignoring its lock settings: %v", err)
|
||||||
|
}
|
||||||
|
env = append(env, policyEnv...)
|
||||||
// systemd user services miss the login shell PATH, so tools in ~/.local/bin
|
// systemd user services miss the login shell PATH, so tools in ~/.local/bin
|
||||||
// (herdr, pip/cargo installs) would be invisible to the shell and its children.
|
// (herdr, pip/cargo installs) would be invisible to the shell and its children.
|
||||||
for _, entry := range utils.EnvWithUserBinPath(nil) {
|
for _, entry := range utils.EnvWithUserBinPath(nil) {
|
||||||
|
|||||||
@@ -20,9 +20,16 @@ var runSystemctl = func(args ...string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Assigned in init: shellApp's config refers back to trySystemdRestart.
|
||||||
|
var shellRunning func() bool
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
shellRunning = func() bool { return len(shellApp.PIDs()) > 0 }
|
||||||
|
}
|
||||||
|
|
||||||
func trySystemdRestart() (bool, error) {
|
func trySystemdRestart() (bool, error) {
|
||||||
if err := runSystemctl("--user", "is-active", "--quiet", dmsSystemdUnit); err != nil {
|
if err := runSystemctl("--user", "is-active", "--quiet", dmsSystemdUnit); err != nil {
|
||||||
return false, nil
|
return trySystemdStart()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := runSystemctl("--user", "restart", dmsSystemdUnit); err != nil {
|
if err := runSystemctl("--user", "restart", dmsSystemdUnit); err != nil {
|
||||||
@@ -30,3 +37,23 @@ func trySystemdRestart() (bool, error) {
|
|||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// With nothing running, restart would spawn an unmanaged daemon that blocks
|
||||||
|
// the unit once the session target starts it. A running hand-started
|
||||||
|
// instance is left to the plain restart path.
|
||||||
|
func trySystemdStart() (bool, error) {
|
||||||
|
if shellRunning() {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := runSystemctl("--user", "is-enabled", "--quiet", dmsSystemdUnit); err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := runSystemctl("--user", "is-active", "--quiet", "graphical-session.target"); err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runSystemctl("--user", "start", dmsSystemdUnit); err != nil {
|
||||||
|
return true, fmt.Errorf("starting %s: %w", dmsSystemdUnit, err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTrySystemdRestart(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
running bool
|
||||||
|
ok []string
|
||||||
|
wantHandled bool
|
||||||
|
wantCall string
|
||||||
|
}{
|
||||||
|
{"active unit restarts", false, []string{"is-active dms.service", "restart"}, true, "restart dms.service"},
|
||||||
|
{"enabled inactive unit starts", false, []string{"is-enabled", "is-active graphical-session.target", "start"}, true, "start dms.service"},
|
||||||
|
{"hand-started instance keeps plain restart", true, []string{"is-enabled", "is-active graphical-session.target", "start"}, false, ""},
|
||||||
|
{"session target not up", false, []string{"is-enabled"}, false, ""},
|
||||||
|
{"unit disabled", false, []string{"is-active graphical-session.target"}, false, ""},
|
||||||
|
{"no systemd", false, nil, false, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
origSystemctl, origRunning := runSystemctl, shellRunning
|
||||||
|
t.Cleanup(func() { runSystemctl, shellRunning = origSystemctl, origRunning })
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
shellRunning = func() bool { return tt.running }
|
||||||
|
var calls []string
|
||||||
|
runSystemctl = func(args ...string) error {
|
||||||
|
call := strings.Join(slices.DeleteFunc(slices.Clone(args), func(a string) bool {
|
||||||
|
return strings.HasPrefix(a, "--")
|
||||||
|
}), " ")
|
||||||
|
calls = append(calls, call)
|
||||||
|
for _, prefix := range tt.ok {
|
||||||
|
if strings.HasPrefix(call, prefix) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.New("exit status 3")
|
||||||
|
}
|
||||||
|
|
||||||
|
handled, err := trySystemdRestart()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if handled != tt.wantHandled {
|
||||||
|
t.Fatalf("handled = %v, want %v (calls %q)", handled, tt.wantHandled, calls)
|
||||||
|
}
|
||||||
|
if tt.wantCall != "" && !slices.Contains(calls, tt.wantCall) {
|
||||||
|
t.Fatalf("missing %q in calls %q", tt.wantCall, calls)
|
||||||
|
}
|
||||||
|
if !tt.wantHandled && slices.ContainsFunc(calls, func(c string) bool { return strings.HasPrefix(c, "start") }) {
|
||||||
|
t.Fatalf("unexpected start in calls %q", calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,9 +32,9 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "dankinstall",
|
Use: "duxinstall",
|
||||||
Short: "Install Dux Shell and its dependencies",
|
Short: "Install Dux Shell and its dependencies",
|
||||||
Long: `dankinstall sets up Dux Shell with your chosen compositor and terminal.
|
Long: `duxinstall sets up Dux Shell with your chosen compositor and terminal.
|
||||||
|
|
||||||
Without flags, it launches an interactive TUI. Providing either --compositor
|
Without flags, it launches an interactive TUI. Providing either --compositor
|
||||||
or --term activates headless (unattended) mode, which requires both flags.
|
or --term activates headless (unattended) mode, which requires both flags.
|
||||||
@@ -42,7 +42,7 @@ or --term activates headless (unattended) mode, which requires both flags.
|
|||||||
Headless mode requires cached credentials or a passwordless rule for your
|
Headless mode requires cached credentials or a passwordless rule for your
|
||||||
privilege escalation tool (sudo, doas, or run0).`,
|
privilege escalation tool (sudo, doas, or run0).`,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: runDankinstall,
|
RunE: runDuxinstall,
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ func init() {
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if os.Getuid() == 0 {
|
if os.Getuid() == 0 {
|
||||||
fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root")
|
fmt.Fprintln(os.Stderr, "Error: duxinstall must not be run as root")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runDankinstall(cmd *cobra.Command, args []string) error {
|
func runDuxinstall(cmd *cobra.Command, args []string) error {
|
||||||
headlessMode := compositor != "" || term != ""
|
headlessMode := compositor != "" || term != ""
|
||||||
|
|
||||||
if !headlessMode {
|
if !headlessMode {
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
module github.com/duxlabs-dev/dux-shell/core
|
module github.com/duxlabs-dev/dux-shell/core
|
||||||
|
|
||||||
go 1.27.1
|
go 1.27.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
||||||
@@ -9,7 +9,7 @@ require (
|
|||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/dlclark/regexp2/v2 v2.9.1
|
github.com/dlclark/regexp2/v2 v2.9.1
|
||||||
github.com/duxlabs-dev/dux-go v1.6.3-0.20261020163124-e2ca52bdf500
|
github.com/duxlabs-dev/dux-go v1.6.3-0.20261024072205-827c879f557a
|
||||||
github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea
|
github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea
|
||||||
github.com/fsnotify/fsnotify v1.10.1
|
github.com/fsnotify/fsnotify v1.10.1
|
||||||
github.com/go-git/go-git/v6 v6.0.0-beta.1
|
github.com/go-git/go-git/v6 v6.0.0-beta.1
|
||||||
|
|||||||
+2
-2
@@ -97,8 +97,8 @@ github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yez
|
|||||||
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
|
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
|
||||||
github.com/dlclark/regexp2/v2 v2.9.1 h1:q6bZW/NN9VodqMjYXpuic2lZxdHPfFTzGS+MJxIJmKQ=
|
github.com/dlclark/regexp2/v2 v2.9.1 h1:q6bZW/NN9VodqMjYXpuic2lZxdHPfFTzGS+MJxIJmKQ=
|
||||||
github.com/dlclark/regexp2/v2 v2.9.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
github.com/dlclark/regexp2/v2 v2.9.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||||
github.com/duxlabs-dev/dux-go v1.6.3-0.20261020163124-e2ca52bdf500 h1:y02m7QgoX4rwauY9HePIPGCRDJ/fMirBmgj78IkeUsc=
|
github.com/duxlabs-dev/dux-go v1.6.3-0.20261024072205-827c879f557a h1:AgR1FUYWhq5JgptZy0dwk1vsuHCXmOjxLFkB6iS/uTc=
|
||||||
github.com/duxlabs-dev/dux-go v1.6.3-0.20261020163124-e2ca52bdf500/go.mod h1:b+hBqIgOVAmlFKJ+33SY2pH4kJjXpifNeioPMxwC4Nk=
|
github.com/duxlabs-dev/dux-go v1.6.3-0.20261024072205-827c879f557a/go.mod h1:b+hBqIgOVAmlFKJ+33SY2pH4kJjXpifNeioPMxwC4Nk=
|
||||||
github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea h1:+z9uPtBmpXXBPupB6++/8avPA0weOkzAz7jBoE+yIok=
|
github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea h1:+z9uPtBmpXXBPupB6++/8avPA0weOkzAz7jBoE+yIok=
|
||||||
github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea/go.mod h1:IYu4GO7zR61w3+tDmjg71dQDInXe5TcP3lHQqJpZYR0=
|
github.com/duxlabs-dev/dux-gop v1.6.3-0.20261020163819-0e4888f1fcea/go.mod h1:IYu4GO7zR61w3+tDmjg71dQDInXe5TcP3lHQqJpZYR0=
|
||||||
github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU=
|
github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU=
|
||||||
|
|||||||
+9
-3
@@ -43,7 +43,7 @@ if [ -z "$LATEST_VERSION" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf "%bInstalling Dankinstall %s for %s...%b\n" "$GREEN" "$LATEST_VERSION" "$ARCH" "$NC"
|
printf "%bInstalling Duxinstall %s for %s...%b\n" "$GREEN" "$LATEST_VERSION" "$ARCH" "$NC"
|
||||||
|
|
||||||
# Download and install
|
# Download and install
|
||||||
TEMP_DIR=$(mktemp -d)
|
TEMP_DIR=$(mktemp -d)
|
||||||
@@ -52,8 +52,14 @@ cd "$TEMP_DIR" || exit 1
|
|||||||
|
|
||||||
# Download the gzipped binary and its checksum
|
# Download the gzipped binary and its checksum
|
||||||
printf "%bDownloading installer...%b\n" "$GREEN" "$NC"
|
printf "%bDownloading installer...%b\n" "$GREEN" "$NC"
|
||||||
curl -L "https://github.com/duxlabs-dev/dux-shell/releases/download/$LATEST_VERSION/dankinstall-$ARCH.gz" -o "installer.gz"
|
RELEASE_URL="https://github.com/duxlabs-dev/dux-shell/releases/download/$LATEST_VERSION"
|
||||||
curl -L "https://github.com/duxlabs-dev/dux-shell/releases/download/$LATEST_VERSION/dankinstall-$ARCH.gz.sha256" -o "expected.sha256"
|
# install.duxlabs.dev serves this file from master; releases before 2.0.1 only ship dankinstall-*.gz
|
||||||
|
ASSET="duxinstall-$ARCH.gz"
|
||||||
|
if ! curl -fsLI "$RELEASE_URL/$ASSET" >/dev/null; then
|
||||||
|
ASSET="dankinstall-$ARCH.gz"
|
||||||
|
fi
|
||||||
|
curl -fL "$RELEASE_URL/$ASSET" -o "installer.gz"
|
||||||
|
curl -fL "$RELEASE_URL/$ASSET.sha256" -o "expected.sha256"
|
||||||
|
|
||||||
# Get the expected checksum
|
# Get the expected checksum
|
||||||
EXPECTED_CHECKSUM=$(awk '{print $1}' expected.sha256)
|
EXPECTED_CHECKSUM=$(awk '{print $1}' expected.sha256)
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,11 +17,46 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type File struct {
|
type File struct {
|
||||||
PolicyVersion int `json:"policy_version"`
|
PolicyVersion int `json:"policy_version"`
|
||||||
ImmutableSystem *bool `json:"immutable_system"`
|
ImmutableSystem *bool `json:"immutable_system"`
|
||||||
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 {
|
if err != nil {
|
||||||
return false, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if policy != nil && policy.DisableRegistries != nil {
|
if policy != nil {
|
||||||
disabled = *policy.DisableRegistries
|
policies = append(policies, policy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return disabled, nil
|
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 {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return flag(policies, func(p *File) *bool { return p.DisableRegistries }), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 settings
|
||||||
|
}
|
||||||
|
|
||||||
|
// LockSettings returns the managed lock settings as raw JSON by key. A policy
|
||||||
|
// file that fails to load manages none, and its error is returned.
|
||||||
|
func LockSettings(fs afero.Fs) (map[string]json.RawMessage, error) {
|
||||||
|
policies, err := load(fs)
|
||||||
|
return lockSettings(policies), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LockSettingBool returns a managed boolean lock setting, or nil when it is
|
||||||
|
// not managed or not a boolean, which the shell ignores too.
|
||||||
|
func LockSettingBool(settings map[string]json.RawMessage, key string) *bool {
|
||||||
|
var value *bool
|
||||||
|
if err := json.Unmarshal(settings[key], &value); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShellEnv returns every shell switch as DMS_POLICY_<KEY>=1 or 0 and the
|
||||||
|
// managed lock settings as DMS_POLICY_LOCK_SETTINGS=<JSON object>, so a value
|
||||||
|
// inherited by the shell never counts. A policy file that fails to load turns
|
||||||
|
// every switch on and manages no lock setting, and its error is returned with
|
||||||
|
// them.
|
||||||
|
func ShellEnv(fs afero.Fs) ([]string, error) {
|
||||||
|
policies, err := load(fs)
|
||||||
|
env := make([]string, 0, len(shellSwitches)+1)
|
||||||
|
for _, s := range shellSwitches {
|
||||||
|
value := "0"
|
||||||
|
if err != nil || flag(policies, s.value) {
|
||||||
|
value = "1"
|
||||||
|
}
|
||||||
|
env = append(env, "DMS_POLICY_"+strings.ToUpper(s.key)+"="+value)
|
||||||
|
}
|
||||||
|
// The values come from json.Unmarshal, so they marshal.
|
||||||
|
settings, _ := json.Marshal(lockSettings(policies))
|
||||||
|
env = append(env, "DMS_POLICY_LOCK_SETTINGS="+string(settings))
|
||||||
|
return env, err
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package clipolicy
|
package clipolicy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@@ -45,3 +47,185 @@ func TestRegistriesDisabled(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLogindUnlockDisabled(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
packaged string
|
||||||
|
admin string
|
||||||
|
want bool
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "no policy files"},
|
||||||
|
{name: "admin policy", admin: `{"disable_logind_unlock": true}`, want: true},
|
||||||
|
{name: "admin policy overrides packaged", packaged: `{"disable_logind_unlock": true}`, admin: `{"disable_logind_unlock": false}`},
|
||||||
|
{name: "another key", admin: `{"disable_lock_ipc_unlock": true}`},
|
||||||
|
{name: "malformed admin policy", admin: "{not json", wantErr: "failed to parse " + AdminPath},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
if tt.packaged != "" {
|
||||||
|
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(tt.packaged), 0o644))
|
||||||
|
}
|
||||||
|
if tt.admin != "" {
|
||||||
|
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(tt.admin), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
disabled, err := LogindUnlockDisabled(fs)
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
require.ErrorContains(t, err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, disabled)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var shellSwitchEnv = map[string]string{
|
||||||
|
"disable_lock_ipc_unlock": "DMS_POLICY_DISABLE_LOCK_IPC_UNLOCK",
|
||||||
|
"disable_logind_unlock": "DMS_POLICY_DISABLE_LOGIND_UNLOCK",
|
||||||
|
"disable_custom_lock_pam": "DMS_POLICY_DISABLE_CUSTOM_LOCK_PAM",
|
||||||
|
"disable_user_plugins": "DMS_POLICY_DISABLE_USER_PLUGINS",
|
||||||
|
"disable_settings_ipc_set": "DMS_POLICY_DISABLE_SETTINGS_IPC_SET",
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellEnv(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
packaged string
|
||||||
|
admin string
|
||||||
|
on bool
|
||||||
|
}{
|
||||||
|
{name: "no policy files"},
|
||||||
|
{name: "packaged policy", packaged: `{"KEY": true}`, on: true},
|
||||||
|
{name: "admin policy", admin: `{"KEY": true}`, on: true},
|
||||||
|
{name: "admin policy overrides packaged", packaged: `{"KEY": true}`, admin: `{"KEY": false}`},
|
||||||
|
{name: "admin policy without the key keeps packaged", packaged: `{"KEY": true}`, admin: `{"immutable_system": true}`, on: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, name := range shellSwitchEnv {
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(key+"/"+tt.name, func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
if tt.packaged != "" {
|
||||||
|
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(strings.ReplaceAll(tt.packaged, "KEY", key)), 0o644))
|
||||||
|
}
|
||||||
|
if tt.admin != "" {
|
||||||
|
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(strings.ReplaceAll(tt.admin, "KEY", key)), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
var want []string
|
||||||
|
for _, other := range shellSwitchEnv {
|
||||||
|
if other == name && tt.on {
|
||||||
|
want = append(want, other+"=1")
|
||||||
|
} else {
|
||||||
|
want = append(want, other+"=0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want = append(want, "DMS_POLICY_LOCK_SETTINGS={}")
|
||||||
|
|
||||||
|
env, err := ShellEnv(fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.ElementsMatch(t, want, env)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellEnvMalformedPolicyTurnsEverySwitchOnAndIgnoresLockSettings(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(`{"lock_settings": {"lockBeforeSuspend": true}}`), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte("{not json"), 0o644))
|
||||||
|
|
||||||
|
want := []string{"DMS_POLICY_LOCK_SETTINGS={}"}
|
||||||
|
for _, name := range shellSwitchEnv {
|
||||||
|
want = append(want, name+"=1")
|
||||||
|
}
|
||||||
|
|
||||||
|
env, err := ShellEnv(fs)
|
||||||
|
require.ErrorContains(t, err, "failed to parse "+AdminPath)
|
||||||
|
assert.ElementsMatch(t, want, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLockSettings(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
packaged string
|
||||||
|
admin string
|
||||||
|
want string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "no policy files", want: `{}`},
|
||||||
|
{name: "no lock settings", admin: `{"disable_registries": true}`, want: `{}`},
|
||||||
|
{name: "packaged policy", packaged: `{"lock_settings": {"lockBeforeSuspend": true, "customPowerActionLock": ""}}`, want: `{"lockBeforeSuspend": true, "customPowerActionLock": ""}`},
|
||||||
|
{name: "admin policy overrides packaged per key", packaged: `{"lock_settings": {"lockBeforeSuspend": true, "acLockTimeout": 300}}`, admin: `{"lock_settings": {"lockBeforeSuspend": false}}`, want: `{"lockBeforeSuspend": false, "acLockTimeout": 300}`},
|
||||||
|
{name: "admin policy without lock settings keeps packaged", packaged: `{"lock_settings": {"enableFprint": false}}`, admin: `{"disable_registries": true}`, want: `{"enableFprint": false}`},
|
||||||
|
{name: "null leaves a key unset", packaged: `{"lock_settings": {"u2fMode": "and"}}`, admin: `{"lock_settings": {"u2fMode": null, "lockAtStartup": null}}`, want: `{"u2fMode": "and"}`},
|
||||||
|
{
|
||||||
|
name: "only lock settings are managed",
|
||||||
|
admin: `{"lock_settings": {"customPowerActionLock": "", "lockBeforeSuspend": true, "loginctlLockIntegration": true, "lockAtStartup": true, "acLockTimeout": 300, "batteryLockTimeout": 120, "fadeToLockEnabled": true, "fadeToLockGracePeriod": 5, "enableFprint": true, "maxFprintTries": 3, "enableU2f": true, "u2fMode": "and", "lockScreenShowPowerActions": false, "lockPamPath": "/tmp/pam", "unknownSetting": 1}}`,
|
||||||
|
want: `{"customPowerActionLock": "", "lockBeforeSuspend": true, "loginctlLockIntegration": true, "lockAtStartup": true, "acLockTimeout": 300, "batteryLockTimeout": 120, "fadeToLockEnabled": true, "fadeToLockGracePeriod": 5, "enableFprint": true, "maxFprintTries": 3, "enableU2f": true, "u2fMode": "and"}`,
|
||||||
|
},
|
||||||
|
{name: "values are passed as written", admin: `{"lock_settings": {"lockBeforeSuspend": "yes", "acLockTimeout": [1]}}`, want: `{"lockBeforeSuspend": "yes", "acLockTimeout": [1]}`},
|
||||||
|
{name: "malformed admin policy manages nothing", packaged: `{"lock_settings": {"lockBeforeSuspend": true}}`, admin: "{not json", want: `{}`, wantErr: "failed to parse " + AdminPath},
|
||||||
|
{name: "lock settings not an object", packaged: `{"lock_settings": {"lockBeforeSuspend": true}}`, admin: `{"lock_settings": ["lockBeforeSuspend"]}`, want: `{}`, wantErr: "failed to parse " + AdminPath},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
if tt.packaged != "" {
|
||||||
|
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(tt.packaged), 0o644))
|
||||||
|
}
|
||||||
|
if tt.admin != "" {
|
||||||
|
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(tt.admin), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := LockSettings(fs)
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
require.ErrorContains(t, err, tt.wantErr)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
got, err := json.Marshal(settings)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.JSONEq(t, tt.want, string(got))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellEnvLockSettings(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(`{"lock_settings": {"lockBeforeSuspend": false, "acLockTimeout": 300}}`), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(`{"lock_settings": {"lockBeforeSuspend": true, "customPowerActionLock": "", "lockScreenShowWeather": false}}`), 0o644))
|
||||||
|
|
||||||
|
env, err := ShellEnv(fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, env, `DMS_POLICY_LOCK_SETTINGS={"acLockTimeout":300,"customPowerActionLock":"","lockBeforeSuspend":true}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLockSettingBool(t *testing.T) {
|
||||||
|
on, off := true, false
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
settings string
|
||||||
|
want *bool
|
||||||
|
}{
|
||||||
|
{name: "not managed", settings: `{"lockAtStartup": true}`},
|
||||||
|
{name: "managed on", settings: `{"lockBeforeSuspend": true}`, want: &on},
|
||||||
|
{name: "managed off", settings: `{"lockBeforeSuspend": false}`, want: &off},
|
||||||
|
{name: "null", settings: `{"lockBeforeSuspend": null}`},
|
||||||
|
{name: "not a boolean", settings: `{"lockBeforeSuspend": "true"}`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var settings map[string]json.RawMessage
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(tt.settings), &settings))
|
||||||
|
assert.Equal(t, tt.want, LockSettingBool(settings, "lockBeforeSuspend"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,7 +182,7 @@ layout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigDeploymentFlow(t *testing.T) {
|
func TestConfigDeploymentFlow(t *testing.T) {
|
||||||
tempDir, err := os.MkdirTemp("", "dankinstall-test")
|
tempDir, err := os.MkdirTemp("", "duxinstall-test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(tempDir)
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
@@ -312,7 +312,7 @@ func TestHyprlangMonitorLineToLuaPreservesOptions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHyprlandConfigDeployment(t *testing.T) {
|
func TestHyprlandConfigDeployment(t *testing.T) {
|
||||||
tempDir, err := os.MkdirTemp("", "dankinstall-hyprland-test")
|
tempDir, err := os.MkdirTemp("", "duxinstall-hyprland-test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(tempDir)
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
@@ -324,7 +324,7 @@ func TestHyprlandConfigDeployment(t *testing.T) {
|
|||||||
cd := NewConfigDeployer(logChan)
|
cd := NewConfigDeployer(logChan)
|
||||||
|
|
||||||
t.Run("deploy hyprland config to empty directory", func(t *testing.T) {
|
t.Run("deploy hyprland config to empty directory", func(t *testing.T) {
|
||||||
td, err := os.MkdirTemp("", "dankinstall-hyprland-empty")
|
td, err := os.MkdirTemp("", "duxinstall-hyprland-empty")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(td)
|
defer os.RemoveAll(td)
|
||||||
os.Setenv("HOME", td)
|
os.Setenv("HOME", td)
|
||||||
@@ -344,7 +344,7 @@ func TestHyprlandConfigDeployment(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("deploy hyprland config with existing monitors", func(t *testing.T) {
|
t.Run("deploy hyprland config with existing monitors", func(t *testing.T) {
|
||||||
td, err := os.MkdirTemp("", "dankinstall-hyprland-merge")
|
td, err := os.MkdirTemp("", "duxinstall-hyprland-merge")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(td)
|
defer os.RemoveAll(td)
|
||||||
os.Setenv("HOME", td)
|
os.Setenv("HOME", td)
|
||||||
@@ -408,7 +408,7 @@ general {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("deploy hyprland config removes root legacy symlink when lua exists", func(t *testing.T) {
|
t.Run("deploy hyprland config removes root legacy symlink when lua exists", func(t *testing.T) {
|
||||||
td, err := os.MkdirTemp("", "dankinstall-hyprland-lua-conf-symlink")
|
td, err := os.MkdirTemp("", "duxinstall-hyprland-lua-conf-symlink")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(td)
|
defer os.RemoveAll(td)
|
||||||
os.Setenv("HOME", td)
|
os.Setenv("HOME", td)
|
||||||
@@ -431,7 +431,7 @@ general {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("deploy hyprland config refreshes managed binds but preserves user binds", func(t *testing.T) {
|
t.Run("deploy hyprland config refreshes managed binds but preserves user binds", func(t *testing.T) {
|
||||||
td, err := os.MkdirTemp("", "dankinstall-hyprland-refresh-binds")
|
td, err := os.MkdirTemp("", "duxinstall-hyprland-refresh-binds")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(td)
|
defer os.RemoveAll(td)
|
||||||
os.Setenv("HOME", td)
|
os.Setenv("HOME", td)
|
||||||
@@ -457,7 +457,7 @@ general {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestKittyConfigDeployment(t *testing.T) {
|
func TestKittyConfigDeployment(t *testing.T) {
|
||||||
tempDir, err := os.MkdirTemp("", "dankinstall-kitty-test")
|
tempDir, err := os.MkdirTemp("", "duxinstall-kitty-test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(tempDir)
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
@@ -495,7 +495,7 @@ func TestKittyConfigDeployment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlacrittyConfigDeployment(t *testing.T) {
|
func TestAlacrittyConfigDeployment(t *testing.T) {
|
||||||
tempDir, err := os.MkdirTemp("", "dankinstall-alacritty-test")
|
tempDir, err := os.MkdirTemp("", "duxinstall-alacritty-test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(tempDir)
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
@@ -570,7 +570,7 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.Run("replaceConfigs nil deploys config", func(t *testing.T) {
|
t.Run("replaceConfigs nil deploys config", func(t *testing.T) {
|
||||||
tempDir, err := os.MkdirTemp("", "dankinstall-replace-nil-test")
|
tempDir, err := os.MkdirTemp("", "duxinstall-replace-nil-test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(tempDir)
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
@@ -603,7 +603,7 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("replaceConfigs all false and config missing deploys config", func(t *testing.T) {
|
t.Run("replaceConfigs all false and config missing deploys config", func(t *testing.T) {
|
||||||
tempDir, err := os.MkdirTemp("", "dankinstall-replace-missing-test")
|
tempDir, err := os.MkdirTemp("", "duxinstall-replace-missing-test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(tempDir)
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
@@ -636,7 +636,7 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("replaceConfigs false and config exists skips config", func(t *testing.T) {
|
t.Run("replaceConfigs false and config exists skips config", func(t *testing.T) {
|
||||||
tempDir, err := os.MkdirTemp("", "dankinstall-replace-exists-test")
|
tempDir, err := os.MkdirTemp("", "duxinstall-replace-exists-test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(tempDir)
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
@@ -678,7 +678,7 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("replaceConfigs true and config exists deploys config", func(t *testing.T) {
|
t.Run("replaceConfigs true and config exists deploys config", func(t *testing.T) {
|
||||||
tempDir, err := os.MkdirTemp("", "dankinstall-replace-true-test")
|
tempDir, err := os.MkdirTemp("", "duxinstall-replace-true-test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(tempDir)
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
@@ -725,7 +725,7 @@ func TestShouldReplaceConfigDeployIfMissing(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("hyprland legacy config exists skips when replace false", func(t *testing.T) {
|
t.Run("hyprland legacy config exists skips when replace false", func(t *testing.T) {
|
||||||
tempDir, err := os.MkdirTemp("", "dankinstall-hyprland-legacy-skip-test")
|
tempDir, err := os.MkdirTemp("", "duxinstall-hyprland-legacy-skip-test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(tempDir)
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Auto-generated by DMS. Overwritten by matugen (dms/colors.conf).
|
# Auto-generated by DMS. Overwritten by matugen (dms/colors.conf).
|
||||||
# Remove `source=./dms/colors.conf` from config.conf to override manually.
|
# Remove `source=./dms/colors.conf` from config.conf to override manually.
|
||||||
|
|
||||||
bordercolor = 0x595959ff
|
border_color = 0x595959ff
|
||||||
focuscolor = 0x8ab4f8ff
|
focus_color = 0x8ab4f8ff
|
||||||
urgentcolor = 0xff5555ff
|
urgent_color = 0xff5555ff
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
# Auto-generated by DMS. Regenerated from DMS settings (dms/layout.conf).
|
# Auto-generated by DMS. Regenerated from DMS settings (dms/layout.conf).
|
||||||
|
|
||||||
border_radius=12
|
border_radius=12
|
||||||
gappih=5
|
gap_inner_horizontal=5
|
||||||
gappiv=5
|
gap_inner_vertical=5
|
||||||
gappoh=5
|
gap_outer_horizontal=5
|
||||||
gappov=5
|
gap_outer_vertical=5
|
||||||
borderpx=2
|
border_px=2
|
||||||
|
|
||||||
|
# layer_animations would tween every DMS layer move; override with no_animation:0 rules after the source lines.
|
||||||
|
layer_rule=no_animation:1,layer_name:^(quickshell)$
|
||||||
|
layer_rule=no_animation:1,layer_name:^dms:.*
|
||||||
@@ -7,7 +7,7 @@ env=XDG_SESSION_TYPE,wayland
|
|||||||
|
|
||||||
# Starts the shell when DMS is not a systemd service. Never exec=: Mango re-runs
|
# Starts the shell when DMS is not a systemd service. Never exec=: Mango re-runs
|
||||||
# exec= on every config reload, and DMS reloads the config.
|
# exec= on every config reload, and DMS reloads the config.
|
||||||
exec-once=dms run
|
exec_once=dms run
|
||||||
|
|
||||||
source=./dms/colors.conf
|
source=./dms/colors.conf
|
||||||
source=./dms/layout.conf
|
source=./dms/layout.conf
|
||||||
|
|||||||
@@ -41,14 +41,13 @@ func nonEmptyFile(path string) (string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
mangoBindLine = regexp.MustCompile(`^\s*(bind[a-z]*|mousebind|axisbind|gesturebind|switchbind|keymode|key_mode)\s*=`)
|
mangoBindLine = regexp.MustCompile(`^\s*(bind[a-z]*|mousebind|axisbind|gesturebind|switchbind|key_mode)\s*=`)
|
||||||
mangoMonitorLine = regexp.MustCompile(`^\s*(monitorrule|monitor_rule)\s*=`)
|
mangoMonitorLine = regexp.MustCompile(`^\s*monitor_rule\s*=`)
|
||||||
mangoWindowLine = regexp.MustCompile(`^\s*(windowrule|window_rule|windowrule-once|window_rule_once)\s*=`)
|
mangoWindowLine = regexp.MustCompile(`^\s*window_rule(_once)?\s*=`)
|
||||||
mangoExecOnceDMS = regexp.MustCompile(`(?m)^# Starts the shell when DMS.*\n#.*\n(exec-once|exec_once)=dms run\n`)
|
mangoExecOnceDMS = regexp.MustCompile(`(?m)^# Starts the shell when DMS.*\n#.*\nexec_once=dms run\n`)
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
mangoDialect = mangoconf.Detect
|
|
||||||
mangoSessionTargetInstalled = mangoconf.SessionTargetInstalled
|
mangoSessionTargetInstalled = mangoconf.SessionTargetInstalled
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -110,17 +109,16 @@ func (cd *ConfigDeployer) deployMangoConfig(terminalCommand string, useSystemd b
|
|||||||
cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath))
|
cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
dialect := mangoDialect()
|
|
||||||
newConfig := strings.ReplaceAll(MangoConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
|
newConfig := strings.ReplaceAll(MangoConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
|
||||||
if cd.systemdAutostart(deps.WindowManagerMango, useSystemd) {
|
if cd.systemdAutostart(deps.WindowManagerMango, useSystemd) {
|
||||||
newConfig = mangoExecOnceDMS.ReplaceAllString(newConfig, "")
|
newConfig = mangoExecOnceDMS.ReplaceAllString(newConfig, "")
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(result.Path, []byte(dialect.Translate(newConfig)), 0o644); err != nil {
|
if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil {
|
||||||
result.Error = fmt.Errorf("failed to write config: %w", err)
|
result.Error = fmt.Errorf("failed to write config: %w", err)
|
||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cd.deployMangoDmsConfigs(dmsDir, terminalCommand, dialect, existing, timestamp); err != nil {
|
if err := cd.deployMangoDmsConfigs(dmsDir, terminalCommand, existing, timestamp); err != nil {
|
||||||
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
|
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
|
||||||
return result, result.Error
|
return result, result.Error
|
||||||
}
|
}
|
||||||
@@ -132,7 +130,7 @@ func (cd *ConfigDeployer) deployMangoConfig(terminalCommand string, useSystemd b
|
|||||||
|
|
||||||
// Like niri, fragments the user or the shell already filled are left alone; the
|
// Like niri, fragments the user or the shell already filled are left alone; the
|
||||||
// replaced config.conf hands its binds, monitor and window rules to empty ones.
|
// replaced config.conf hands its binds, monitor and window rules to empty ones.
|
||||||
func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string, dialect mangoconf.Dialect, oldMain, timestamp string) error {
|
func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand, oldMain, timestamp string) error {
|
||||||
stockBinds := strings.ReplaceAll(MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
|
stockBinds := strings.ReplaceAll(MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
|
||||||
bindsPath := filepath.Join(dmsDir, "binds.conf")
|
bindsPath := filepath.Join(dmsDir, "binds.conf")
|
||||||
if data, err := os.ReadFile(bindsPath); err == nil && len(data) > 0 {
|
if data, err := os.ReadFile(bindsPath); err == nil && len(data) > 0 {
|
||||||
@@ -142,7 +140,7 @@ func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string,
|
|||||||
}
|
}
|
||||||
cd.log(fmt.Sprintf("Backed up existing binds to %s", backup))
|
cd.log(fmt.Sprintf("Backed up existing binds to %s", backup))
|
||||||
if cd.replaceBinds {
|
if cd.replaceBinds {
|
||||||
if err := os.WriteFile(bindsPath, []byte(dialect.Translate(stockBinds)), 0o644); err != nil {
|
if err := os.WriteFile(bindsPath, []byte(stockBinds), 0o644); err != nil {
|
||||||
return fmt.Errorf("failed to write binds.conf: %w", err)
|
return fmt.Errorf("failed to write binds.conf: %w", err)
|
||||||
}
|
}
|
||||||
cd.log("Deployed DMS stock binds.conf")
|
cd.log("Deployed DMS stock binds.conf")
|
||||||
@@ -160,7 +158,7 @@ func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string,
|
|||||||
binds = mangoconf.BindsMovedHeader + "\n" + old
|
binds = mangoconf.BindsMovedHeader + "\n" + old
|
||||||
cd.log("Moved existing binds from config.conf to binds.conf")
|
cd.log("Moved existing binds from config.conf to binds.conf")
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(bindsPath, []byte(dialect.Translate(binds)), 0o644); err != nil {
|
if err := os.WriteFile(bindsPath, []byte(binds), 0o644); err != nil {
|
||||||
return fmt.Errorf("failed to write binds.conf: %w", err)
|
return fmt.Errorf("failed to write binds.conf: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,7 +180,7 @@ func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string,
|
|||||||
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
|
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, []byte(dialect.Translate(cfg.content)), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil {
|
||||||
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
|
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
|
||||||
}
|
}
|
||||||
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
|
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
|
||||||
|
|||||||
@@ -13,12 +13,11 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func stubMango(t *testing.T, dialect mangoconf.Dialect, target bool, addWants error) *int {
|
func stubMango(t *testing.T, target bool, addWants error) *int {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
prevDialect, prevTarget := mangoDialect, mangoSessionTargetInstalled
|
prevTarget := mangoSessionTargetInstalled
|
||||||
mangoDialect = func() mangoconf.Dialect { return dialect }
|
|
||||||
mangoSessionTargetInstalled = func() bool { return target }
|
mangoSessionTargetInstalled = func() bool { return target }
|
||||||
t.Cleanup(func() { mangoDialect, mangoSessionTargetInstalled = prevDialect, prevTarget })
|
t.Cleanup(func() { mangoSessionTargetInstalled = prevTarget })
|
||||||
return stubAddWants(t, addWants)
|
return stubAddWants(t, addWants)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,16 +30,16 @@ func readMango(t *testing.T, home, rel string) string {
|
|||||||
|
|
||||||
const oldMangoConfig = `exec-once=waybar
|
const oldMangoConfig = `exec-once=waybar
|
||||||
bind=SUPER,Return,spawn,foot
|
bind=SUPER,Return,spawn,foot
|
||||||
keymode=resize
|
key_mode=resize
|
||||||
bind=NONE,h,resizewin,-10,0
|
bind=NONE,h,resizewin,-10,0
|
||||||
monitorrule=name:^DP-1$,width:2560,height:1440,refresh:144,x:0,y:0,scale:1,rr:0,vrr:1,hdr:1
|
monitor_rule=name:^DP-1$,width:2560,height:1440,refresh:144,x:0,y:0,scale:1,rr:0,vrr:1,hdr:1
|
||||||
windowrule=appid:pavucontrol,isfloating:1
|
window_rule=app_id:pavucontrol,is_floating:1
|
||||||
`
|
`
|
||||||
|
|
||||||
func TestMangoDeployMovesUserRulesAndKeepsBindsByDefault(t *testing.T) {
|
func TestMangoDeployMovesUserRulesAndKeepsBindsByDefault(t *testing.T) {
|
||||||
home := t.TempDir()
|
home := t.TempDir()
|
||||||
t.Setenv("HOME", home)
|
t.Setenv("HOME", home)
|
||||||
stubMango(t, mangoconf.Legacy, false, nil)
|
stubMango(t, false, nil)
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(home, ".config", "mango"), 0o755))
|
require.NoError(t, os.MkdirAll(filepath.Join(home, ".config", "mango"), 0o755))
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(home, ".config", "mango", "config.conf"), []byte(oldMangoConfig), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(home, ".config", "mango", "config.conf"), []byte(oldMangoConfig), 0o644))
|
||||||
|
|
||||||
@@ -48,25 +47,26 @@ func TestMangoDeployMovesUserRulesAndKeepsBindsByDefault(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotEmpty(t, result.BackupPath)
|
assert.NotEmpty(t, result.BackupPath)
|
||||||
|
|
||||||
assert.Contains(t, readMango(t, home, "config.conf"), "exec-once=dms run")
|
assert.Contains(t, readMango(t, home, "config.conf"), "exec_once=dms run")
|
||||||
assert.Contains(t, readMango(t, home, "config.conf"), "source=./dms/input.conf")
|
assert.Contains(t, readMango(t, home, "config.conf"), "source=./dms/input.conf")
|
||||||
assert.Equal(t, "", readMango(t, home, "dms/input.conf"))
|
assert.Equal(t, "", readMango(t, home, "dms/input.conf"))
|
||||||
binds := readMango(t, home, "dms/binds.conf")
|
binds := readMango(t, home, "dms/binds.conf")
|
||||||
assert.Contains(t, binds, "bind=SUPER,Return,spawn,foot\nkeymode=resize\nbind=NONE,h,resizewin,-10,0")
|
assert.Contains(t, binds, "bind=SUPER,Return,spawn,foot\nkey_mode=resize\nbind=NONE,h,resizewin,-10,0")
|
||||||
assert.NotContains(t, binds, "{{TERMINAL_COMMAND}}")
|
assert.NotContains(t, binds, "{{TERMINAL_COMMAND}}")
|
||||||
|
assert.Contains(t, readMango(t, home, "dms/outputs.conf"), "monitor_rule=name:^DP-1$")
|
||||||
assert.Contains(t, readMango(t, home, "dms/outputs.conf"), "hdr:1")
|
assert.Contains(t, readMango(t, home, "dms/outputs.conf"), "hdr:1")
|
||||||
assert.Contains(t, readMango(t, home, "dms/windowrules.conf"), "windowrule=appid:pavucontrol,isfloating:1")
|
assert.Contains(t, readMango(t, home, "dms/windowrules.conf"), "window_rule=app_id:pavucontrol,is_floating:1")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMangoDeployBindsChoiceAlwaysBacksUp(t *testing.T) {
|
func TestMangoDeployBindsChoiceAlwaysBacksUp(t *testing.T) {
|
||||||
for _, replace := range []bool{false, true} {
|
for _, replace := range []bool{false, true} {
|
||||||
home := t.TempDir()
|
home := t.TempDir()
|
||||||
t.Setenv("HOME", home)
|
t.Setenv("HOME", home)
|
||||||
stubMango(t, mangoconf.Legacy, false, nil)
|
stubMango(t, false, nil)
|
||||||
dms := filepath.Join(home, ".config", "mango", "dms")
|
dms := filepath.Join(home, ".config", "mango", "dms")
|
||||||
require.NoError(t, os.MkdirAll(dms, 0o755))
|
require.NoError(t, os.MkdirAll(dms, 0o755))
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dms, "binds.conf"), []byte("bind=SUPER,x,killclient\n"), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(dms, "binds.conf"), []byte("bind=SUPER,x,killclient\nkey_mode=resize\n"), 0o644))
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dms, "windowrules.conf"), []byte("windowrule=appid:keep,isglobal:1\n"), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(dms, "windowrules.conf"), []byte("window_rule=app_id:keep,is_global:1\n"), 0o644))
|
||||||
|
|
||||||
cd := NewConfigDeployer(nil)
|
cd := NewConfigDeployer(nil)
|
||||||
cd.SetReplaceBinds(replace)
|
cd.SetReplaceBinds(replace)
|
||||||
@@ -76,8 +76,8 @@ func TestMangoDeployBindsChoiceAlwaysBacksUp(t *testing.T) {
|
|||||||
backups, _ := filepath.Glob(filepath.Join(dms, "binds.conf.backup.*"))
|
backups, _ := filepath.Glob(filepath.Join(dms, "binds.conf.backup.*"))
|
||||||
assert.Len(t, backups, 1, "replace=%v", replace)
|
assert.Len(t, backups, 1, "replace=%v", replace)
|
||||||
binds := readMango(t, home, "dms/binds.conf")
|
binds := readMango(t, home, "dms/binds.conf")
|
||||||
assert.Equal(t, !replace, binds == mangoconf.BindsKeptHeader+"\nbind=SUPER,x,killclient\n", "replace=%v", replace)
|
assert.Equal(t, !replace, binds == mangoconf.BindsKeptHeader+"\nbind=SUPER,x,killclient\nkey_mode=resize\n", "replace=%v", replace)
|
||||||
assert.Equal(t, "windowrule=appid:keep,isglobal:1\n", readMango(t, home, "dms/windowrules.conf"))
|
assert.Equal(t, "window_rule=app_id:keep,is_global:1\n", readMango(t, home, "dms/windowrules.conf"), "kept fragments are left alone")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ func TestMangoDeploySystemdAutostart(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
home := t.TempDir()
|
home := t.TempDir()
|
||||||
t.Setenv("HOME", home)
|
t.Setenv("HOME", home)
|
||||||
calls := stubMango(t, mangoconf.Snake, tc.target, tc.addWants)
|
calls := stubMango(t, tc.target, tc.addWants)
|
||||||
_, err := NewConfigDeployer(nil).DeployCompositor(deps.WindowManagerMango, "kitty", true)
|
_, err := NewConfigDeployer(nil).DeployCompositor(deps.WindowManagerMango, "kitty", true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
main := readMango(t, home, "config.conf")
|
main := readMango(t, home, "config.conf")
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func TestDisableSessionRemovesEveryWantsLink(t *testing.T) {
|
|||||||
|
|
||||||
func TestEnableSessionMangoWithoutTarget(t *testing.T) {
|
func TestEnableSessionMangoWithoutTarget(t *testing.T) {
|
||||||
t.Setenv("HOME", t.TempDir())
|
t.Setenv("HOME", t.TempDir())
|
||||||
calls := stubMango(t, mangoconf.Snake, false, nil)
|
calls := stubMango(t, false, nil)
|
||||||
unit, err := EnableSession(deps.WindowManagerMango)
|
unit, err := EnableSession(deps.WindowManagerMango)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Empty(t, unit)
|
assert.Empty(t, unit)
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
|
|||||||
func (a *ArchDistribution) detectDanksearch() deps.Dependency {
|
func (a *ArchDistribution) detectDanksearch() deps.Dependency {
|
||||||
dep := a.BaseDistribution.detectDanksearch()
|
dep := a.BaseDistribution.detectDanksearch()
|
||||||
dep.CanToggle = true
|
dep.CanToggle = true
|
||||||
if a.packageInstalled("dsearch-git") {
|
if a.packageInstalled("dux-search-git") {
|
||||||
dep.Variant = deps.VariantGit
|
dep.Variant = deps.VariantGit
|
||||||
}
|
}
|
||||||
return dep
|
return dep
|
||||||
@@ -136,14 +136,14 @@ func (a *ArchDistribution) detectDanksearch() deps.Dependency {
|
|||||||
|
|
||||||
func (a *ArchDistribution) detectDMS() deps.Dependency {
|
func (a *ArchDistribution) detectDMS() deps.Dependency {
|
||||||
dep := a.BaseDistribution.detectDMS()
|
dep := a.BaseDistribution.detectDMS()
|
||||||
if a.packageInstalled("dms-shell-git") {
|
if a.packageInstalled("dux-shell-git") {
|
||||||
dep.Variant = deps.VariantGit
|
dep.Variant = deps.VariantGit
|
||||||
}
|
}
|
||||||
return dep
|
return dep
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) detectDankCalendar() deps.Dependency {
|
func (a *ArchDistribution) detectDankCalendar() deps.Dependency {
|
||||||
return withGitVariant(a.BaseDistribution.detectDankCalendar(), a.packageInstalled("dankcalendar-git"))
|
return withGitVariant(a.BaseDistribution.detectDankCalendar(), a.packageInstalled("dux-calendar-git"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) detectXDGPortal() deps.Dependency {
|
func (a *ArchDistribution) detectXDGPortal() deps.Dependency {
|
||||||
@@ -155,19 +155,17 @@ func (a *ArchDistribution) detectAccountsService() deps.Dependency {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) detectDMSGreeter() deps.Dependency {
|
func (a *ArchDistribution) detectDMSGreeter() deps.Dependency {
|
||||||
installed := a.packageInstalled("greetd-dms-greeter-git") || a.packageInstalled("greetd-dms-greeter-bin")
|
installed := a.packageInstalled("dux-greeter-git") || a.packageInstalled("dux-greeter-bin")
|
||||||
dep := a.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", installed)
|
dep := a.detectOptionalPackage("dms-greeter", "Dux Shell greetd greeter", installed)
|
||||||
dep.CanToggle = true
|
dep.CanToggle = true
|
||||||
if a.packageInstalled("greetd-dms-greeter-git") {
|
if a.packageInstalled("dux-greeter-git") {
|
||||||
dep.Variant = deps.VariantGit
|
dep.Variant = deps.VariantGit
|
||||||
}
|
}
|
||||||
return dep
|
return dep
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) packageInstalled(pkg string) bool {
|
func (a *ArchDistribution) packageInstalled(pkg string) bool {
|
||||||
cmd := exec.Command("pacman", "-Q", pkg)
|
return exec.Command("pacman", "-Q", pkg).Run() == nil
|
||||||
err := cmd.Run()
|
|
||||||
return err == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSRCINFODeps reads a .SRCINFO file and returns runtime dep and makedep package
|
// parseSRCINFODeps reads a .SRCINFO file and returns runtime dep and makedep package
|
||||||
@@ -270,7 +268,7 @@ func (a *ArchDistribution) getMangoMapping(variant deps.PackageVariant) PackageM
|
|||||||
if variant == deps.VariantGit {
|
if variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "mangowm-git", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "mangowm-git", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "mangowm", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "mangowm", Repository: RepoTypeSystem}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) PackageMapping {
|
func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
@@ -286,30 +284,30 @@ func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) Packag
|
|||||||
|
|
||||||
func (a *ArchDistribution) getDanksearchMapping(variant deps.PackageVariant) PackageMapping {
|
func (a *ArchDistribution) getDanksearchMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if variant == deps.VariantGit {
|
if variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "dsearch-git", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "dux-search-git", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "dsearch-bin", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "dux-search-bin", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) getDankCalendarMapping(variant deps.PackageVariant) PackageMapping {
|
func (a *ArchDistribution) getDankCalendarMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if variant == deps.VariantGit {
|
if variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "dankcalendar-git", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "dux-calendar-git", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "dankcalendar-bin", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "dux-calendar-bin", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) getDMSGreeterMapping(variant deps.PackageVariant) PackageMapping {
|
func (a *ArchDistribution) getDMSGreeterMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if variant == deps.VariantGit {
|
if variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "greetd-dms-greeter-git", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "dux-greeter-git", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "greetd-dms-greeter-bin", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "dux-greeter-bin", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMapping {
|
func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMapping {
|
||||||
if forceDMSGit || variant == deps.VariantGit {
|
if forceDMSGit || variant == deps.VariantGit {
|
||||||
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
|
return PackageMapping{Name: "dux-shell-git", Repository: RepoTypeAUR}
|
||||||
}
|
}
|
||||||
return PackageMapping{Name: "dms-shell", Repository: RepoTypeSystem}
|
return PackageMapping{Name: "dux-shell", Repository: RepoTypeSystem}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency {
|
func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency {
|
||||||
@@ -396,7 +394,7 @@ func (a *ArchDistribution) InstallPackages(ctx context.Context, dependencies []d
|
|||||||
|
|
||||||
systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, skipFlags)
|
systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, skipFlags)
|
||||||
|
|
||||||
if slices.Contains(aurPkgs, "quickshell-git") && slices.Contains(systemPkgs, "dms-shell") {
|
if slices.Contains(aurPkgs, "quickshell-git") && slices.Contains(systemPkgs, "dux-shell") {
|
||||||
if err := a.preinstallQuickshellGit(ctx, sudoPassword, progressChan); err != nil {
|
if err := a.preinstallQuickshellGit(ctx, sudoPassword, progressChan); err != nil {
|
||||||
return fmt.Errorf("failed to preinstall quickshell-git: %w", err)
|
return fmt.Errorf("failed to preinstall quickshell-git: %w", err)
|
||||||
}
|
}
|
||||||
@@ -566,7 +564,7 @@ func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPass
|
|||||||
Progress: 0.18,
|
Progress: 0.18,
|
||||||
Step: "Building quickshell-git before system packages...",
|
Step: "Building quickshell-git before system packages...",
|
||||||
IsComplete: false,
|
IsComplete: false,
|
||||||
CommandInfo: "Installing quickshell-git ahead of dms-shell to avoid conflict",
|
CommandInfo: "Installing quickshell-git ahead of dux-shell to avoid conflict",
|
||||||
}
|
}
|
||||||
return a.installSingleAURPackage(ctx, "quickshell-git", sudoPassword, progressChan, 0.18, 0.32)
|
return a.installSingleAURPackage(ctx, "quickshell-git", sudoPassword, progressChan, 0.18, 0.32)
|
||||||
}
|
}
|
||||||
@@ -587,7 +585,8 @@ func (a *ArchDistribution) SwapVariants(ctx context.Context, swaps []VariantSwap
|
|||||||
}
|
}
|
||||||
|
|
||||||
if swap.To.Repository == RepoTypeAUR {
|
if swap.To.Repository == RepoTypeAUR {
|
||||||
if err := a.installSingleAURPackageInternal(ctx, swap.To.Name, swap.From.Name, sudoPassword, progressChan, 0.40, 0.85, make(map[string]bool)); err != nil {
|
from := swap.From.Name
|
||||||
|
if err := a.installSingleAURPackageInternal(ctx, swap.To.Name, from, sudoPassword, progressChan, 0.40, 0.85, make(map[string]bool)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -608,11 +607,7 @@ func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages [
|
|||||||
|
|
||||||
a.log(fmt.Sprintf("Installing system packages: %s", strings.Join(packages, ", ")))
|
a.log(fmt.Sprintf("Installing system packages: %s", strings.Join(packages, ", ")))
|
||||||
|
|
||||||
args := []string{"pacman", "-S", "--needed", "--noconfirm"}
|
args := append([]string{"pacman", "-S", "--needed", "--noconfirm"}, packages...)
|
||||||
if slices.Contains(packages, "dms-shell") {
|
|
||||||
args = append(args, "--assume-installed", "dms-shell-compositor=1")
|
|
||||||
}
|
|
||||||
args = append(args, packages...)
|
|
||||||
|
|
||||||
progressChan <- InstallProgressMsg{
|
progressChan <- InstallProgressMsg{
|
||||||
Phase: PhaseSystemPackages,
|
Phase: PhaseSystemPackages,
|
||||||
@@ -658,7 +653,7 @@ func (a *ArchDistribution) installAURPackages(ctx context.Context, packages []st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reorder packages to ensure dms-shell-git dependencies are installed first
|
// Reorder packages to ensure dux-shell-git dependencies are installed first
|
||||||
orderedPackages := a.reorderAURPackages(packages)
|
orderedPackages := a.reorderAURPackages(packages)
|
||||||
|
|
||||||
baseProgress := 0.67
|
baseProgress := 0.67
|
||||||
@@ -699,7 +694,7 @@ func (a *ArchDistribution) reorderAURPackages(packages []string) []string {
|
|||||||
var dmsShell []string
|
var dmsShell []string
|
||||||
|
|
||||||
for _, pkg := range packages {
|
for _, pkg := range packages {
|
||||||
if pkg == "dms-shell-git" {
|
if pkg == "dux-shell-git" {
|
||||||
dmsShell = append(dmsShell, pkg)
|
dmsShell = append(dmsShell, pkg)
|
||||||
} else {
|
} else {
|
||||||
isDep := false
|
isDep := false
|
||||||
@@ -735,7 +730,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
|
|||||||
return fmt.Errorf("failed to get user home directory: %w", err)
|
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
buildDir := filepath.Join(homeDir, ".cache", "dankinstall", "aur-builds", pkg)
|
buildDir := filepath.Join(homeDir, ".cache", "duxinstall", "aur-builds", pkg)
|
||||||
|
|
||||||
// Clean up any existing cache first
|
// Clean up any existing cache first
|
||||||
if err := os.RemoveAll(buildDir); err != nil {
|
if err := os.RemoveAll(buildDir); err != nil {
|
||||||
@@ -781,17 +776,18 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if pkg == "dms-shell-git" {
|
if pkg == "dux-shell-git" {
|
||||||
srcinfoPath := filepath.Join(packageDir, ".SRCINFO")
|
srcinfoPath := filepath.Join(packageDir, ".SRCINFO")
|
||||||
depsToRemove := []string{
|
// extra ships dgop only as dux-gop (provides dgop), which makepkg resolves but pacman -Si does not
|
||||||
"depends = quickshell",
|
srcinfoEdits := []string{
|
||||||
"depends = dgop",
|
"/depends = quickshell/d",
|
||||||
|
`s/^\(\s*depends = \)dgop$/\1dux-gop/`,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, dep := range depsToRemove {
|
for _, edit := range srcinfoEdits {
|
||||||
sedCmd := exec.CommandContext(ctx, "sed", "-i", fmt.Sprintf("/%s/d", dep), srcinfoPath)
|
sedCmd := exec.CommandContext(ctx, "sed", "-i", edit, srcinfoPath)
|
||||||
if err := sedCmd.Run(); err != nil {
|
if err := sedCmd.Run(); err != nil {
|
||||||
return fmt.Errorf("failed to remove dependency %s from .SRCINFO for %s: %w", dep, pkg, err)
|
return fmt.Errorf("failed to patch .SRCINFO for %s: %w", pkg, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -659,7 +659,7 @@ func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword st
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get user home directory: %w", err)
|
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||||
}
|
}
|
||||||
tmpDir := filepath.Join(homeDir, ".cache", "dankinstall", "manual-builds")
|
tmpDir := filepath.Join(homeDir, ".cache", "duxinstall", "manual-builds")
|
||||||
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
|
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("failed to create temp directory: %w", err)
|
return fmt.Errorf("failed to create temp directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package distros
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -23,9 +24,6 @@ func init() {
|
|||||||
Register("fedora-asahi-remix", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
|
Register("fedora-asahi-remix", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
|
||||||
return NewFedoraDistribution(config, logChan)
|
return NewFedoraDistribution(config, logChan)
|
||||||
})
|
})
|
||||||
Register("bluefin", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
|
|
||||||
return NewFedoraDistribution(config, logChan)
|
|
||||||
})
|
|
||||||
Register("ultramarine", "#00078b", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
|
Register("ultramarine", "#00078b", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution {
|
||||||
return NewFedoraDistribution(config, logChan)
|
return NewFedoraDistribution(config, logChan)
|
||||||
})
|
})
|
||||||
@@ -54,6 +52,8 @@ type FedoraDistribution struct {
|
|||||||
*BaseDistribution
|
*BaseDistribution
|
||||||
*ManualPackageInstaller
|
*ManualPackageInstaller
|
||||||
config DistroConfig
|
config DistroConfig
|
||||||
|
|
||||||
|
rpmInstalled func(pkg string) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFedoraDistribution(config DistroConfig, logChan chan<- string) *FedoraDistribution {
|
func NewFedoraDistribution(config DistroConfig, logChan chan<- string) *FedoraDistribution {
|
||||||
@@ -138,6 +138,9 @@ func (f *FedoraDistribution) detectXDGPortal() deps.Dependency {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *FedoraDistribution) packageInstalled(pkg string) bool {
|
func (f *FedoraDistribution) packageInstalled(pkg string) bool {
|
||||||
|
if f.rpmInstalled != nil {
|
||||||
|
return f.rpmInstalled(pkg)
|
||||||
|
}
|
||||||
cmd := exec.Command("rpm", "-q", pkg)
|
cmd := exec.Command("rpm", "-q", pkg)
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
return err == nil
|
return err == nil
|
||||||
@@ -150,21 +153,33 @@ 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 {
|
if err != nil {
|
||||||
return false
|
return rpmLooksLikeGitBuild(pkg)
|
||||||
}
|
}
|
||||||
return strings.HasSuffix(strings.TrimSpace(string(out)), ":"+strings.ReplaceAll(coprRepo, "/", ":"))
|
return strings.HasSuffix(strings.TrimSpace(string(out)), ":"+strings.ReplaceAll(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 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
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 {
|
||||||
if err := f.enableCOPRRepos(ctx, swapTargets(swaps, RepoTypeCOPR), sudoPassword, progressChan); err != nil {
|
if err := f.enableCOPRRepos(ctx, swapTargets(swaps, RepoTypeCOPR), sudoPassword, progressChan); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -744,3 +759,76 @@ 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 coprRepoFilePath(repo string) string {
|
||||||
|
owner, project, _ := strings.Cut(repo, "/")
|
||||||
|
return fmt.Sprintf("/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:%s:%s.repo", owner, project)
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
coprRepoFilePath(repo), owner, project, chroot, owner, project, chroot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shown to the user, never run. Terra-only packages have no rpm-ostree path.
|
||||||
|
func (f *FedoraDistribution) LayerCommands(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags, disabledFlags map[string]bool) []string {
|
||||||
|
var commands []string
|
||||||
|
var uninstall []string
|
||||||
|
skip := maps.Clone(disabledFlags)
|
||||||
|
if skip == nil {
|
||||||
|
skip = map[string]bool{}
|
||||||
|
}
|
||||||
|
// rpm-ostree refuses to install what is already layered, so a reinstall mark only means a variant switch.
|
||||||
|
for _, dep := range dependencies {
|
||||||
|
if !reinstallFlags[dep.Name] || skip[dep.Name] || dep.Status != deps.StatusInstalled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
swap, status := PlanVariantSwap(f, wm, dep.Name, dep.Variant)
|
||||||
|
if status != SwapNeeded {
|
||||||
|
skip[dep.Name] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uninstall = append(uninstall, swap.From.Name)
|
||||||
|
if swap.From.Name == swap.To.Name && swap.From.Repository == RepoTypeCOPR {
|
||||||
|
commands = append(commands, "sudo rm -f "+coprRepoFilePath(swap.From.RepoURL))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 /etc/yum.repos.d/_copr:copr.fedorainfracloud.org:yalter:niri-git.repo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package distros
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/site"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Markers only reached when /run/ostree-booted is absent, such as a chroot.
|
||||||
|
var immutableIDs = map[string]bool{
|
||||||
|
"bluefin": true,
|
||||||
|
"bazzite": true,
|
||||||
|
"aurora": true,
|
||||||
|
"secureblue": true,
|
||||||
|
"zirconium": true,
|
||||||
|
"fedora-iot": true,
|
||||||
|
"fedora-coreos": true,
|
||||||
|
"opensuse-microos": true,
|
||||||
|
"opensuse-aeon": true,
|
||||||
|
"steamos": true,
|
||||||
|
"org.gnome.os": true,
|
||||||
|
"blendos": true,
|
||||||
|
"arkane": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var immutableVariantMarkers = []string{"silverblue", "kinoite", "sericea", "onyx", "atomic", "coreos", "iot"}
|
||||||
|
|
||||||
|
var immutableMarkerFiles = []struct {
|
||||||
|
path string
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{"/run/ostree-booted", "ostree-based system"},
|
||||||
|
{"/usr/sbin/transactional-update", "transactional-update system"},
|
||||||
|
{"/usr/bin/abroot", "ABRoot system"},
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZirconiumSwitchCommand = "sudo bootc switch ghcr.io/zirconium-dev/zirconium:latest"
|
||||||
|
|
||||||
|
// The commands are shown to the user, never run.
|
||||||
|
type PackageLayerer interface {
|
||||||
|
LayerCommands(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags, disabledFlags map[string]bool) []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func DetectImmutable() (bool, string) {
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
return detectImmutable(fs, readOSRelease(fs))
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectImmutable(fs afero.Fs, osRelease map[string]string) (bool, string) {
|
||||||
|
for _, marker := range immutableMarkerFiles {
|
||||||
|
if exists, _ := afero.Exists(fs, marker.path); exists {
|
||||||
|
return true, fmt.Sprintf("%s (%s)", marker.reason, marker.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strings.ToLower(osRelease["ID"])
|
||||||
|
if immutableIDs[id] {
|
||||||
|
return true, "os-release ID=" + id
|
||||||
|
}
|
||||||
|
|
||||||
|
variantID := strings.ToLower(osRelease["VARIANT_ID"])
|
||||||
|
for _, marker := range immutableVariantMarkers {
|
||||||
|
if strings.Contains(variantID, marker) {
|
||||||
|
return true, "os-release VARIANT_ID=" + variantID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, like := range strings.Fields(strings.ToLower(osRelease["ID_LIKE"])) {
|
||||||
|
if like == "microos" || like == "ostree" {
|
||||||
|
return true, "os-release ID_LIKE=" + like
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func ImmutableGuidance(info *OSInfo) string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "%s is an image-based system: %s.\n\n", info.PrettyName, info.ImmutableReason)
|
||||||
|
b.WriteString("duxinstall has no package mapping for this image, so it cannot tell what is installed or deploy configuration for it. Nothing was changed on this system.\n\n")
|
||||||
|
b.WriteString("See " + site.Docs + "/getting-started for other options.")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func PendingDependencies(dependencies []deps.Dependency, reinstallFlags, disabledFlags map[string]bool) []deps.Dependency {
|
||||||
|
var pending []deps.Dependency
|
||||||
|
for _, dep := range dependencies {
|
||||||
|
if disabledFlags[dep.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pending = append(pending, dep)
|
||||||
|
}
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectDMS keys off the clone directory; packaged hosts only have /usr/bin/dms.
|
||||||
|
func ApplyImmutableDetection(dependencies []deps.Dependency) []deps.Dependency {
|
||||||
|
if _, err := exec.LookPath("dms"); err != nil {
|
||||||
|
return dependencies
|
||||||
|
}
|
||||||
|
for i := range dependencies {
|
||||||
|
if dependencies[i].Name != "dms (Dux Shell)" || dependencies[i].Status != deps.StatusMissing {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dependencies[i].Status = deps.StatusInstalled
|
||||||
|
// "dms (git) v2.0.0-5333" or "dms v2.0.0"
|
||||||
|
out, err := exec.Command("dms", "version").Output()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(string(out))
|
||||||
|
if len(fields) > 0 {
|
||||||
|
dependencies[i].Version = fields[len(fields)-1]
|
||||||
|
}
|
||||||
|
if strings.Contains(string(out), "(git)") {
|
||||||
|
dependencies[i].Variant = deps.VariantGit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dependencies
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package distros
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeOSRelease(t *testing.T, fs afero.Fs, body string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := afero.WriteFile(fs, "/etc/os-release", []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectImmutable(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
files []string
|
||||||
|
osRelease map[string]string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"ostree marker beats plain fedora id", []string{"/run/ostree-booted"}, map[string]string{"ID": "fedora"}, true},
|
||||||
|
{"silverblue variant without marker", nil, map[string]string{"ID": "fedora", "VARIANT_ID": "silverblue"}, true},
|
||||||
|
{"sway atomic variant", nil, map[string]string{"ID": "fedora", "VARIANT_ID": "sway-atomic"}, true},
|
||||||
|
{"workstation fedora", nil, map[string]string{"ID": "fedora", "VARIANT_ID": "workstation"}, false},
|
||||||
|
{"bazzite id", nil, map[string]string{"ID": "bazzite", "ID_LIKE": "fedora"}, true},
|
||||||
|
{"microos", []string{"/usr/sbin/transactional-update"}, map[string]string{"ID": "opensuse-microos"}, true},
|
||||||
|
{"aeon by id_like", nil, map[string]string{"ID": "opensuse-aeon-rc", "ID_LIKE": "suse opensuse microos"}, true},
|
||||||
|
{"tumbleweed", nil, map[string]string{"ID": "opensuse-tumbleweed", "ID_LIKE": "suse opensuse"}, false},
|
||||||
|
{"vanilla by abroot", []string{"/usr/bin/abroot"}, map[string]string{"ID": "ubuntu", "ID_LIKE": "debian"}, true},
|
||||||
|
{"steamos", nil, map[string]string{"ID": "steamos", "ID_LIKE": "arch"}, true},
|
||||||
|
{"arch", nil, map[string]string{"ID": "arch"}, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
for _, f := range c.files {
|
||||||
|
if err := afero.WriteFile(fs, f, nil, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, reason := detectImmutable(fs, c.osRelease)
|
||||||
|
if got != c.want {
|
||||||
|
t.Fatalf("detectImmutable = %v (%q), want %v", got, reason, c.want)
|
||||||
|
}
|
||||||
|
if got && reason == "" {
|
||||||
|
t.Fatal("immutable without a reason")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOSInfoImmutable(t *testing.T) {
|
||||||
|
t.Run("unregistered image id is accepted when immutable", func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
writeOSRelease(t, fs, "ID=\"zirconium\"\nID_LIKE=\"zirconium\"\nPRETTY_NAME=\"Zirconium\"\nVERSION_ID=44\n")
|
||||||
|
if err := afero.WriteFile(fs, "/run/ostree-booted", nil, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info, err := getOSInfo(fs, "amd64")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !info.Immutable || info.Distribution.ID != "zirconium" || info.VersionID != "44" {
|
||||||
|
t.Fatalf("got %+v", info)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fedora derivative resolves through id_like", func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
writeOSRelease(t, fs, "ID=bazzite\nID_LIKE=fedora\nPRETTY_NAME=Bazzite\nVERSION_ID=44\n")
|
||||||
|
info, err := getOSInfo(fs, "amd64")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !info.Immutable || info.ID != "bazzite" || info.Distribution.ID != "fedora" {
|
||||||
|
t.Fatalf("got %+v", info)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unregistered mutable id still errors", func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
writeOSRelease(t, fs, "ID=slackware\nPRETTY_NAME=Slackware\n")
|
||||||
|
if _, err := getOSInfo(fs, "amd64"); err == nil {
|
||||||
|
t.Fatal("expected unsupported distribution error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("silverblue resolves to fedora but is flagged", func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
writeOSRelease(t, fs, "ID=fedora\nVARIANT_ID=silverblue\nPRETTY_NAME=\"Fedora Linux 44 (Silverblue)\"\nVERSION_ID=44\n")
|
||||||
|
info, err := getOSInfo(fs, "amd64")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !info.Immutable || info.Distribution.ID != "fedora" || info.VariantID != "silverblue" {
|
||||||
|
t.Fatalf("got %+v", info)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bluefin is no longer a mutable fedora", func(t *testing.T) {
|
||||||
|
if IsDistroSupported("bluefin") {
|
||||||
|
t.Fatal("bluefin must not be registered as an installable distribution")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingDependencies(t *testing.T) {
|
||||||
|
all := []deps.Dependency{
|
||||||
|
{Name: "dms (Dux Shell)", Status: deps.StatusMissing},
|
||||||
|
{Name: "niri", Status: deps.StatusInstalled},
|
||||||
|
{Name: "quickshell", Status: deps.StatusInstalled},
|
||||||
|
{Name: "danksearch", Status: deps.StatusMissing},
|
||||||
|
}
|
||||||
|
got := PendingDependencies(all, map[string]bool{"quickshell": true}, map[string]bool{"danksearch": true})
|
||||||
|
if len(got) != 2 || got[0].Name != "dms (Dux Shell)" || got[1].Name != "quickshell" {
|
||||||
|
t.Fatalf("got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyImmutableDetection(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "dms"), []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", dir)
|
||||||
|
got := ApplyImmutableDetection([]deps.Dependency{{Name: "dms (Dux Shell)", Status: deps.StatusMissing}, {Name: "niri", Status: deps.StatusMissing}})
|
||||||
|
if got[0].Status != deps.StatusInstalled || got[1].Status != deps.StatusMissing {
|
||||||
|
t.Fatalf("got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFedoraLayerCommands(t *testing.T) {
|
||||||
|
f := NewFedoraDistribution(Registry["fedora"], nil)
|
||||||
|
dependencies := []deps.Dependency{
|
||||||
|
{Name: "dms (Dux Shell)", Status: deps.StatusMissing},
|
||||||
|
{Name: "niri", Status: deps.StatusMissing, Variant: deps.VariantGit},
|
||||||
|
{Name: "ghostty", Status: deps.StatusInstalled},
|
||||||
|
{Name: "matugen", Status: deps.StatusMissing},
|
||||||
|
{Name: "danksearch", Status: deps.StatusMissing},
|
||||||
|
{Name: "xwayland-satellite", Status: deps.StatusMissing},
|
||||||
|
}
|
||||||
|
got := strings.Join(f.LayerCommands(dependencies, deps.WindowManagerNiri, nil, map[string]bool{"danksearch": true}), "\n")
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"_copr:copr.fedorainfracloud.org:avengemedia:dms.repo https://copr.fedorainfracloud.org/coprs/avengemedia/dms/repo/fedora-$(rpm -E %fedora)/avengemedia-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, "avengemedia:danklinux.repo https") != 1 {
|
||||||
|
t.Fatalf("danklinux repo must be fetched exactly once:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImmutableGuidance(t *testing.T) {
|
||||||
|
steam := &OSInfo{PrettyName: "SteamOS", Distribution: DistroInfo{ID: "steamos"}, ImmutableReason: "os-release ID=steamos"}
|
||||||
|
got := ImmutableGuidance(steam)
|
||||||
|
if !strings.Contains(got, "SteamOS is an image-based system") || !strings.Contains(got, "Nothing was changed") {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFedoraLayerCommandsSwapVariants(t *testing.T) {
|
||||||
|
f := NewFedoraDistribution(Registry["fedora"], nil)
|
||||||
|
installed := map[string]bool{"dms-greeter": true, "dms": true}
|
||||||
|
f.rpmInstalled = func(pkg string) bool { return installed[pkg] }
|
||||||
|
orig := installedFromCOPR
|
||||||
|
installedFromCOPR = func(pkg, repo string) bool { return pkg == "dms" && repo == "avengemedia/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:avengemedia:dms-git.repo",
|
||||||
|
"sudo rpm-ostree install dms dms-greeter-git --uninstall dms dms-greeter",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("missing %q in:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "danksearch") {
|
||||||
|
t.Fatalf("reinstall mark without a variant change must not be layered:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyImmutableDetectionReadsVariant(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "dms"), []byte("#!/bin/sh\necho 'dms (git) v2.0.0-5333'\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", dir)
|
||||||
|
got := ApplyImmutableDetection([]deps.Dependency{{Name: "dms (Dux Shell)", Status: deps.StatusMissing}})
|
||||||
|
if got[0].Variant != deps.VariantGit || got[0].Version != "v2.0.0-5333" {
|
||||||
|
t.Fatalf("got %+v", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,8 +95,8 @@ func (m *ManualPackageInstaller) installNiri(ctx context.Context, sudoPassword s
|
|||||||
m.log("Installing niri from source...")
|
m.log("Installing niri from source...")
|
||||||
|
|
||||||
homeDir, _ := os.UserHomeDir()
|
homeDir, _ := os.UserHomeDir()
|
||||||
buildDir := filepath.Join(homeDir, ".cache", "dankinstall", "niri-build")
|
buildDir := filepath.Join(homeDir, ".cache", "duxinstall", "niri-build")
|
||||||
tmpDir := filepath.Join(homeDir, ".cache", "dankinstall", "tmp")
|
tmpDir := filepath.Join(homeDir, ".cache", "duxinstall", "tmp")
|
||||||
if err := os.MkdirAll(buildDir, 0o755); err != nil {
|
if err := os.MkdirAll(buildDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("failed to create build directory: %w", err)
|
return fmt.Errorf("failed to create build directory: %w", err)
|
||||||
}
|
}
|
||||||
@@ -173,7 +173,7 @@ func (m *ManualPackageInstaller) installQuickshell(ctx context.Context, variant
|
|||||||
return fmt.Errorf("HOME environment variable not set")
|
return fmt.Errorf("HOME environment variable not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
|
cacheDir := filepath.Join(homeDir, ".cache", "duxinstall")
|
||||||
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||||
}
|
}
|
||||||
@@ -279,7 +279,7 @@ func (m *ManualPackageInstaller) installHyprland(ctx context.Context, sudoPasswo
|
|||||||
return fmt.Errorf("HOME environment variable not set")
|
return fmt.Errorf("HOME environment variable not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
|
cacheDir := filepath.Join(homeDir, ".cache", "duxinstall")
|
||||||
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||||
}
|
}
|
||||||
@@ -342,7 +342,7 @@ func (m *ManualPackageInstaller) installGhostty(ctx context.Context, sudoPasswor
|
|||||||
return fmt.Errorf("HOME environment variable not set")
|
return fmt.Errorf("HOME environment variable not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
|
cacheDir := filepath.Join(homeDir, ".cache", "duxinstall")
|
||||||
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -703,7 +703,7 @@ func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant de
|
|||||||
return fmt.Errorf("HOME environment variable not set")
|
return fmt.Errorf("HOME environment variable not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheDir := filepath.Join(homeDir, ".cache", "dankinstall")
|
cacheDir := filepath.Join(homeDir, ".cache", "duxinstall")
|
||||||
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
||||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/errdefs"
|
"github.com/duxlabs-dev/dux-shell/core/internal/errdefs"
|
||||||
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DistroInfo contains basic information about a distribution
|
// DistroInfo contains basic information about a distribution
|
||||||
@@ -19,12 +20,18 @@ type DistroInfo struct {
|
|||||||
|
|
||||||
// OSInfo contains complete OS information
|
// OSInfo contains complete OS information
|
||||||
type OSInfo struct {
|
type OSInfo struct {
|
||||||
|
// Raw os-release ID; Distribution.ID is the registered distro it resolves to via ID_LIKE (bazzite -> fedora).
|
||||||
|
ID string
|
||||||
Distribution DistroInfo
|
Distribution DistroInfo
|
||||||
Version string
|
Version string
|
||||||
VersionID string
|
VersionID string
|
||||||
VersionCodename string
|
VersionCodename string
|
||||||
PrettyName string
|
PrettyName string
|
||||||
Architecture string
|
Architecture string
|
||||||
|
IDLike []string
|
||||||
|
VariantID string
|
||||||
|
Immutable bool
|
||||||
|
ImmutableReason string
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOSInfo detects the current OS and returns information about it
|
// GetOSInfo detects the current OS and returns information about it
|
||||||
@@ -37,50 +44,74 @@ func GetOSInfo() (*OSInfo, error) {
|
|||||||
return nil, errdefs.NewCustomError(errdefs.ErrTypeInvalidArchitecture, fmt.Sprintf("Only amd64 and arm64 are supported, but I found %s", runtime.GOARCH))
|
return nil, errdefs.NewCustomError(errdefs.ErrTypeInvalidArchitecture, fmt.Sprintf("Only amd64 and arm64 are supported, but I found %s", runtime.GOARCH))
|
||||||
}
|
}
|
||||||
|
|
||||||
info := &OSInfo{
|
return getOSInfo(afero.NewOsFs(), runtime.GOARCH)
|
||||||
Architecture: runtime.GOARCH,
|
}
|
||||||
|
|
||||||
|
// An unregistered ID is only an error on a mutable system; immutable hosts
|
||||||
|
// stop at the guidance screen and need no Distribution implementation.
|
||||||
|
func getOSInfo(fs afero.Fs, arch string) (*OSInfo, error) {
|
||||||
|
values := readOSRelease(fs)
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil, fmt.Errorf("cannot read /etc/os-release")
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := os.Open("/etc/os-release")
|
info := &OSInfo{
|
||||||
|
Architecture: arch,
|
||||||
|
Version: values["VERSION"],
|
||||||
|
VersionID: values["VERSION_ID"],
|
||||||
|
VersionCodename: values["VERSION_CODENAME"],
|
||||||
|
PrettyName: values["PRETTY_NAME"],
|
||||||
|
IDLike: strings.Fields(values["ID_LIKE"]),
|
||||||
|
VariantID: values["VARIANT_ID"],
|
||||||
|
}
|
||||||
|
if info.VersionID == "" {
|
||||||
|
info.VersionID = values["BUILD_ID"]
|
||||||
|
}
|
||||||
|
info.Immutable, info.ImmutableReason = detectImmutable(fs, values)
|
||||||
|
|
||||||
|
info.ID = values["ID"]
|
||||||
|
id := info.ID
|
||||||
|
config, exists := Registry[id]
|
||||||
|
if !exists && !info.Immutable {
|
||||||
|
return nil, errdefs.NewCustomError(errdefs.ErrTypeUnsupportedDistribution, fmt.Sprintf("Unsupported distribution: %s", id))
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
for _, like := range info.IDLike {
|
||||||
|
if likeConfig, ok := Registry[like]; ok {
|
||||||
|
id, config = like, likeConfig
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info.Distribution = DistroInfo{ID: id, HexColorCode: config.ColorHex}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readOSRelease(fs afero.Fs) map[string]string {
|
||||||
|
values := make(map[string]string)
|
||||||
|
|
||||||
|
file, err := fs.Open("/etc/os-release")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return values
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
scanner := bufio.NewScanner(file)
|
scanner := bufio.NewScanner(file)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
parts := strings.SplitN(scanner.Text(), "=", 2)
|
||||||
parts := strings.SplitN(line, "=", 2)
|
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
values[strings.TrimSpace(parts[0])] = strings.Trim(strings.TrimSpace(parts[1]), "\"'")
|
||||||
key := parts[0]
|
|
||||||
value := strings.Trim(parts[1], "\"'")
|
|
||||||
|
|
||||||
switch key {
|
|
||||||
case "ID":
|
|
||||||
config, exists := Registry[value]
|
|
||||||
if !exists {
|
|
||||||
return nil, errdefs.NewCustomError(errdefs.ErrTypeUnsupportedDistribution, fmt.Sprintf("Unsupported distribution: %s", value))
|
|
||||||
}
|
|
||||||
|
|
||||||
info.Distribution = DistroInfo{
|
|
||||||
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()
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -149,10 +155,10 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
|||||||
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)
|
||||||
@@ -265,6 +271,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 +424,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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 +104,18 @@ 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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -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 {
|
||||||
if err != nil {
|
sudoPassword, err = r.installPackages(distro, dependencies, wm, reinstallItems, disabledItems)
|
||||||
return err
|
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,
|
|
||||||
)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Consume progress messages and print them
|
|
||||||
for msg := range progressChan {
|
|
||||||
if msg.Error != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error)
|
|
||||||
} else if msg.Step != "" {
|
|
||||||
fmt.Fprintf(os.Stdout, " [%3.0f%%] %s\n", msg.Progress*100, msg.Step)
|
|
||||||
}
|
|
||||||
if msg.LogOutput != "" {
|
|
||||||
r.log(msg.LogOutput)
|
|
||||||
fmt.Fprintf(os.Stdout, " %s\n", msg.LogOutput)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := <-installErr; err != nil {
|
useSystemd := distros.HasSystemd()
|
||||||
return fmt.Errorf("package installation failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
useSystemd := true
|
// 9. Greeter setup. On images dms-greeter escalates itself, so it is left to the user.
|
||||||
if distroConfig, exists := distros.Registry[osInfo.Distribution.ID]; exists && distroConfig.Family == distros.FamilyVoid {
|
if osInfo.Immutable && !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
|
||||||
useSystemd = false
|
fmt.Fprintln(os.Stdout, "Greeter: run 'dms-greeter install --yes' to configure greetd.")
|
||||||
}
|
}
|
||||||
|
if !osInfo.Immutable && !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
|
||||||
// 9. Greeter setup (if dms-greeter was included)
|
|
||||||
if !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") {
|
|
||||||
fmt.Fprintln(os.Stdout, "Configuring DMS greeter...")
|
fmt.Fprintln(os.Stdout, "Configuring DMS greeter...")
|
||||||
logFunc := func(line string) {
|
logFunc := func(line string) {
|
||||||
r.log(line)
|
r.log(line)
|
||||||
@@ -531,14 +518,14 @@ func (r *Runner) resolveSudoPassword() (string, error) {
|
|||||||
return "", fmt.Errorf(
|
return "", fmt.Errorf(
|
||||||
"sudo authentication required but no cached credentials found\n" +
|
"sudo authentication required but no cached credentials found\n" +
|
||||||
"Options:\n" +
|
"Options:\n" +
|
||||||
" 1. Run 'sudo -v' before dankinstall to cache credentials\n" +
|
" 1. Run 'sudo -v' before duxinstall to cache credentials\n" +
|
||||||
" 2. Configure passwordless sudo for your user",
|
" 2. Configure passwordless sudo for your user",
|
||||||
)
|
)
|
||||||
case privesc.ToolDoas:
|
case privesc.ToolDoas:
|
||||||
return "", fmt.Errorf(
|
return "", fmt.Errorf(
|
||||||
"doas authentication required but no cached credentials found\n" +
|
"doas authentication required but no cached credentials found\n" +
|
||||||
"Options:\n" +
|
"Options:\n" +
|
||||||
" 1. Run 'doas true' before dankinstall to cache credentials (requires 'persist' in /etc/doas.conf)\n" +
|
" 1. Run 'doas true' before duxinstall to cache credentials (requires 'persist' in /etc/doas.conf)\n" +
|
||||||
" 2. Configure a 'nopass' rule in /etc/doas.conf for your user",
|
" 2. Configure a 'nopass' rule in /etc/doas.conf for your user",
|
||||||
)
|
)
|
||||||
case privesc.ToolRun0:
|
case privesc.ToolRun0:
|
||||||
@@ -569,3 +556,69 @@ func (r *Runner) depExists(dependencies []deps.Dependency, name string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Runner) requireLayeredPackages(distro distros.Distribution, dependencies []deps.Dependency, wm deps.WindowManager, reinstallItems, disabledItems map[string]bool) error {
|
||||||
|
pending := distros.PendingDependencies(dependencies, reinstallItems, disabledItems)
|
||||||
|
if len(pending) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
layerer, ok := distro.(distros.PackageLayerer)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("this image has no known way to add packages; install them with the image's own tooling, reboot, and run duxinstall again")
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stdout, "Packages must be added to the image first. Run these, reboot, then run duxinstall again:")
|
||||||
|
fmt.Fprintln(os.Stdout)
|
||||||
|
for _, cmd := range layerer.LayerCommands(dependencies, wm, reinstallItems, disabledItems) {
|
||||||
|
fmt.Fprintf(os.Stdout, " %s\n", cmd)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stdout)
|
||||||
|
r.log("Aborted: packages not layered on image-based host")
|
||||||
|
return ErrPackagesNotLayered
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) installPackages(distro distros.Distribution, dependencies []deps.Dependency, wm deps.WindowManager, reinstallItems, disabledItems map[string]bool) (string, error) {
|
||||||
|
// 7. Authenticate sudo
|
||||||
|
sudoPassword, err := r.resolveSudoPassword()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Install packages
|
||||||
|
fmt.Fprintln(os.Stdout, "Installing packages...")
|
||||||
|
r.log("Starting package installation")
|
||||||
|
|
||||||
|
progressChan := make(chan distros.InstallProgressMsg, 100)
|
||||||
|
|
||||||
|
installErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
defer close(progressChan)
|
||||||
|
installErr <- distro.InstallPackages(
|
||||||
|
context.Background(),
|
||||||
|
dependencies,
|
||||||
|
wm,
|
||||||
|
sudoPassword,
|
||||||
|
reinstallItems,
|
||||||
|
disabledItems,
|
||||||
|
false, // skipGlobalUseFlags
|
||||||
|
progressChan,
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
for msg := range progressChan {
|
||||||
|
if msg.Error != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error)
|
||||||
|
} else if msg.Step != "" {
|
||||||
|
fmt.Fprintf(os.Stdout, " [%3.0f%%] %s\n", msg.Progress*100, msg.Step)
|
||||||
|
}
|
||||||
|
if msg.LogOutput != "" {
|
||||||
|
r.log(msg.LogOutput)
|
||||||
|
fmt.Fprintf(os.Stdout, " %s\n", msg.LogOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := <-installErr; err != nil {
|
||||||
|
return "", fmt.Errorf("package installation failed: %w", err)
|
||||||
|
}
|
||||||
|
return sudoPassword, nil
|
||||||
|
}
|
||||||
@@ -23,15 +23,18 @@ type FileLogger struct {
|
|||||||
func NewFileLogger() (*FileLogger, error) {
|
func NewFileLogger() (*FileLogger, error) {
|
||||||
timestamp := time.Now().Unix()
|
timestamp := time.Now().Unix()
|
||||||
|
|
||||||
// Use DANKINSTALL_LOG_DIR if set, otherwise fall back to /tmp.
|
logDir := os.Getenv("DUXINSTALL_LOG_DIR")
|
||||||
logDir := os.Getenv("DANKINSTALL_LOG_DIR")
|
if logDir == "" {
|
||||||
|
// Pre-rename name, still set in existing scripts.
|
||||||
|
logDir = os.Getenv("DANKINSTALL_LOG_DIR")
|
||||||
|
}
|
||||||
if logDir == "" {
|
if logDir == "" {
|
||||||
logDir = "/tmp"
|
logDir = "/tmp"
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create log directory: %w", err)
|
return nil, fmt.Errorf("failed to create log directory: %w", err)
|
||||||
}
|
}
|
||||||
logPath := filepath.Join(logDir, fmt.Sprintf("dankinstall-%d.log", timestamp))
|
logPath := filepath.Join(logDir, fmt.Sprintf("duxinstall-%d.log", timestamp))
|
||||||
|
|
||||||
file, err := os.Create(logPath)
|
file, err := os.Create(logPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -49,7 +52,7 @@ func NewFileLogger() (*FileLogger, error) {
|
|||||||
passwordRe: passwordRe,
|
passwordRe: passwordRe,
|
||||||
}
|
}
|
||||||
|
|
||||||
header := fmt.Sprintf("=== DankInstall Log ===\nStarted: %s\n\n", time.Now().Format(time.RFC3339))
|
header := fmt.Sprintf("=== DuxInstall Log ===\nStarted: %s\n\n", time.Now().Format(time.RFC3339))
|
||||||
logger.writeToFile(header)
|
logger.writeToFile(header)
|
||||||
|
|
||||||
return logger, nil
|
return logger, nil
|
||||||
@@ -102,7 +105,7 @@ func (l *FileLogger) Close() error {
|
|||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
footer := fmt.Sprintf("\n=== DankInstall Log End ===\nCompleted: %s\n", time.Now().Format(time.RFC3339))
|
footer := fmt.Sprintf("\n=== DuxInstall Log End ===\nCompleted: %s\n", time.Now().Format(time.RFC3339))
|
||||||
l.writer.WriteString(footer) //nolint:errcheck
|
l.writer.WriteString(footer) //nolint:errcheck
|
||||||
l.writer.Flush()
|
l.writer.Flush()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Package mangoconf holds what DMS knows about Mango's config files: where they
|
||||||
|
// live, the session target, and the markers DMS leaves in the binds fragment.
|
||||||
|
package mangoconf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mango reads each line into a 512-byte buffer and the value into 255 bytes; longer is cut silently.
|
||||||
|
const maxValueLen = 255
|
||||||
|
|
||||||
|
func CheckLine(line string) error {
|
||||||
|
_, value, ok := strings.Cut(line, "=")
|
||||||
|
if !ok || len(strings.TrimSpace(value)) <= maxValueLen {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("line is %d characters after '='; Mango truncates past %d", len(strings.TrimSpace(value)), maxValueLen)
|
||||||
|
}
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
// Package mangoconf maps Mango config keys between the run-together names of
|
|
||||||
// Mango <= 0.17.5 and the snake_case names of upstream 8adc6bc3 (mangowm/mango#1491).
|
|
||||||
// Neither parser accepts the other's names.
|
|
||||||
package mangoconf
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Dialect int
|
|
||||||
|
|
||||||
const (
|
|
||||||
Legacy Dialect = iota
|
|
||||||
Snake
|
|
||||||
)
|
|
||||||
|
|
||||||
// Upstream migration order: where two legacy names share a snake name, the
|
|
||||||
// window-rule spelling comes first and wins the reverse lookup.
|
|
||||||
var renames = [][2]string{
|
|
||||||
{"windowrule-once", "window_rule_once"},
|
|
||||||
{"windowrule", "window_rule"},
|
|
||||||
{"monitorrule", "monitor_rule"},
|
|
||||||
{"tagrule", "tag_rule"},
|
|
||||||
{"layerrule", "layer_rule"},
|
|
||||||
{"devicerule", "device_rule"},
|
|
||||||
{"source-optional", "source_optional"},
|
|
||||||
{"exec-once", "exec_once"},
|
|
||||||
{"keymode", "key_mode"},
|
|
||||||
{"isnoborder", "no_border"},
|
|
||||||
{"isnoshadow", "no_shadow"},
|
|
||||||
{"isnoradius", "no_radius"},
|
|
||||||
{"isnoanimation", "no_animation"},
|
|
||||||
{"isnosizehint", "no_size_hint"},
|
|
||||||
{"noblur", "no_blur"},
|
|
||||||
{"noshadow", "no_shadow"},
|
|
||||||
{"noanim", "no_animation"},
|
|
||||||
{"nofocus", "no_focus"},
|
|
||||||
{"nofadein", "no_fade_in"},
|
|
||||||
{"nofadeout", "no_fade_out"},
|
|
||||||
{"noswallow", "no_swallow"},
|
|
||||||
{"special_gappih", "special_gap_inner_horizontal"},
|
|
||||||
{"special_gappiv", "special_gap_inner_vertical"},
|
|
||||||
{"special_gappoh", "special_gap_outer_horizontal"},
|
|
||||||
{"special_gappov", "special_gap_outer_vertical"},
|
|
||||||
{"gappih", "gap_inner_horizontal"},
|
|
||||||
{"gappiv", "gap_inner_vertical"},
|
|
||||||
{"gappoh", "gap_outer_horizontal"},
|
|
||||||
{"gappov", "gap_outer_vertical"},
|
|
||||||
{"overviewgappi", "overview_gap_inner"},
|
|
||||||
{"overviewgappo", "overview_gap_outer"},
|
|
||||||
{"default_mfact", "default_master_factor"},
|
|
||||||
{"default_nmaster", "default_master_count"},
|
|
||||||
{"animation_curve_opafadein", "animation_curve_opacity_fade_in"},
|
|
||||||
{"animation_curve_opafadeout", "animation_curve_opacity_fade_out"},
|
|
||||||
{"dwindle_hsplit", "dwindle_horizontal_split"},
|
|
||||||
{"dwindle_vsplit", "dwindle_vertical_split"},
|
|
||||||
{"fadein_begin_opacity", "fade_in_begin_opacity"},
|
|
||||||
{"fadeout_begin_opacity", "fade_out_begin_opacity"},
|
|
||||||
{"focusdir_only_zone_overlap", "focus_direction_only_zone_overlap"},
|
|
||||||
{"force_fakemaximize", "force_fake_maximize"},
|
|
||||||
{"idleinhibit_ignore_visible", "idle_inhibit_ignore_visible"},
|
|
||||||
{"idleinhibit_when_focus", "idle_inhibit_when_focus"},
|
|
||||||
{"idleinhibit_when_fullscreen", "idle_inhibit_when_fullscreen"},
|
|
||||||
{"syncobj_enable", "sync_obj_enable"},
|
|
||||||
{"isfakefullscreen", "is_fake_fullscreen"},
|
|
||||||
{"isfloating", "is_floating"},
|
|
||||||
{"isfullscreen", "is_fullscreen"},
|
|
||||||
{"isunglobal", "is_unmanaged_global"},
|
|
||||||
{"isglobal", "is_global"},
|
|
||||||
{"isnamedscratchpad", "is_named_scratchpad"},
|
|
||||||
{"isopensilent", "is_open_silent"},
|
|
||||||
{"isoverlay", "is_overlay"},
|
|
||||||
{"istagsilent", "is_tag_silent"},
|
|
||||||
{"isterm", "is_term"},
|
|
||||||
{"appid", "app_id"},
|
|
||||||
{"bordercolor", "border_color"},
|
|
||||||
{"borderpx", "border_px"},
|
|
||||||
{"dropcolor", "drop_color"},
|
|
||||||
{"focuscolor", "focus_color"},
|
|
||||||
{"globalcolor", "global_color"},
|
|
||||||
{"globalkeybinding", "global_key_binding"},
|
|
||||||
{"maximizescreencolor", "maximized_screen_color"},
|
|
||||||
{"mfact", "master_factor"},
|
|
||||||
{"nmaster", "master_count"},
|
|
||||||
{"numlockon", "numlock_on"},
|
|
||||||
{"offsetx", "offset_x"},
|
|
||||||
{"offsety", "offset_y"},
|
|
||||||
{"overlaycolor", "overlay_color"},
|
|
||||||
{"rootcolor", "root_color"},
|
|
||||||
{"scratchpadcolor", "scratchpad_color"},
|
|
||||||
{"shadowscolor", "shadows_color"},
|
|
||||||
{"sloppyfocus", "sloppy_focus"},
|
|
||||||
{"smartgaps", "smart_gaps"},
|
|
||||||
{"splitcolor", "split_color"},
|
|
||||||
{"urgentcolor", "urgent_color"},
|
|
||||||
{"warpcursor", "warp_cursor"},
|
|
||||||
}
|
|
||||||
|
|
||||||
var toSnake, toLegacy = func() (map[string]string, map[string]string) {
|
|
||||||
fwd := make(map[string]string, len(renames))
|
|
||||||
rev := make(map[string]string, len(renames))
|
|
||||||
for _, r := range renames {
|
|
||||||
fwd[r[0]] = r[1]
|
|
||||||
if _, ok := rev[r[1]]; !ok {
|
|
||||||
rev[r[1]] = r[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fwd, rev
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Normalize returns the snake_case name for a key in either dialect.
|
|
||||||
func Normalize(key string) string {
|
|
||||||
if s, ok := toSnake[key]; ok {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
|
|
||||||
// Key spells a snake_case key in this dialect.
|
|
||||||
func (d Dialect) Key(snake string) string {
|
|
||||||
if d == Snake {
|
|
||||||
return snake
|
|
||||||
}
|
|
||||||
if l, ok := toLegacy[snake]; ok {
|
|
||||||
return l
|
|
||||||
}
|
|
||||||
return snake
|
|
||||||
}
|
|
||||||
|
|
||||||
var keyLine = regexp.MustCompile(`^(\s*)([A-Za-z][A-Za-z_-]*)(\s*=\s*)(.*)$`)
|
|
||||||
|
|
||||||
// Translate respells every line key, and the field keys of rule lines, in this dialect.
|
|
||||||
func (d Dialect) Translate(text string) string {
|
|
||||||
lines := strings.Split(text, "\n")
|
|
||||||
for i, line := range lines {
|
|
||||||
m := keyLine.FindStringSubmatch(line)
|
|
||||||
if m == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := Normalize(m[2])
|
|
||||||
value := m[4]
|
|
||||||
switch key {
|
|
||||||
case "window_rule", "window_rule_once", "tag_rule":
|
|
||||||
value = d.translateFields(value, nil)
|
|
||||||
case "layer_rule":
|
|
||||||
value = d.translateFields(value, legacyLayerFields)
|
|
||||||
}
|
|
||||||
lines[i] = m[1] + d.Key(key) + m[3] + value
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layer rules spell these without the window rules' "is" prefix.
|
|
||||||
var legacyLayerFields = map[string]string{"no_shadow": "noshadow", "no_animation": "noanim"}
|
|
||||||
|
|
||||||
func (d Dialect) translateFields(value string, legacyOverride map[string]string) string {
|
|
||||||
parts := strings.Split(value, ",")
|
|
||||||
for i, part := range parts {
|
|
||||||
k, v, ok := strings.Cut(part, ":")
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
trimmed := strings.TrimSpace(k)
|
|
||||||
key := d.Key(Normalize(trimmed))
|
|
||||||
if l, ok := legacyOverride[Normalize(trimmed)]; ok && d == Legacy {
|
|
||||||
key = l
|
|
||||||
}
|
|
||||||
parts[i] = strings.Replace(k, trimmed, key, 1) + ":" + v
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ",")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy builds carry exec_once as a symbol name, so it cannot tell the dialects apart.
|
|
||||||
var snakeMarker = []byte("gap_inner_horizontal")
|
|
||||||
|
|
||||||
var (
|
|
||||||
detectOnce sync.Once
|
|
||||||
detected Dialect
|
|
||||||
)
|
|
||||||
|
|
||||||
// Detect reads the mango binary: snake_case shipped on master builds still reporting 0.17.5, before the 0.18.0 release.
|
|
||||||
func Detect() Dialect {
|
|
||||||
detectOnce.Do(func() {
|
|
||||||
path, err := exec.LookPath("mango")
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
detected = DetectBinary(path)
|
|
||||||
})
|
|
||||||
return detected
|
|
||||||
}
|
|
||||||
|
|
||||||
func DetectBinary(path string) Dialect {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil || !bytes.Contains(data, snakeMarker) {
|
|
||||||
return Legacy
|
|
||||||
}
|
|
||||||
return Snake
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mango reads each line into a 512-byte buffer and the value into 255 bytes; longer is cut silently.
|
|
||||||
const maxValueLen = 255
|
|
||||||
|
|
||||||
func CheckLine(line string) error {
|
|
||||||
_, value, ok := strings.Cut(line, "=")
|
|
||||||
if !ok || len(strings.TrimSpace(value)) <= maxValueLen {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("line is %d characters after '='; Mango truncates past %d", len(strings.TrimSpace(value)), maxValueLen)
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
package mangoconf
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestTranslateRespellsKeysBothWays(t *testing.T) {
|
|
||||||
legacy := "exec-once=dms run\nsource=./dms/colors.conf\ngappih = 5\nbordercolor = 0x595959ff\nwindowrule=appid:foo,isfloating:1,isnoanimation:1\nlayerrule=noanim:1,noshadow:1,layer_name:rofi\n# exec-once=kept\n"
|
|
||||||
snake := "exec_once=dms run\nsource=./dms/colors.conf\ngap_inner_horizontal = 5\nborder_color = 0x595959ff\nwindow_rule=app_id:foo,is_floating:1,no_animation:1\nlayer_rule=no_animation:1,no_shadow:1,layer_name:rofi\n# exec-once=kept\n"
|
|
||||||
assert.Equal(t, snake, Snake.Translate(legacy))
|
|
||||||
assert.Equal(t, legacy, Legacy.Translate(snake))
|
|
||||||
assert.Equal(t, legacy, Legacy.Translate(legacy))
|
|
||||||
assert.Equal(t, "tag_rule=id:1,master_factor:0.6,master_count:2\n", Snake.Translate("tagrule=id:1,mfact:0.6,nmaster:2\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectBinary(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
old := filepath.Join(dir, "old")
|
|
||||||
cur := filepath.Join(dir, "new")
|
|
||||||
require.NoError(t, os.WriteFile(old, []byte("\x00run_exec_once\x00gappih\x00"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(cur, []byte("\x00exec_once\x00gap_inner_horizontal\x00"), 0o755))
|
|
||||||
assert.Equal(t, Legacy, DetectBinary(old))
|
|
||||||
assert.Equal(t, Snake, DetectBinary(cur))
|
|
||||||
assert.Equal(t, Legacy, DetectBinary(filepath.Join(dir, "missing")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMigrateLeavesUserConfigUnlessAsked(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dms"), 0o755))
|
|
||||||
main := filepath.Join(dir, "config.conf")
|
|
||||||
layout := filepath.Join(dir, "dms", "layout.conf")
|
|
||||||
input := filepath.Join(dir, "dms", "input.conf")
|
|
||||||
require.NoError(t, os.WriteFile(main, []byte("exec-once=dms run\n"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(layout, []byte("borderpx=2\n"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(input, []byte("numlockon=1\n"), 0o644))
|
|
||||||
|
|
||||||
changed, err := Snake.Migrate(dir, false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{layout, input}, changed)
|
|
||||||
data, _ := os.ReadFile(main)
|
|
||||||
assert.Equal(t, "exec-once=dms run\n", string(data))
|
|
||||||
data, _ = os.ReadFile(input)
|
|
||||||
assert.Equal(t, "numlock_on=1\n", string(data))
|
|
||||||
|
|
||||||
changed, err = Snake.Migrate(dir, true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{main}, changed)
|
|
||||||
backups, _ := filepath.Glob(main + ".backup.*")
|
|
||||||
assert.Len(t, backups, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMigrateReportsUnwritableFragmentAndContinues(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dms"), 0o755))
|
|
||||||
binds := filepath.Join(dir, "dms", "binds.conf")
|
|
||||||
layout := filepath.Join(dir, "dms", "layout.conf")
|
|
||||||
require.NoError(t, os.WriteFile(binds, []byte("exec-once=foo\n"), 0o444))
|
|
||||||
require.NoError(t, os.WriteFile(layout, []byte("borderpx=2\n"), 0o644))
|
|
||||||
|
|
||||||
changed, err := Snake.Migrate(dir, false)
|
|
||||||
assert.ErrorContains(t, err, "binds.conf")
|
|
||||||
assert.Equal(t, []string{layout}, changed)
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package mangoconf
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DMS-owned fragments under mango/dms; config.conf is the user's and only
|
|
||||||
// migrated on request.
|
|
||||||
var fragments = []string{"binds.conf", "colors.conf", "layout.conf", "cursor.conf", "input.conf", "outputs.conf", "windowrules.conf"}
|
|
||||||
|
|
||||||
// Migrate respells the dms fragments, and config.conf (after a backup) when includeMain is set.
|
|
||||||
// It returns the files rewritten; an unwritable file is reported without stopping the others.
|
|
||||||
func (d Dialect) Migrate(mangoDir string, includeMain bool) ([]string, error) {
|
|
||||||
var changed []string
|
|
||||||
var errs []error
|
|
||||||
paths := make([]string, 0, len(fragments)+1)
|
|
||||||
for _, f := range fragments {
|
|
||||||
paths = append(paths, filepath.Join(mangoDir, "dms", f))
|
|
||||||
}
|
|
||||||
if includeMain {
|
|
||||||
paths = append(paths, filepath.Join(mangoDir, "config.conf"))
|
|
||||||
}
|
|
||||||
for _, path := range paths {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
translated := d.Translate(string(data))
|
|
||||||
if translated == string(data) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if filepath.Base(path) == "config.conf" {
|
|
||||||
backup := path + ".backup." + time.Now().Format("2006-01-02_15-04-05")
|
|
||||||
if err := os.WriteFile(backup, data, 0o644); err != nil {
|
|
||||||
errs = append(errs, fmt.Errorf("backup %s: %w", path, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(path, []byte(translated), 0o644); err != nil {
|
|
||||||
errs = append(errs, fmt.Errorf("write %s: %w", path, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
changed = append(changed, path)
|
|
||||||
}
|
|
||||||
return changed, errors.Join(errs...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d Dialect) String() string {
|
|
||||||
if d == Snake {
|
|
||||||
return "snake"
|
|
||||||
}
|
|
||||||
return "legacy"
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -20,7 +19,6 @@ import (
|
|||||||
|
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/dank16"
|
"github.com/duxlabs-dev/dux-shell/core/internal/dank16"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
|
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
||||||
"github.com/godbus/dbus/v5"
|
"github.com/godbus/dbus/v5"
|
||||||
"github.com/lucasb-eyer/go-colorful"
|
"github.com/lucasb-eyer/go-colorful"
|
||||||
@@ -44,7 +42,6 @@ const (
|
|||||||
TemplateKindGTK
|
TemplateKindGTK
|
||||||
TemplateKindVSCode
|
TemplateKindVSCode
|
||||||
TemplateKindEmacs
|
TemplateKindEmacs
|
||||||
TemplateKindMango
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type TemplateDef struct {
|
type TemplateDef struct {
|
||||||
@@ -63,7 +60,7 @@ var templateRegistry = []TemplateDef{
|
|||||||
{ID: "gtk", Kind: TemplateKindGTK, RunUnconditionally: true},
|
{ID: "gtk", Kind: TemplateKindGTK, RunUnconditionally: true},
|
||||||
{ID: "niri", Commands: []string{"niri"}, ConfigFile: "niri.toml"},
|
{ID: "niri", Commands: []string{"niri"}, ConfigFile: "niri.toml"},
|
||||||
{ID: "hyprland", Commands: []string{"Hyprland"}, ConfigFile: "hyprland.toml"},
|
{ID: "hyprland", Commands: []string{"Hyprland"}, ConfigFile: "hyprland.toml"},
|
||||||
{ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml", RequiredEnv: "MANGO_INSTANCE_SIGNATURE", Kind: TemplateKindMango},
|
{ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml", RequiredEnv: "MANGO_INSTANCE_SIGNATURE"},
|
||||||
{ID: "qt5ct", Commands: []string{"qt5ct"}, ConfigFile: "qt5ct.toml"},
|
{ID: "qt5ct", Commands: []string{"qt5ct"}, ConfigFile: "qt5ct.toml"},
|
||||||
{ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"},
|
{ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"},
|
||||||
{ID: "fcitx5", Commands: []string{"fcitx5"}, ConfigDirs: []string{"fcitx5"}, ConfigFile: "fcitx5.toml"},
|
{ID: "fcitx5", Commands: []string{"fcitx5"}, ConfigDirs: []string{"fcitx5"}, ConfigFile: "fcitx5.toml"},
|
||||||
@@ -667,7 +664,7 @@ func userConfigSection(opts *Options) string {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "[config]\n\n"
|
return "[config]\n\n"
|
||||||
}
|
}
|
||||||
section := extractTOMLSection(string(data), "[config]", "[templates]")
|
section := extractTOMLSection(string(data), "[config]", "\n[templates")
|
||||||
if section == "" {
|
if section == "" {
|
||||||
return "[config]\n\n"
|
return "[config]\n\n"
|
||||||
}
|
}
|
||||||
@@ -725,12 +722,6 @@ output_path = '%s'
|
|||||||
for _, editor := range vscodeEditors {
|
for _, editor := range vscodeEditors {
|
||||||
appendVSCodeConfig(cfgFile, editor.name, editor.extensionsDir(homeDir), opts.ShellDir)
|
appendVSCodeConfig(cfgFile, editor.name, editor.extensionsDir(homeDir), opts.ShellDir)
|
||||||
}
|
}
|
||||||
case TemplateKindMango:
|
|
||||||
configFile := tmpl.ConfigFile
|
|
||||||
if mangoconf.Detect() == mangoconf.Snake {
|
|
||||||
configFile = "mangowc-snake.toml"
|
|
||||||
}
|
|
||||||
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, configFile)
|
|
||||||
case TemplateKindEmacs:
|
case TemplateKindEmacs:
|
||||||
if utils.EmacsConfigDir() != "" {
|
if utils.EmacsConfigDir() != "" {
|
||||||
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
|
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
|
||||||
@@ -749,7 +740,7 @@ output_path = '%s'
|
|||||||
|
|
||||||
if opts.RunUserTemplates {
|
if opts.RunUserTemplates {
|
||||||
if data, err := os.ReadFile(userConfigPath); err == nil {
|
if data, err := os.ReadFile(userConfigPath); err == nil {
|
||||||
templatesSection := extractTOMLSection(string(data), "[templates]", "")
|
templatesSection := extractTOMLSection("\n"+string(data), "\n[templates", "\n[config]")
|
||||||
if templatesSection != "" {
|
if templatesSection != "" {
|
||||||
cfgFile.WriteString(templatesSection)
|
cfgFile.WriteString(templatesSection)
|
||||||
cfgFile.WriteString("\n")
|
cfgFile.WriteString("\n")
|
||||||
@@ -965,6 +956,7 @@ var vscodeEditors = []vscodeEditor{
|
|||||||
{"cursor", ".cursor"},
|
{"cursor", ".cursor"},
|
||||||
{"windsurf", ".windsurf"},
|
{"windsurf", ".windsurf"},
|
||||||
{"positron", ".positron"},
|
{"positron", ".positron"},
|
||||||
|
{"antigravity", ".antigravity-ide"},
|
||||||
{"vscode-insiders", ".vscode-insiders"},
|
{"vscode-insiders", ".vscode-insiders"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1154,21 +1146,32 @@ func runMatugen(baseArgs []string, sourceMode string) error {
|
|||||||
|
|
||||||
// matugen's stderr is echoed as before and its tail travels in the error, so a failure
|
// matugen's stderr is echoed as before and its tail travels in the error, so a failure
|
||||||
// reaching the shell through the socket still names the broken template or option.
|
// reaching the shell through the socket still names the broken template or option.
|
||||||
|
// It goes through a file, not a pipe: with a pipe, Wait blocks until every hook
|
||||||
|
// child that inherited stderr exits, and a backgrounded post_hook never does (#3901).
|
||||||
func execMatugen(args []string) error {
|
func execMatugen(args []string) error {
|
||||||
|
stderrFile, err := os.CreateTemp("", "matugen-stderr-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("matugen: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(stderrFile.Name())
|
||||||
|
defer stderrFile.Close()
|
||||||
|
|
||||||
cmd := exec.Command("matugen", args...)
|
cmd := exec.Command("matugen", args...)
|
||||||
cmd.Env = utils.EnvWithUserBinPath(nil)
|
cmd.Env = utils.EnvWithUserBinPath(nil)
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
var stderr bytes.Buffer
|
cmd.Stderr = stderrFile
|
||||||
cmd.Stderr = io.MultiWriter(os.Stderr, &stderr)
|
runErr := cmd.Run()
|
||||||
err := cmd.Run()
|
|
||||||
if err == nil {
|
stderr, _ := os.ReadFile(stderrFile.Name())
|
||||||
|
os.Stderr.Write(stderr)
|
||||||
|
if runErr == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
tail := stderrTail(stderr.String(), 6)
|
tail := stderrTail(string(stderr), 6)
|
||||||
if tail == "" {
|
if tail == "" {
|
||||||
return fmt.Errorf("matugen: %w", err)
|
return fmt.Errorf("matugen: %w", runErr)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("matugen: %w\n%s", err, tail)
|
return fmt.Errorf("matugen: %w\n%s", runErr, tail)
|
||||||
}
|
}
|
||||||
|
|
||||||
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
|
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
|
||||||
@@ -1218,11 +1221,7 @@ func writeDryRunConfig(opts *Options) (string, error) {
|
|||||||
}
|
}
|
||||||
defer cfgFile.Close()
|
defer cfgFile.Close()
|
||||||
|
|
||||||
section := userConfigSection(opts)
|
cfgFile.WriteString(userConfigSection(opts))
|
||||||
if idx := strings.Index(section, "\n[templates"); idx != -1 {
|
|
||||||
section = section[:idx+1]
|
|
||||||
}
|
|
||||||
cfgFile.WriteString(section)
|
|
||||||
cfgFile.WriteString("[templates]\n")
|
cfgFile.WriteString("[templates]\n")
|
||||||
return cfgFile.Name(), nil
|
return cfgFile.Name(), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -511,7 +511,7 @@ func TestBuildMergedConfigColorsOnly(t *testing.T) {
|
|||||||
assert.False(t, strings.Contains(content, "output_path = 'CONFIG_DIR/"), "colors-only config should not emit app template outputs")
|
assert.False(t, strings.Contains(content, "output_path = 'CONFIG_DIR/"), "colors-only config should not emit app template outputs")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildMergedConfigSkipsMangowcWithoutActiveSession(t *testing.T) {
|
func TestBuildMergedConfigMangoSession(t *testing.T) {
|
||||||
t.Setenv("MANGO_INSTANCE_SIGNATURE", "")
|
t.Setenv("MANGO_INSTANCE_SIGNATURE", "")
|
||||||
|
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
@@ -555,6 +555,31 @@ func TestBuildMergedConfigSkipsMangowcWithoutActiveSession(t *testing.T) {
|
|||||||
t.Fatalf("failed to read merged config: %v", err)
|
t.Fatalf("failed to read merged config: %v", err)
|
||||||
}
|
}
|
||||||
assert.NotContains(t, string(output), "[templates.dmsmango]")
|
assert.NotContains(t, string(output), "[templates.dmsmango]")
|
||||||
|
|
||||||
|
socket := filepath.Join(tempDir, "mango.sock")
|
||||||
|
if err := os.WriteFile(socket, nil, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("MANGO_INSTANCE_SIGNATURE", socket)
|
||||||
|
checker := mocks_utils.NewMockAppChecker(t)
|
||||||
|
checker.EXPECT().AnyCommandExists("mango").Return(true)
|
||||||
|
opts.AppChecker = checker
|
||||||
|
cfgFile, err = os.CreateTemp(tempDir, "merged-active-*.toml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer cfgFile.Close()
|
||||||
|
if err := buildMergedConfig(opts, cfgFile, filepath.Join(tempDir, "templates")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cfgFile.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
output, err = os.ReadFile(cfgFile.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assert.Contains(t, string(output), mangowcConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAppendConfigConfigDirExists(t *testing.T) {
|
func TestAppendConfigConfigDirExists(t *testing.T) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/duxlabs-dev/dux-go/wayland/client"
|
"github.com/duxlabs-dev/dux-go/wayland/client"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wlr_output_management"
|
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wlr_output_management"
|
||||||
@@ -151,6 +152,31 @@ func GetHyprlandMonitorScale(name string) float64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// !TODO: remove with the frameInLogicalOrientation version fallback
|
||||||
|
var hyprlandVersion = sync.OnceValues(func() (int, int) {
|
||||||
|
output, err := exec.Command("hyprctl", "-j", "version").Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
var info struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output, &info); err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
var major, minor int
|
||||||
|
fmt.Sscanf(info.Version, "%d.%d", &major, &minor)
|
||||||
|
return major, minor
|
||||||
|
})
|
||||||
|
|
||||||
|
func hyprlandVersionAtLeast(major, minor int) bool {
|
||||||
|
haveMajor, haveMinor := hyprlandVersion()
|
||||||
|
if haveMajor != major {
|
||||||
|
return haveMajor > major
|
||||||
|
}
|
||||||
|
return haveMinor >= minor
|
||||||
|
}
|
||||||
|
|
||||||
func getHyprlandFocusedMonitor() string {
|
func getHyprlandFocusedMonitor() string {
|
||||||
output, err := exec.Command("hyprctl", "-j", "monitors").Output()
|
output, err := exec.Command("hyprctl", "-j", "monitors").Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -609,15 +609,12 @@ func (r *RegionSelector) preCaptureOutput(output *WaylandOutput, pc *PreCapture,
|
|||||||
pc.yInverted = false
|
pc.yInverted = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if output.transform != TransformNormal {
|
transformed, err := output.untransformFrame(capturedBuf)
|
||||||
invTransform := InverseTransform(output.transform)
|
if err != nil {
|
||||||
transformed, err := capturedBuf.ApplyTransform(invTransform)
|
log.Error("apply transform failed", "err", err)
|
||||||
if err != nil {
|
} else if transformed != capturedBuf {
|
||||||
log.Error("apply transform failed", "err", err)
|
capturedBuf.Close()
|
||||||
} else if transformed != capturedBuf {
|
capturedBuf = transformed
|
||||||
capturedBuf.Close()
|
|
||||||
capturedBuf = transformed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if withCursor {
|
if withCursor {
|
||||||
|
|||||||
@@ -49,6 +49,28 @@ func (o *WaylandOutput) effectiveScale() float64 {
|
|||||||
return scale
|
return scale
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hyprland renders screencopy frames in the output's logical orientation since
|
||||||
|
// 901881c9f (v0.57.0); wlroots and niri still hand back the native framebuffer.
|
||||||
|
// !TODO: drop the version fallback once every distro ships hyprland >= 0.57
|
||||||
|
func (o *WaylandOutput) frameInLogicalOrientation(frameW, frameH int) bool {
|
||||||
|
switch o.transform {
|
||||||
|
case TransformNormal:
|
||||||
|
return true
|
||||||
|
case Transform90, Transform270, TransformFlipped90, TransformFlipped270:
|
||||||
|
if o.width != o.height {
|
||||||
|
return frameW == int(o.height) && frameH == int(o.width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DetectCompositor() == CompositorHyprland && hyprlandVersionAtLeast(0, 57)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *WaylandOutput) untransformFrame(buf *ShmBuffer) (*ShmBuffer, error) {
|
||||||
|
if o.frameInLogicalOrientation(buf.Width, buf.Height) {
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
return buf.ApplyTransform(InverseTransform(o.transform))
|
||||||
|
}
|
||||||
|
|
||||||
func (o *WaylandOutput) bounds() Region {
|
func (o *WaylandOutput) bounds() Region {
|
||||||
x, y, w, h := o.x, o.y, o.width, o.height
|
x, y, w, h := o.x, o.y, o.width, o.height
|
||||||
if DetectCompositor() == CompositorHyprland {
|
if DetectCompositor() == CompositorHyprland {
|
||||||
@@ -677,12 +699,7 @@ func (s *Screenshoter) captureWholeOutput(output *WaylandOutput) (*CaptureResult
|
|||||||
result.YInverted = false
|
result.YInverted = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if output.transform == TransformNormal {
|
transformed, err := output.untransformFrame(result.Buffer)
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
invTransform := InverseTransform(output.transform)
|
|
||||||
transformed, err := result.Buffer.ApplyTransform(invTransform)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Buffer.Close()
|
result.Buffer.Close()
|
||||||
return nil, fmt.Errorf("apply transform: %w", err)
|
return nil, fmt.Errorf("apply transform: %w", err)
|
||||||
|
|||||||
@@ -40,10 +40,6 @@ func HandleRequest(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
|
|||||||
handleSubscribe(conn, req, m)
|
handleSubscribe(conn, req, m)
|
||||||
case "clipboard.search":
|
case "clipboard.search":
|
||||||
handleSearch(conn, req, m)
|
handleSearch(conn, req, m)
|
||||||
case "clipboard.getConfig":
|
|
||||||
handleGetConfig(conn, req, m)
|
|
||||||
case "clipboard.setConfig":
|
|
||||||
handleSetConfig(conn, req, m)
|
|
||||||
case "clipboard.store":
|
case "clipboard.store":
|
||||||
handleStore(conn, req, m)
|
handleStore(conn, req, m)
|
||||||
case "clipboard.pinEntry":
|
case "clipboard.pinEntry":
|
||||||
@@ -320,40 +316,6 @@ func handleSearch(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
|
|||||||
conn.Respond(req.ID, m.Search(p))
|
conn.Respond(req.ID, m.Search(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleGetConfig(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
|
|
||||||
conn.Respond(req.ID, m.GetConfig())
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleSetConfig(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
|
|
||||||
cfg := m.GetConfig()
|
|
||||||
|
|
||||||
if v, ok := req.Get[float64]("maxHistory"); ok {
|
|
||||||
cfg.MaxHistory = int(v)
|
|
||||||
}
|
|
||||||
if v, ok := req.Get[float64]("maxEntrySize"); ok {
|
|
||||||
cfg.MaxEntrySize = int64(v)
|
|
||||||
}
|
|
||||||
if v, ok := req.Get[float64]("autoClearDays"); ok {
|
|
||||||
cfg.AutoClearDays = int(v)
|
|
||||||
}
|
|
||||||
if v, ok := req.Get[bool]("clearAtStartup"); ok {
|
|
||||||
cfg.ClearAtStartup = v
|
|
||||||
}
|
|
||||||
if v, ok := req.Get[bool]("disabled"); ok {
|
|
||||||
cfg.Disabled = v
|
|
||||||
}
|
|
||||||
if v, ok := req.Get[float64]("maxPinned"); ok {
|
|
||||||
cfg.MaxPinned = int(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.SetConfig(cfg); err != nil {
|
|
||||||
conn.RespondError(req.ID, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "config updated"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleStore(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
|
func handleStore(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
|
||||||
data, err := params.String(req.Params, "data")
|
data, err := params.String(req.Params, "data")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package clipboard
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"cmp"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -29,6 +30,7 @@ import (
|
|||||||
_ "golang.org/x/image/webp"
|
_ "golang.org/x/image/webp"
|
||||||
|
|
||||||
bolt "go.etcd.io/bbolt"
|
bolt "go.etcd.io/bbolt"
|
||||||
|
berrors "go.etcd.io/bbolt/errors"
|
||||||
|
|
||||||
wlclient "github.com/duxlabs-dev/dux-go/wayland/client"
|
wlclient "github.com/duxlabs-dev/dux-go/wayland/client"
|
||||||
"github.com/duxlabs-dev/dux-go/wayland/ext_data_control"
|
"github.com/duxlabs-dev/dux-go/wayland/ext_data_control"
|
||||||
@@ -55,8 +57,14 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
|
|||||||
|
|
||||||
configPath, _ := getConfigPath()
|
configPath, _ := getConfigPath()
|
||||||
|
|
||||||
|
db, err := openDB(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
m := &Manager{
|
m := &Manager{
|
||||||
config: config,
|
config: config,
|
||||||
|
db: db,
|
||||||
configPath: configPath,
|
configPath: configPath,
|
||||||
display: display,
|
display: display,
|
||||||
wlCtx: wlCtx,
|
wlCtx: wlCtx,
|
||||||
@@ -69,6 +77,7 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
|
|||||||
|
|
||||||
if !config.Disabled {
|
if !config.Disabled {
|
||||||
if err := m.setupRegistry(); err != nil {
|
if err := m.setupRegistry(); err != nil {
|
||||||
|
db.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,12 +87,6 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
|
|||||||
|
|
||||||
go m.watchConfig()
|
go m.watchConfig()
|
||||||
|
|
||||||
db, err := openDB(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to open db: %w", err)
|
|
||||||
}
|
|
||||||
m.db = db
|
|
||||||
|
|
||||||
if err := m.migrateHashes(); err != nil {
|
if err := m.migrateHashes(); err != nil {
|
||||||
log.Errorf("Failed to migrate hashes: %v", err)
|
log.Errorf("Failed to migrate hashes: %v", err)
|
||||||
}
|
}
|
||||||
@@ -117,6 +120,9 @@ func openDB(path string) (*bolt.DB, error) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, berrors.ErrTimeout) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
log.Errorf("Clipboard db corrupted, salvaging readable entries: %v", err)
|
log.Errorf("Clipboard db corrupted, salvaging readable entries: %v", err)
|
||||||
|
|
||||||
@@ -244,6 +250,9 @@ func (m *Manager) setupRegistry() error {
|
|||||||
m.dataControlMgr = dataControlMgr
|
m.dataControlMgr = dataControlMgr
|
||||||
log.Info("Bound ext_data_control_manager_v1")
|
log.Info("Bound ext_data_control_manager_v1")
|
||||||
case "wl_seat":
|
case "wl_seat":
|
||||||
|
if m.seat != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
seat := wlclient.NewSeat(ctx)
|
seat := wlclient.NewSeat(ctx)
|
||||||
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
|
if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil {
|
||||||
log.Errorf("Failed to bind wl_seat: %v", err)
|
log.Errorf("Failed to bind wl_seat: %v", err)
|
||||||
@@ -272,17 +281,65 @@ func (m *Manager) setupRegistry() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) setupDataDeviceSync() {
|
func (m *Manager) setupDataDeviceSync() {
|
||||||
if m.dataControlMgr == nil || m.seat == nil {
|
if !m.bindDataDevice() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := m.display.Context()
|
if err := m.display.Context().Dispatch(); err != nil {
|
||||||
|
log.Errorf("Failed to dispatch initial events: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Data device setup complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs on the dispatcher goroutine; a disabled manager must hold no device so it receives no offers.
|
||||||
|
func (m *Manager) syncTracking() {
|
||||||
|
if m.getConfig().Disabled {
|
||||||
|
m.releaseDataDevice()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.dataDevice != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.registry == nil {
|
||||||
|
if err := m.setupRegistry(); err != nil {
|
||||||
|
log.Errorf("Failed to enable clipboard tracking: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.bindDataDevice() {
|
||||||
|
log.Info("Data device setup complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) releaseDataDevice() {
|
||||||
|
m.deviceBound.Store(false)
|
||||||
|
|
||||||
|
if m.currentOffer != nil {
|
||||||
|
m.releaseOffer(m.currentOffer)
|
||||||
|
m.currentOffer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.dataDevice == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1).Destroy()
|
||||||
|
m.dataDevice = nil
|
||||||
|
m.mimeTypes = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) bindDataDevice() bool {
|
||||||
|
if m.dataControlMgr == nil || m.seat == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
|
dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
|
||||||
|
|
||||||
dataDevice, err := dataMgr.GetDataDevice(m.seat)
|
dataDevice, err := dataMgr.GetDataDevice(m.seat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("Failed to send get_data_device request: %v", err)
|
log.Errorf("Failed to send get_data_device request: %v", err)
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
dataDevice.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) {
|
dataDevice.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) {
|
||||||
@@ -302,8 +359,12 @@ func (m *Manager) setupDataDeviceSync() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
dataDevice.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) {
|
dataDevice.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) {
|
||||||
|
// The first event replays the selection that existed before binding.
|
||||||
if !m.initialized {
|
if !m.initialized {
|
||||||
m.initialized = true
|
m.initialized = true
|
||||||
|
if e.Id != nil {
|
||||||
|
m.releaseOffer(e.Id)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +392,11 @@ func (m *Manager) setupDataDeviceSync() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An event can land between a runtime disable and the posted release.
|
||||||
|
if m.getConfig().Disabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
m.offerMutex.RLock()
|
m.offerMutex.RLock()
|
||||||
mimes := m.offerMimeTypes[offer]
|
mimes := m.offerMimeTypes[offer]
|
||||||
m.offerMutex.RUnlock()
|
m.offerMutex.RUnlock()
|
||||||
@@ -389,14 +455,10 @@ func (m *Manager) setupDataDeviceSync() {
|
|||||||
go m.readAndStore(r, preferredMime, altR, altMime)
|
go m.readAndStore(r, preferredMime, altR, altMime)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
m.initialized = false
|
||||||
m.dataDevice = dataDevice
|
m.dataDevice = dataDevice
|
||||||
|
m.deviceBound.Store(true)
|
||||||
if err := ctx.Dispatch(); err != nil {
|
return true
|
||||||
log.Errorf("Failed to dispatch initial events: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("Data device setup complete")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) releaseOffer(offer any) {
|
func (m *Manager) releaseOffer(offer any) {
|
||||||
@@ -515,6 +577,9 @@ func (m *Manager) storeClipboardEntry(data []byte, mimeType string, altData []by
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) storeEntry(entry Entry) error {
|
func (m *Manager) storeEntry(entry Entry) error {
|
||||||
|
if m.getConfig().Disabled {
|
||||||
|
return errTrackingDisabled
|
||||||
|
}
|
||||||
if m.db == nil {
|
if m.db == nil {
|
||||||
return fmt.Errorf("database not available")
|
return fmt.Errorf("database not available")
|
||||||
}
|
}
|
||||||
@@ -569,21 +634,31 @@ func (m *Manager) deduplicateInTx(b *bolt.Bucket, hash uint64) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) trimLengthInTx(b *bolt.Bucket) error {
|
func (m *Manager) trimLengthInTx(b *bolt.Bucket) error {
|
||||||
if m.config.MaxHistory < 0 {
|
maxHistory := m.getConfig().MaxHistory
|
||||||
|
if maxHistory < 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
key []byte
|
||||||
|
entry Entry
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
c := b.Cursor()
|
c := b.Cursor()
|
||||||
var count int
|
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||||
for k, v := c.Last(); k != nil; k, v = c.Prev() {
|
|
||||||
entry, err := decodeEntryMeta(v)
|
entry, err := decodeEntryMeta(v)
|
||||||
if err == nil && entry.Pinned {
|
if err != nil || entry.Pinned {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if count < m.config.MaxHistory {
|
rows = append(rows, row{slices.Clone(k), entry})
|
||||||
count++
|
}
|
||||||
continue
|
|
||||||
}
|
slices.SortFunc(rows, func(a, b row) int {
|
||||||
if err := b.Delete(k); err != nil {
|
return compareEntriesNewestFirst(a.entry, b.entry)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, r := range rows[min(maxHistory, len(rows)):] {
|
||||||
|
if err := b.Delete(r.key); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -601,7 +676,7 @@ func encodeEntry(e Entry) ([]byte, error) {
|
|||||||
binary.Write(buf, binary.BigEndian, uint32(len(e.Preview)))
|
binary.Write(buf, binary.BigEndian, uint32(len(e.Preview)))
|
||||||
buf.WriteString(e.Preview)
|
buf.WriteString(e.Preview)
|
||||||
binary.Write(buf, binary.BigEndian, int32(e.Size))
|
binary.Write(buf, binary.BigEndian, int32(e.Size))
|
||||||
binary.Write(buf, binary.BigEndian, e.Timestamp.Unix())
|
binary.Write(buf, binary.BigEndian, e.Timestamp.UnixNano())
|
||||||
if e.IsImage {
|
if e.IsImage {
|
||||||
buf.WriteByte(1)
|
buf.WriteByte(1)
|
||||||
} else {
|
} else {
|
||||||
@@ -667,7 +742,13 @@ func decodeEntryFields(data []byte, withData bool) (Entry, error) {
|
|||||||
|
|
||||||
var timestamp int64
|
var timestamp int64
|
||||||
binary.Read(buf, binary.BigEndian, ×tamp)
|
binary.Read(buf, binary.BigEndian, ×tamp)
|
||||||
e.Timestamp = time.Unix(timestamp, 0)
|
// Rows written before the switch to nanosecond precision hold seconds;
|
||||||
|
// no second-resolution stamp reaches 1e12 until the year 33658.
|
||||||
|
if timestamp < 1e12 {
|
||||||
|
e.Timestamp = time.Unix(timestamp, 0)
|
||||||
|
} else {
|
||||||
|
e.Timestamp = time.Unix(0, timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
var isImage byte
|
var isImage byte
|
||||||
binary.Read(buf, binary.BigEndian, &isImage)
|
binary.Read(buf, binary.BigEndian, &isImage)
|
||||||
@@ -1043,9 +1124,25 @@ func (m *Manager) GetHistory() []Entry {
|
|||||||
go m.deleteStaleEntries(stale)
|
go m.deleteStaleEntries(stale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sortEntriesNewestFirst(history)
|
||||||
|
|
||||||
return history
|
return history
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sortEntriesNewestFirst orders by timestamp, falling back to id: TouchEntry
|
||||||
|
// rewrites a row in place to keep its id stable, so bucket key order stops
|
||||||
|
// tracking recency.
|
||||||
|
func sortEntriesNewestFirst(entries []Entry) {
|
||||||
|
slices.SortFunc(entries, compareEntriesNewestFirst)
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareEntriesNewestFirst(a, b Entry) int {
|
||||||
|
if c := b.Timestamp.Compare(a.Timestamp); c != 0 {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return cmp.Compare(b.ID, a.ID)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) deleteStaleEntries(ids []uint64) {
|
func (m *Manager) deleteStaleEntries(ids []uint64) {
|
||||||
if m.db == nil {
|
if m.db == nil {
|
||||||
return
|
return
|
||||||
@@ -1181,9 +1278,24 @@ func (m *Manager) TouchEntry(id uint64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.Timestamp = time.Now()
|
// Round(0) drops the monotonic reading so the in-memory stamp compares the
|
||||||
|
// same way as the one decoded back from the bucket.
|
||||||
|
entry.Timestamp = time.Now().Round(0)
|
||||||
|
|
||||||
if err := m.storeEntry(*entry); err != nil {
|
// storeEntry would dedupe the row against itself and hand out a fresh
|
||||||
|
// sequence number, invalidating the id the UI is still holding.
|
||||||
|
err = m.dbUpdate(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket([]byte("clipboard"))
|
||||||
|
if b == nil {
|
||||||
|
return fmt.Errorf("clipboard bucket missing")
|
||||||
|
}
|
||||||
|
encoded, err := encodeEntry(*entry)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return b.Put(itob(id), encoded)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1336,19 +1448,22 @@ func (m *Manager) reopenDB() {
|
|||||||
m.db = db
|
m.db = db
|
||||||
}
|
}
|
||||||
|
|
||||||
var errSelectionUnavailable = errors.New("clipboard selection unavailable: tracking disabled or no data control protocol")
|
var (
|
||||||
|
errSelectionUnavailable = errors.New("clipboard selection unavailable: tracking disabled or no data control protocol")
|
||||||
|
errTrackingDisabled = errors.New("clipboard tracking disabled")
|
||||||
|
)
|
||||||
|
|
||||||
// Both are bound once in NewManager; a disabled manager never binds them but
|
// A disabled manager still serves copy requests; failing here lets callers
|
||||||
// still serves copy requests, so callers must fail before touching history.
|
// such as dms screenshot fall back to their own copy.
|
||||||
func (m *Manager) canTakeSelection() bool {
|
func (m *Manager) canTakeSelection() bool {
|
||||||
return m.dataControlMgr != nil && m.dataDevice != nil
|
return m.deviceBound.Load() && !m.getConfig().Disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) SetClipboard(data []byte, mimeType string) error {
|
func (m *Manager) SetClipboard(data []byte, mimeType string) error {
|
||||||
if !m.canTakeSelection() {
|
if !m.canTakeSelection() {
|
||||||
return errSelectionUnavailable
|
return errSelectionUnavailable
|
||||||
}
|
}
|
||||||
if int64(len(data)) > m.config.MaxEntrySize {
|
if int64(len(data)) > m.getConfig().MaxEntrySize {
|
||||||
return fmt.Errorf("data too large")
|
return fmt.Errorf("data too large")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1366,7 +1481,7 @@ func (m *Manager) SetClipboardEntry(entry *Entry) error {
|
|||||||
if !m.canTakeSelection() {
|
if !m.canTakeSelection() {
|
||||||
return errSelectionUnavailable
|
return errSelectionUnavailable
|
||||||
}
|
}
|
||||||
if int64(len(entry.Data)) > m.config.MaxEntrySize {
|
if int64(len(entry.Data)) > m.getConfig().MaxEntrySize {
|
||||||
return fmt.Errorf("data too large")
|
return fmt.Errorf("data too large")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1515,16 +1630,7 @@ func (m *Manager) Close() {
|
|||||||
m.subMutex.Unlock()
|
m.subMutex.Unlock()
|
||||||
|
|
||||||
m.releaseCurrentSource()
|
m.releaseCurrentSource()
|
||||||
|
m.releaseDataDevice()
|
||||||
if m.currentOffer != nil {
|
|
||||||
m.releaseOffer(m.currentOffer)
|
|
||||||
m.currentOffer = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.dataDevice != nil {
|
|
||||||
device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1)
|
|
||||||
device.Destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.dataControlMgr != nil {
|
if m.dataControlMgr != nil {
|
||||||
mgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
|
mgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
|
||||||
@@ -1724,19 +1830,12 @@ func (m *Manager) Search(params SearchParams) SearchResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) GetConfig() Config {
|
|
||||||
return m.config
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Manager) SetConfig(cfg Config) error {
|
func (m *Manager) SetConfig(cfg Config) error {
|
||||||
m.configMutex.Lock()
|
if err := SaveConfig(cfg); err != nil {
|
||||||
m.config = cfg
|
return err
|
||||||
m.configMutex.Unlock()
|
}
|
||||||
|
m.applyConfigChange(cfg)
|
||||||
m.updateState()
|
return nil
|
||||||
m.notifySubscribers()
|
|
||||||
|
|
||||||
return SaveConfig(cfg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) getConfig() Config {
|
func (m *Manager) getConfig() Config {
|
||||||
@@ -1777,7 +1876,12 @@ func (m *Manager) watchConfig() {
|
|||||||
if event.Op&(fsnotify.Write|fsnotify.Create) == 0 {
|
if event.Op&(fsnotify.Write|fsnotify.Create) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
newCfg := LoadConfig()
|
// os.WriteFile truncates first; parsing that empty read would
|
||||||
|
// briefly reset every setting to its default.
|
||||||
|
newCfg, err := readConfig()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
m.applyConfigChange(newCfg)
|
m.applyConfigChange(newCfg)
|
||||||
case err, ok := <-watcher.Errors:
|
case err, ok := <-watcher.Errors:
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1794,11 +1898,9 @@ func (m *Manager) applyConfigChange(newCfg Config) {
|
|||||||
m.config = newCfg
|
m.config = newCfg
|
||||||
m.configMutex.Unlock()
|
m.configMutex.Unlock()
|
||||||
|
|
||||||
switch {
|
if newCfg.Disabled != oldCfg.Disabled {
|
||||||
case newCfg.Disabled && !oldCfg.Disabled:
|
log.Infof("Clipboard tracking disabled=%v", newCfg.Disabled)
|
||||||
log.Info("Clipboard tracking disabled")
|
m.post(m.syncTracking)
|
||||||
case !newCfg.Disabled && oldCfg.Disabled:
|
|
||||||
log.Info("Clipboard tracking enabled")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
m.updateState()
|
m.updateState()
|
||||||
@@ -1809,7 +1911,7 @@ func (m *Manager) StoreData(data []byte, mimeType string) error {
|
|||||||
cfg := m.getConfig()
|
cfg := m.getConfig()
|
||||||
|
|
||||||
if cfg.Disabled {
|
if cfg.Disabled {
|
||||||
return fmt.Errorf("clipboard tracking disabled")
|
return errTrackingDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.db == nil {
|
if m.db == nil {
|
||||||
@@ -2148,6 +2250,8 @@ func (m *Manager) GetPinnedEntries() []Entry {
|
|||||||
log.Errorf("Failed to get pinned entries: %v", err)
|
log.Errorf("Failed to get pinned entries: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sortEntriesNewestFirst(pinned)
|
||||||
|
|
||||||
return pinned
|
return pinned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package clipboard
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/png"
|
"image/png"
|
||||||
"net"
|
"net"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
bolt "go.etcd.io/bbolt"
|
bolt "go.etcd.io/bbolt"
|
||||||
|
|
||||||
"github.com/duxlabs-dev/dux-go/ipc"
|
"github.com/duxlabs-dev/dux-go/ipc"
|
||||||
|
wlclient "github.com/duxlabs-dev/dux-go/wayland/client"
|
||||||
"github.com/duxlabs-dev/dux-go/wayland/ext_data_control"
|
"github.com/duxlabs-dev/dux-go/wayland/ext_data_control"
|
||||||
mocks_wlcontext "github.com/duxlabs-dev/dux-shell/core/internal/mocks/wlcontext"
|
mocks_wlcontext "github.com/duxlabs-dev/dux-shell/core/internal/mocks/wlcontext"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/server/models"
|
"github.com/duxlabs-dev/dux-shell/core/internal/server/models"
|
||||||
@@ -51,13 +53,15 @@ func newTestManagerWithDB(t *testing.T) *Manager {
|
|||||||
mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
|
mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
|
||||||
mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Return().Maybe()
|
mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Return().Maybe()
|
||||||
|
|
||||||
return &Manager{
|
m := &Manager{
|
||||||
config: DefaultConfig(),
|
config: DefaultConfig(),
|
||||||
db: db,
|
db: db,
|
||||||
wlCtx: mockCtx,
|
wlCtx: mockCtx,
|
||||||
dataControlMgr: &ext_data_control.ExtDataControlManagerV1{},
|
dataControlMgr: &ext_data_control.ExtDataControlManagerV1{},
|
||||||
dataDevice: &ext_data_control.ExtDataControlDeviceV1{},
|
dataDevice: &ext_data_control.ExtDataControlDeviceV1{},
|
||||||
}
|
}
|
||||||
|
m.deviceBound.Store(true)
|
||||||
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEncodeDecodeEntry_Roundtrip(t *testing.T) {
|
func TestEncodeDecodeEntry_Roundtrip(t *testing.T) {
|
||||||
@@ -443,7 +447,9 @@ func TestUnpinEntry_KeepsTopUnpinnedDuplicate(t *testing.T) {
|
|||||||
|
|
||||||
history = m.GetHistory()
|
history = m.GetHistory()
|
||||||
require.Len(t, history, 3)
|
require.Len(t, history, 3)
|
||||||
require.Equal(t, topHistoryDuplicate.ID, history[0].ID)
|
// GetHistory sorts by timestamp, so the newer row leads even though its
|
||||||
|
// id is lower.
|
||||||
|
require.Equal(t, olderHistoryDuplicate.ID, history[0].ID)
|
||||||
require.NoError(t, m.UnpinEntry(pinnedID))
|
require.NoError(t, m.UnpinEntry(pinnedID))
|
||||||
|
|
||||||
history = m.GetHistory()
|
history = m.GetHistory()
|
||||||
@@ -887,6 +893,24 @@ func TestOpenDB_HealsGarbageFile(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenDB_LockedByAnotherProcessKeepsHistory(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "db")
|
||||||
|
holder, err := openDB(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
m := &Manager{config: DefaultConfig(), db: holder, dbPath: path}
|
||||||
|
require.NoError(t, m.storeEntry(Entry{Data: []byte("x"), MimeType: "text/plain"}))
|
||||||
|
|
||||||
|
_, err = openDB(path)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.NoError(t, holder.Close())
|
||||||
|
|
||||||
|
reopened, err := openDB(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer reopened.Close()
|
||||||
|
m = &Manager{config: DefaultConfig(), db: reopened, dbPath: path}
|
||||||
|
assert.Len(t, m.GetHistory(), 1)
|
||||||
|
}
|
||||||
|
|
||||||
func TestStoreEntry_CorruptDBReturnsErrorNotPanic(t *testing.T) {
|
func TestStoreEntry_CorruptDBReturnsErrorNotPanic(t *testing.T) {
|
||||||
for _, tt := range corruptDBCases {
|
for _, tt := range corruptDBCases {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -1040,3 +1064,86 @@ func TestCopyFile_DisabledTrackingRefusesCopy(t *testing.T) {
|
|||||||
require.ErrorIs(t, m.CopyFile(path), errSelectionUnavailable)
|
require.ErrorIs(t, m.CopyFile(path), errSelectionUnavailable)
|
||||||
assert.Empty(t, m.GetHistory())
|
assert.Empty(t, m.GetHistory())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyConfigChange_DisableReleasesDataDevice(t *testing.T) {
|
||||||
|
sock := filepath.Join(t.TempDir(), "wayland-test")
|
||||||
|
ln, err := net.Listen("unix", sock)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { ln.Close() })
|
||||||
|
|
||||||
|
display, err := wlclient.Connect(sock)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { display.Context().Close() })
|
||||||
|
|
||||||
|
m := newTestManagerWithDB(t)
|
||||||
|
mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
|
||||||
|
mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) { fn() })
|
||||||
|
m.wlCtx = mockCtx
|
||||||
|
device := ext_data_control.NewExtDataControlDeviceV1(display.Context())
|
||||||
|
m.dataDevice = device
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.Disabled = true
|
||||||
|
m.applyConfigChange(cfg)
|
||||||
|
|
||||||
|
assert.True(t, device.IsZombie())
|
||||||
|
assert.Nil(t, m.dataDevice)
|
||||||
|
require.ErrorIs(t, m.CopyText("secret"), errSelectionUnavailable)
|
||||||
|
assert.Empty(t, m.GetHistory())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTouchEntry_SurvivesTrimWhenHistoryFull(t *testing.T) {
|
||||||
|
m := newTestManagerWithDB(t)
|
||||||
|
m.config.MaxHistory = 3
|
||||||
|
|
||||||
|
for i := range 3 {
|
||||||
|
require.NoError(t, m.storeEntry(Entry{
|
||||||
|
Data: []byte(fmt.Sprintf("entry-%d", i)),
|
||||||
|
MimeType: "text/plain",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
oldest := m.GetHistory()[2]
|
||||||
|
require.NoError(t, m.TouchEntry(oldest.ID))
|
||||||
|
|
||||||
|
// A new copy trims the bucket back to MaxHistory.
|
||||||
|
require.NoError(t, m.storeEntry(Entry{
|
||||||
|
Data: []byte("newcomer"),
|
||||||
|
MimeType: "text/plain",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
history := m.GetHistory()
|
||||||
|
require.Len(t, history, 3)
|
||||||
|
ids := make([]uint64, len(history))
|
||||||
|
for i, e := range history {
|
||||||
|
ids[i] = e.ID
|
||||||
|
}
|
||||||
|
assert.Contains(t, ids, oldest.ID, "the touched row must survive trimming")
|
||||||
|
assert.Equal(t, oldest.ID, history[1].ID, "the touched row ranks second, behind the newer copy")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetHistory_SameSecondOrdersByID(t *testing.T) {
|
||||||
|
m := newTestManagerWithDB(t)
|
||||||
|
m.config.MaxHistory = 10
|
||||||
|
|
||||||
|
stamp := time.Now()
|
||||||
|
for i := range 3 {
|
||||||
|
require.NoError(t, m.storeEntry(Entry{
|
||||||
|
Data: []byte(fmt.Sprintf("same-second-%d", i)),
|
||||||
|
MimeType: "text/plain",
|
||||||
|
Timestamp: stamp,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touching the oldest row rewrites it in place, so the bucket now yields
|
||||||
|
// it first even though every row shares a timestamp.
|
||||||
|
oldest := m.GetHistory()[2]
|
||||||
|
require.NoError(t, m.TouchEntry(oldest.ID))
|
||||||
|
|
||||||
|
history := m.GetHistory()
|
||||||
|
require.Len(t, history, 3)
|
||||||
|
assert.Equal(t, oldest.ID, history[0].ID, "the touched row must lead: its timestamp is newer")
|
||||||
|
assert.Greater(t, history[1].ID, history[2].ID, "rows still tied on timestamp break on newest id")
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
"github.com/godbus/dbus/v5"
|
||||||
@@ -44,22 +45,30 @@ func getConfigPath() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() Config {
|
func LoadConfig() Config {
|
||||||
|
cfg, err := readConfig()
|
||||||
|
if err != nil {
|
||||||
|
return DefaultConfig()
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func readConfig() (Config, error) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
path, err := getConfigPath()
|
path, err := getConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cfg
|
return cfg, err
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cfg
|
return cfg, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
return DefaultConfig()
|
return DefaultConfig(), err
|
||||||
}
|
}
|
||||||
return cfg
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func SaveConfig(cfg Config) error {
|
func SaveConfig(cfg Config) error {
|
||||||
@@ -140,6 +149,7 @@ type Manager struct {
|
|||||||
pasteSupported bool
|
pasteSupported bool
|
||||||
|
|
||||||
initialized bool
|
initialized bool
|
||||||
|
deviceBound atomic.Bool
|
||||||
|
|
||||||
alive bool
|
alive bool
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package dbus
|
package dbus
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/duxlabs-dev/dux-go/ipc"
|
"github.com/duxlabs-dev/dux-go/ipc"
|
||||||
@@ -43,7 +44,7 @@ func extractObjectParams(p map[string]any, requirePath bool) (objectParams, erro
|
|||||||
return objectParams{bus: bus, dest: dest, path: path, iface: iface}, nil
|
return objectParams{bus: bus, dest: dest, path: path, iface: iface}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleRequest(conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID string) {
|
func HandleRequest(ctx context.Context, conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID string) {
|
||||||
switch req.Method {
|
switch req.Method {
|
||||||
case "dbus.call":
|
case "dbus.call":
|
||||||
handleCall(conn, req, m)
|
handleCall(conn, req, m)
|
||||||
@@ -58,7 +59,7 @@ func HandleRequest(conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID s
|
|||||||
case "dbus.listNames":
|
case "dbus.listNames":
|
||||||
handleListNames(conn, req, m)
|
handleListNames(conn, req, m)
|
||||||
case "dbus.subscribe":
|
case "dbus.subscribe":
|
||||||
handleSubscribe(conn, req, m, clientID)
|
handleSubscribe(ctx, conn, req, m, clientID)
|
||||||
case "dbus.unsubscribe":
|
case "dbus.unsubscribe":
|
||||||
handleUnsubscribe(conn, req, m)
|
handleUnsubscribe(conn, req, m)
|
||||||
default:
|
default:
|
||||||
@@ -200,7 +201,7 @@ func handleListNames(conn *ipc.ConnWriter, req ipc.Request, m *Manager) {
|
|||||||
conn.Respond(req.ID, result)
|
conn.Respond(req.ID, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSubscribe(conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID string) {
|
func handleSubscribe(ctx context.Context, conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID string) {
|
||||||
if id := params.StringOpt(req.Params, "clientId", ""); id != "" {
|
if id := params.StringOpt(req.Params, "clientId", ""); id != "" {
|
||||||
clientID = id
|
clientID = id
|
||||||
}
|
}
|
||||||
@@ -216,12 +217,13 @@ func handleSubscribe(conn *ipc.ConnWriter, req ipc.Request, m *Manager, clientID
|
|||||||
iface := params.StringOpt(req.Params, "interface", "")
|
iface := params.StringOpt(req.Params, "interface", "")
|
||||||
member := params.StringOpt(req.Params, "member", "")
|
member := params.StringOpt(req.Params, "member", "")
|
||||||
|
|
||||||
result, err := m.Subscribe(clientID, bus, sender, path, iface, member)
|
result, err := m.Subscribe(conn, clientID, bus, sender, path, iface, member)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conn.RespondError(req.ID, err.Error())
|
conn.RespondError(req.ID, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.ReleaseOnClose(ctx, conn)
|
||||||
conn.Respond(req.ID, result)
|
conn.Respond(req.ID, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package dbus
|
package dbus
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/duxlabs-dev/dux-go/dbusutil"
|
"github.com/duxlabs-dev/dux-go/dbusutil"
|
||||||
|
"github.com/duxlabs-dev/dux-go/ipc"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||||
"github.com/godbus/dbus/v5"
|
"github.com/godbus/dbus/v5"
|
||||||
)
|
)
|
||||||
@@ -188,7 +190,7 @@ func (m *Manager) ListNames(bus string) (*ListNamesResult, error) {
|
|||||||
return &ListNamesResult{Names: names}, nil
|
return &ListNamesResult{Names: names}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Subscribe(clientID, bus, sender, path, iface, member string) (*SubscribeResult, error) {
|
func (m *Manager) Subscribe(owner *ipc.ConnWriter, clientID, bus, sender, path, iface, member string) (*SubscribeResult, error) {
|
||||||
conn, err := m.getConn(bus)
|
conn, err := m.getConn(bus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -223,6 +225,7 @@ func (m *Manager) Subscribe(clientID, bus, sender, path, iface, member string) (
|
|||||||
Interface: iface,
|
Interface: iface,
|
||||||
Member: member,
|
Member: member,
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
|
Owner: owner,
|
||||||
}
|
}
|
||||||
m.subscriptions.Store(subID, sub)
|
m.subscriptions.Store(subID, sub)
|
||||||
|
|
||||||
@@ -267,10 +270,23 @@ func (m *Manager) Unsubscribe(subID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) UnsubscribeClient(clientID string) {
|
// Match rules live as long as the request connection that added them, not the
|
||||||
|
// subscribe socket, which the shell re-dials whenever its service list changes.
|
||||||
|
func (m *Manager) ReleaseOnClose(ctx context.Context, owner *ipc.ConnWriter) {
|
||||||
|
if _, loaded := m.owners.LoadOrStore(owner, struct{}{}); loaded {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
m.owners.Delete(owner)
|
||||||
|
m.unsubscribeOwner(owner)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) unsubscribeOwner(owner *ipc.ConnWriter) {
|
||||||
var toDelete []string
|
var toDelete []string
|
||||||
m.subscriptions.Range(func(subID string, sub *signalSubscription) bool {
|
m.subscriptions.Range(func(subID string, sub *signalSubscription) bool {
|
||||||
if sub.ClientID == clientID {
|
if sub.Owner == owner {
|
||||||
toDelete = append(toDelete, subID)
|
toDelete = append(toDelete, subID)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -281,20 +297,23 @@ func (m *Manager) UnsubscribeClient(clientID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A re-dialed subscribe socket can register before the old one tears down, so
|
||||||
|
// the old release must only drop the channel it created. Channels are never
|
||||||
|
// closed here because dispatchSignal may be mid-send on another goroutine.
|
||||||
func (m *Manager) SubscribeSignals(clientID string) chan SignalEvent {
|
func (m *Manager) SubscribeSignals(clientID string) chan SignalEvent {
|
||||||
ch := make(chan SignalEvent, 64)
|
ch := make(chan SignalEvent, 64)
|
||||||
existing, loaded := m.signalSubscribers.LoadOrStore(clientID, ch)
|
m.subscribersMu.Lock()
|
||||||
if loaded {
|
m.signalSubscribers.Store(clientID, ch)
|
||||||
return existing
|
m.subscribersMu.Unlock()
|
||||||
}
|
|
||||||
return ch
|
return ch
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) UnsubscribeSignals(clientID string) {
|
func (m *Manager) UnsubscribeSignals(clientID string, ch chan SignalEvent) {
|
||||||
if ch, ok := m.signalSubscribers.LoadAndDelete(clientID); ok {
|
m.subscribersMu.Lock()
|
||||||
close(ch)
|
defer m.subscribersMu.Unlock()
|
||||||
|
if current, ok := m.signalSubscribers.Load(clientID); ok && current == ch {
|
||||||
|
m.signalSubscribers.Delete(clientID)
|
||||||
}
|
}
|
||||||
m.UnsubscribeClient(clientID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) processSystemSignals() {
|
func (m *Manager) processSystemSignals() {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package dbus
|
package dbus
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/duxlabs-dev/dux-go/ipc"
|
||||||
"github.com/duxlabs-dev/dux-go/syncmap"
|
"github.com/duxlabs-dev/dux-go/syncmap"
|
||||||
"github.com/godbus/dbus/v5"
|
"github.com/godbus/dbus/v5"
|
||||||
)
|
)
|
||||||
@@ -12,7 +15,9 @@ type Manager struct {
|
|||||||
sessionSignals chan *dbus.Signal
|
sessionSignals chan *dbus.Signal
|
||||||
|
|
||||||
subscriptions syncmap.Map[string, *signalSubscription]
|
subscriptions syncmap.Map[string, *signalSubscription]
|
||||||
|
subscribersMu sync.Mutex
|
||||||
signalSubscribers syncmap.Map[string, chan SignalEvent]
|
signalSubscribers syncmap.Map[string, chan SignalEvent]
|
||||||
|
owners syncmap.Map[*ipc.ConnWriter, struct{}]
|
||||||
}
|
}
|
||||||
|
|
||||||
type signalSubscription struct {
|
type signalSubscription struct {
|
||||||
@@ -22,6 +27,7 @@ type signalSubscription struct {
|
|||||||
Interface string
|
Interface string
|
||||||
Member string
|
Member string
|
||||||
ClientID string
|
ClientID string
|
||||||
|
Owner *ipc.ConnWriter
|
||||||
}
|
}
|
||||||
|
|
||||||
type SignalEvent struct {
|
type SignalEvent struct {
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
package loginctl
|
package loginctl
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errLockBeforeSuspendManaged = errors.New("lock before suspend is managed by administrator policy")
|
||||||
|
errSleepInhibitorManaged = errors.New("the sleep inhibitor is managed by administrator policy")
|
||||||
|
)
|
||||||
|
|
||||||
func (m *Manager) Lock() error {
|
func (m *Manager) Lock() error {
|
||||||
if m.sessionObj == nil {
|
if m.sessionObj == nil {
|
||||||
return fmt.Errorf("session object not available")
|
return fmt.Errorf("session object not available")
|
||||||
@@ -85,8 +91,21 @@ func (m *Manager) Terminate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) SetLockBeforeSuspend(enabled bool) {
|
// applyLockPolicy starts from the managed settings, before the shell connects.
|
||||||
|
func (m *Manager) applyLockPolicy() {
|
||||||
|
if managed := m.lockPolicy.LockBeforeSuspend; managed != nil {
|
||||||
|
m.lockBeforeSuspend.Store(*managed)
|
||||||
|
}
|
||||||
|
enabled, managed := m.lockPolicy.sleepInhibitor()
|
||||||
|
m.sleepInhibitorEnabled.Store(enabled || !managed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) SetLockBeforeSuspend(enabled bool) error {
|
||||||
|
if managed := m.lockPolicy.LockBeforeSuspend; managed != nil && *managed != enabled {
|
||||||
|
return errLockBeforeSuspendManaged
|
||||||
|
}
|
||||||
m.lockBeforeSuspend.Store(enabled)
|
m.lockBeforeSuspend.Store(enabled)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) markLockerReady() {
|
func (m *Manager) markLockerReady() {
|
||||||
@@ -107,7 +126,10 @@ func (m *Manager) markLockerReady() {
|
|||||||
m.prelockedReady.Store(true)
|
m.prelockedReady.Store(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) SetSleepInhibitorEnabled(enabled bool) {
|
func (m *Manager) SetSleepInhibitorEnabled(enabled bool) error {
|
||||||
|
if managedValue, managed := m.lockPolicy.sleepInhibitor(); managed && managedValue != enabled {
|
||||||
|
return errSleepInhibitorManaged
|
||||||
|
}
|
||||||
m.sleepInhibitorEnabled.Store(enabled)
|
m.sleepInhibitorEnabled.Store(enabled)
|
||||||
if enabled {
|
if enabled {
|
||||||
// Re-acquire inhibitor if enabled
|
// Re-acquire inhibitor if enabled
|
||||||
@@ -116,4 +138,5 @@ func (m *Manager) SetSleepInhibitorEnabled(enabled bool) {
|
|||||||
// Release inhibitor if disabled
|
// Release inhibitor if disabled
|
||||||
m.releaseSleepInhibitor()
|
m.releaseSleepInhibitor()
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,10 @@ func handleSetLockBeforeSuspend(conn *ipc.ConnWriter, req ipc.Request, manager *
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
manager.SetLockBeforeSuspend(enabled)
|
if err := manager.SetLockBeforeSuspend(enabled); err != nil {
|
||||||
|
conn.RespondError(req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "lock before suspend set"})
|
conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "lock before suspend set"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +114,10 @@ func handleSetSleepInhibitorEnabled(conn *ipc.ConnWriter, req ipc.Request, manag
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
manager.SetSleepInhibitorEnabled(enabled)
|
if err := manager.SetSleepInhibitorEnabled(enabled); err != nil {
|
||||||
|
conn.RespondError(req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "sleep inhibitor setting updated"})
|
conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "sleep inhibitor setting updated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -424,3 +425,85 @@ func TestHandleRequest(t *testing.T) {
|
|||||||
assert.Empty(t, resp.Error)
|
assert.Empty(t, resp.Error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleSetLockBeforeSuspend(t *testing.T) {
|
||||||
|
on, off := true, false
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
managed *bool
|
||||||
|
request bool
|
||||||
|
want bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "unmanaged", request: true, want: true},
|
||||||
|
{name: "managed, same value", managed: &on, request: true, want: true},
|
||||||
|
{name: "managed on, request off", managed: &on, request: false, want: true, wantErr: true},
|
||||||
|
{name: "managed off, request on", managed: &off, request: true, want: false, wantErr: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
manager := &Manager{lockPolicy: LockPolicy{LockBeforeSuspend: tt.managed}}
|
||||||
|
manager.applyLockPolicy()
|
||||||
|
|
||||||
|
mc := newMockNetConn()
|
||||||
|
conn := ipc.NewConnWriter(mc)
|
||||||
|
HandleRequest(conn, ipc.Request{ID: 7, Method: "loginctl.setLockBeforeSuspend", Params: map[string]any{"enabled": tt.request}}, manager)
|
||||||
|
|
||||||
|
var resp ipc.Response[models.SuccessResult]
|
||||||
|
require.NoError(t, json.NewDecoder(mc.writeBuf).Decode(&resp))
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Equal(t, "lock before suspend is managed by administrator policy", resp.Error)
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, resp.Error)
|
||||||
|
}
|
||||||
|
assert.Equal(t, tt.want, manager.lockBeforeSuspend.Load())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSetSleepInhibitorEnabled(t *testing.T) {
|
||||||
|
on, off := true, false
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
policy LockPolicy
|
||||||
|
request bool
|
||||||
|
want bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "unmanaged", request: false, want: false},
|
||||||
|
{name: "both managed on, request off", policy: LockPolicy{LockBeforeSuspend: &on, LoginctlLockIntegration: &on}, request: false, want: true, wantErr: true},
|
||||||
|
{name: "both managed on, request on", policy: LockPolicy{LockBeforeSuspend: &on, LoginctlLockIntegration: &on}, request: true, want: true},
|
||||||
|
{name: "lock before suspend managed off", policy: LockPolicy{LockBeforeSuspend: &off}, request: true, want: false, wantErr: true},
|
||||||
|
{name: "integration managed off", policy: LockPolicy{LoginctlLockIntegration: &off}, request: true, want: false, wantErr: true},
|
||||||
|
{name: "lock before suspend managed on, integration left to the user", policy: LockPolicy{LockBeforeSuspend: &on}, request: false, want: false},
|
||||||
|
{name: "integration managed on, lock before suspend left to the user", policy: LockPolicy{LoginctlLockIntegration: &on}, request: false, want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
manager := &Manager{lockPolicy: tt.policy}
|
||||||
|
manager.applyLockPolicy()
|
||||||
|
inhibit, err := os.CreateTemp(t.TempDir(), "inhibit")
|
||||||
|
require.NoError(t, err)
|
||||||
|
held := manager.sleepInhibitorEnabled.Load()
|
||||||
|
if held {
|
||||||
|
manager.inhibitFile = inhibit
|
||||||
|
}
|
||||||
|
|
||||||
|
mc := newMockNetConn()
|
||||||
|
conn := ipc.NewConnWriter(mc)
|
||||||
|
HandleRequest(conn, ipc.Request{ID: 7, Method: "loginctl.setSleepInhibitorEnabled", Params: map[string]any{"enabled": tt.request}}, manager)
|
||||||
|
|
||||||
|
var resp ipc.Response[models.SuccessResult]
|
||||||
|
require.NoError(t, json.NewDecoder(mc.writeBuf).Decode(&resp))
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Equal(t, "the sleep inhibitor is managed by administrator policy", resp.Error)
|
||||||
|
assert.Equal(t, held, manager.inhibitFile != nil, "a refused request leaves the inhibitor as it was")
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, resp.Error)
|
||||||
|
}
|
||||||
|
assert.Equal(t, tt.want, manager.sleepInhibitorEnabled.Load())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"github.com/godbus/dbus/v5"
|
"github.com/godbus/dbus/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewManager() (*Manager, error) {
|
func NewManager(keepLockOnUnlock bool, lockPolicy LockPolicy) (*Manager, error) {
|
||||||
conn, err := dbus.SystemBus()
|
conn, err := dbus.SystemBus()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to connect to system bus: %w", err)
|
return nil, fmt.Errorf("failed to connect to system bus: %w", err)
|
||||||
@@ -25,8 +25,11 @@ func NewManager() (*Manager, error) {
|
|||||||
conn: conn,
|
conn: conn,
|
||||||
dirty: make(chan struct{}, 1),
|
dirty: make(chan struct{}, 1),
|
||||||
signals: make(chan *dbus.Signal, 256),
|
signals: make(chan *dbus.Signal, 256),
|
||||||
|
|
||||||
|
keepLockOnUnlock: keepLockOnUnlock,
|
||||||
|
lockPolicy: lockPolicy,
|
||||||
}
|
}
|
||||||
m.sleepInhibitorEnabled.Store(true)
|
m.applyLockPolicy()
|
||||||
|
|
||||||
if err := m.initialize(); err != nil {
|
if err := m.initialize(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -126,3 +126,28 @@ func TestStateChangedMeaningfully(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestManager_ApplyLockPolicy(t *testing.T) {
|
||||||
|
on, off := true, false
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
policy LockPolicy
|
||||||
|
lockBeforeSuspend bool
|
||||||
|
sleepInhibitorEnabled bool
|
||||||
|
}{
|
||||||
|
{name: "unmanaged", sleepInhibitorEnabled: true},
|
||||||
|
{name: "lock before suspend managed on", policy: LockPolicy{LockBeforeSuspend: &on}, lockBeforeSuspend: true, sleepInhibitorEnabled: true},
|
||||||
|
{name: "both managed on", policy: LockPolicy{LockBeforeSuspend: &on, LoginctlLockIntegration: &on}, lockBeforeSuspend: true, sleepInhibitorEnabled: true},
|
||||||
|
{name: "lock before suspend managed off", policy: LockPolicy{LockBeforeSuspend: &off}},
|
||||||
|
{name: "integration managed off", policy: LockPolicy{LockBeforeSuspend: &on, LoginctlLockIntegration: &off}, lockBeforeSuspend: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
manager := &Manager{lockPolicy: tt.policy}
|
||||||
|
manager.applyLockPolicy()
|
||||||
|
assert.Equal(t, tt.lockBeforeSuspend, manager.lockBeforeSuspend.Load())
|
||||||
|
assert.Equal(t, tt.sleepInhibitorEnabled, manager.sleepInhibitorEnabled.Load())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,13 +30,22 @@ func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case dbusSessionInterface + ".Unlock":
|
case dbusSessionInterface + ".Unlock":
|
||||||
lockedHint := m.sessionLockedHint()
|
if m.keepLockOnUnlock {
|
||||||
m.stateMutex.Lock()
|
// Only the locker clears LockedHint, so take both from logind, and
|
||||||
m.state.Locked = false
|
// keep the cached state when logind cannot be read.
|
||||||
m.state.LockedHint = lockedHint
|
if m.sessionObj != nil && m.updateSessionState() == nil && !m.snapshotState().Locked {
|
||||||
m.stateMutex.Unlock()
|
m.prelockedReady.Store(false)
|
||||||
m.notifySubscribers()
|
}
|
||||||
m.prelockedReady.Store(false)
|
m.notifySubscribers()
|
||||||
|
} else {
|
||||||
|
lockedHint := m.sessionLockedHint()
|
||||||
|
m.stateMutex.Lock()
|
||||||
|
m.state.Locked = false
|
||||||
|
m.state.LockedHint = lockedHint
|
||||||
|
m.stateMutex.Unlock()
|
||||||
|
m.notifySubscribers()
|
||||||
|
m.prelockedReady.Store(false)
|
||||||
|
}
|
||||||
|
|
||||||
// Cancel the lock timer if it's still running
|
// Cancel the lock timer if it's still running
|
||||||
m.lockTimerMu.Lock()
|
m.lockTimerMu.Lock()
|
||||||
|
|||||||
@@ -37,42 +37,58 @@ func TestManager_HandleDBusSignal_Lock(t *testing.T) {
|
|||||||
|
|
||||||
func TestManager_HandleDBusSignal_Unlock(t *testing.T) {
|
func TestManager_HandleDBusSignal_Unlock(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
lockedHint bool
|
keepLock bool
|
||||||
|
call *dbus.Call // logind's GetAll answer; nil means no session object
|
||||||
|
wantLocked bool
|
||||||
|
wantLockedHint bool
|
||||||
}{
|
}{
|
||||||
{name: "logind cleared LockedHint", lockedHint: false},
|
{name: "logind cleared LockedHint", call: lockedHintCall(false)},
|
||||||
{name: "logind still reports LockedHint", lockedHint: true},
|
{name: "logind still reports LockedHint", call: lockedHintCall(true), wantLockedHint: true},
|
||||||
|
{name: "logind unreachable", call: &dbus.Call{Err: assert.AnError}},
|
||||||
|
{name: "policy, LockedHint still set", keepLock: true, call: lockedHintCall(true), wantLocked: true, wantLockedHint: true},
|
||||||
|
{name: "policy, LockedHint cleared", keepLock: true, call: lockedHintCall(false)},
|
||||||
|
{name: "policy, logind unreachable", keepLock: true, call: &dbus.Call{Err: assert.AnError}, wantLocked: true, wantLockedHint: true},
|
||||||
|
{name: "policy, no session object", keepLock: true, wantLocked: true, wantLockedHint: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
sessionObj := mockdbus.NewMockBusObject(t)
|
|
||||||
sessionObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.login1.Session").Return(&dbus.Call{
|
|
||||||
Body: []any{map[string]dbus.Variant{"LockedHint": dbus.MakeVariant(tt.lockedHint)}},
|
|
||||||
})
|
|
||||||
|
|
||||||
manager := &Manager{
|
manager := &Manager{
|
||||||
state: &SessionState{
|
state: &SessionState{
|
||||||
Locked: true,
|
Locked: true,
|
||||||
LockedHint: true,
|
LockedHint: true,
|
||||||
},
|
},
|
||||||
stateMutex: sync.RWMutex{},
|
stateMutex: sync.RWMutex{},
|
||||||
dirty: make(chan struct{}, 1),
|
dirty: make(chan struct{}, 1),
|
||||||
sessionObj: sessionObj,
|
keepLockOnUnlock: tt.keepLock,
|
||||||
}
|
}
|
||||||
|
if tt.call != nil {
|
||||||
|
sessionObj := mockdbus.NewMockBusObject(t)
|
||||||
|
sessionObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.login1.Session").Return(tt.call)
|
||||||
|
manager.sessionObj = sessionObj
|
||||||
|
}
|
||||||
|
manager.markLockerReady()
|
||||||
|
|
||||||
manager.handleDBusSignal(&dbus.Signal{
|
manager.handleDBusSignal(&dbus.Signal{
|
||||||
Name: "org.freedesktop.login1.Session.Unlock",
|
Name: "org.freedesktop.login1.Session.Unlock",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantLocked, manager.prelockedReady.Load())
|
||||||
manager.stateMutex.RLock()
|
manager.stateMutex.RLock()
|
||||||
defer manager.stateMutex.RUnlock()
|
defer manager.stateMutex.RUnlock()
|
||||||
assert.False(t, manager.state.Locked)
|
assert.Equal(t, tt.wantLocked, manager.state.Locked)
|
||||||
assert.Equal(t, tt.lockedHint, manager.state.LockedHint)
|
assert.Equal(t, tt.wantLockedHint, manager.state.LockedHint)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lockedHintCall(lockedHint bool) *dbus.Call {
|
||||||
|
return &dbus.Call{Body: []any{map[string]dbus.Variant{
|
||||||
|
"LockedHint": dbus.MakeVariant(lockedHint),
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
|
||||||
func TestManager_HandleDBusSignal_PrepareForSleep(t *testing.T) {
|
func TestManager_HandleDBusSignal_PrepareForSleep(t *testing.T) {
|
||||||
t.Run("preparing for sleep - true", func(t *testing.T) {
|
t.Run("preparing for sleep - true", func(t *testing.T) {
|
||||||
manager := &Manager{
|
manager := &Manager{
|
||||||
|
|||||||
@@ -69,4 +69,26 @@ type Manager struct {
|
|||||||
lockTimer *time.Timer
|
lockTimer *time.Timer
|
||||||
sleepInhibitorEnabled atomic.Bool
|
sleepInhibitorEnabled atomic.Bool
|
||||||
fallbackDelay time.Duration
|
fallbackDelay time.Duration
|
||||||
|
keepLockOnUnlock bool
|
||||||
|
lockPolicy LockPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
// LockPolicy holds the lock settings an administrator policy manages; nil
|
||||||
|
// leaves a setting to the shell.
|
||||||
|
type LockPolicy struct {
|
||||||
|
LockBeforeSuspend *bool
|
||||||
|
LoginctlLockIntegration *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// sleepInhibitor returns the inhibitor state the shell asks for,
|
||||||
|
// loginctlLockIntegration && lockBeforeSuspend, when the managed settings
|
||||||
|
// decide it on their own.
|
||||||
|
func (p LockPolicy) sleepInhibitor() (enabled, managed bool) {
|
||||||
|
switch {
|
||||||
|
case p.LockBeforeSuspend != nil && !*p.LockBeforeSuspend, p.LoginctlLockIntegration != nil && !*p.LoginctlLockIntegration:
|
||||||
|
return false, true
|
||||||
|
case p.LockBeforeSuspend != nil && p.LoginctlLockIntegration != nil:
|
||||||
|
return true, true
|
||||||
|
}
|
||||||
|
return false, false
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -278,7 +279,7 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
|
|
||||||
a.backend.cachedVPNCredsMu.Lock()
|
a.backend.cachedVPNCredsMu.Lock()
|
||||||
cached := a.backend.cachedVPNCreds
|
cached := a.backend.cachedVPNCreds
|
||||||
if cached != nil && cached.ConnectionUUID == connUuid {
|
if cached != nil && cached.ConnectionUUID == connUuid && slices.Contains(fields, "password") {
|
||||||
a.backend.cachedVPNCreds = nil
|
a.backend.cachedVPNCreds = nil
|
||||||
a.backend.cachedVPNCredsMu.Unlock()
|
a.backend.cachedVPNCredsMu.Unlock()
|
||||||
|
|
||||||
@@ -451,7 +452,7 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
VpnService: vpnSvc,
|
VpnService: vpnSvc,
|
||||||
SettingName: settingName,
|
SettingName: settingName,
|
||||||
Fields: fields,
|
Fields: fields,
|
||||||
FieldsInfo: buildFieldsInfo(settingName, fields, vpnSvc),
|
FieldsInfo: buildFieldsInfo(settingName, fields, hints, vpnSvc),
|
||||||
Hints: hints,
|
Hints: hints,
|
||||||
Reason: reason,
|
Reason: reason,
|
||||||
ConnectionId: connId,
|
ConnectionId: connId,
|
||||||
@@ -577,13 +578,13 @@ func (a *SecretAgent) GetSecrets(
|
|||||||
default:
|
default:
|
||||||
out[settingName] = sec
|
out[settingName] = sec
|
||||||
}
|
}
|
||||||
if settingName == "vpn" && a.backend != nil && !isPKCS11 && (vpnUsername != "" || reply.Save) {
|
secrets := make(map[string]string)
|
||||||
secrets := make(map[string]string)
|
for k, v := range reply.Secrets {
|
||||||
for k, v := range reply.Secrets {
|
if k != "username" && k != "challenge-response" {
|
||||||
if k != "username" {
|
secrets[k] = v
|
||||||
secrets[k] = v
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if settingName == "vpn" && a.backend != nil && !isPKCS11 && (vpnUsername != "" || (reply.Save && len(secrets) > 0)) {
|
||||||
a.backend.pendingVPNSaveMu.Lock()
|
a.backend.pendingVPNSaveMu.Lock()
|
||||||
a.backend.pendingVPNSave = &pendingVPNCredentials{
|
a.backend.pendingVPNSave = &pendingVPNCredentials{
|
||||||
ConnectionPath: string(path),
|
ConnectionPath: string(path),
|
||||||
@@ -835,13 +836,46 @@ func fieldsNeeded(setting string, hints []string, conn map[string]nmVariantMap)
|
|||||||
return hints
|
return hints
|
||||||
}
|
}
|
||||||
return infer8021xFields(conn)
|
return infer8021xFields(conn)
|
||||||
case "vpn", "wireguard":
|
case "vpn":
|
||||||
|
return parseVPNHints(hints).fields
|
||||||
|
case "wireguard":
|
||||||
return hints
|
return hints
|
||||||
default:
|
default:
|
||||||
return []string{}
|
return []string{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
vpnHintMessagePrefix = "x-vpn-message:"
|
||||||
|
vpnHintChallengePrefix = "x-dynamic-challenge:"
|
||||||
|
vpnHintChallengeEchoPrefix = "x-dynamic-challenge-echo:"
|
||||||
|
)
|
||||||
|
|
||||||
|
type vpnPromptHints struct {
|
||||||
|
fields []string
|
||||||
|
echo map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NM-openvpn asks for a CRV1 challenge answer as x-dynamic-challenge[-echo]:<secret>
|
||||||
|
// and NM appends the plugin's prompt text as x-vpn-message:<text>, which the modal shows.
|
||||||
|
func parseVPNHints(hints []string) vpnPromptHints {
|
||||||
|
parsed := vpnPromptHints{echo: map[string]bool{}}
|
||||||
|
for _, hint := range hints {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(hint, vpnHintMessagePrefix):
|
||||||
|
case strings.HasPrefix(hint, vpnHintChallengeEchoPrefix):
|
||||||
|
field := strings.TrimPrefix(hint, vpnHintChallengeEchoPrefix)
|
||||||
|
parsed.echo[field] = true
|
||||||
|
parsed.fields = append(parsed.fields, field)
|
||||||
|
case strings.HasPrefix(hint, vpnHintChallengePrefix):
|
||||||
|
parsed.fields = append(parsed.fields, strings.TrimPrefix(hint, vpnHintChallengePrefix))
|
||||||
|
default:
|
||||||
|
parsed.fields = append(parsed.fields, hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
func infer8021xFields(conn map[string]nmVariantMap) []string {
|
func infer8021xFields(conn map[string]nmVariantMap) []string {
|
||||||
dot1x, ok := conn["802-1x"]
|
dot1x, ok := conn["802-1x"]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -877,7 +911,8 @@ func infer8021xFields(conn map[string]nmVariantMap) []string {
|
|||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildFieldsInfo(setting string, fields []string, vpnService string) []FieldInfo {
|
func buildFieldsInfo(setting string, fields []string, hints []string, vpnService string) []FieldInfo {
|
||||||
|
echo := parseVPNHints(hints).echo
|
||||||
result := make([]FieldInfo, 0, len(fields))
|
result := make([]FieldInfo, 0, len(fields))
|
||||||
for _, f := range fields {
|
for _, f := range fields {
|
||||||
info := FieldInfo{Name: f}
|
info := FieldInfo{Name: f}
|
||||||
@@ -899,6 +934,9 @@ func buildFieldsInfo(setting string, fields []string, vpnService string) []Field
|
|||||||
}
|
}
|
||||||
case "vpn":
|
case "vpn":
|
||||||
info.Label, info.IsSecret = vpnFieldMeta(f, vpnService)
|
info.Label, info.IsSecret = vpnFieldMeta(f, vpnService)
|
||||||
|
if echo[f] {
|
||||||
|
info.IsSecret = false
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
info.Label = f
|
info.Label = f
|
||||||
info.IsSecret = true
|
info.IsSecret = true
|
||||||
@@ -1052,6 +1090,8 @@ func vpnFieldMeta(field, _ string) (label string, isSecret bool) {
|
|||||||
return "IPSec Pre-Shared Key", true
|
return "IPSec Pre-Shared Key", true
|
||||||
case "cert-pass":
|
case "cert-pass":
|
||||||
return "Certificate Password", true
|
return "Certificate Password", true
|
||||||
|
case "challenge-response":
|
||||||
|
return "Challenge Response", true
|
||||||
case "http-proxy-password":
|
case "http-proxy-password":
|
||||||
return "HTTP Proxy Password", true
|
return "HTTP Proxy Password", true
|
||||||
case "username":
|
case "username":
|
||||||
|
|||||||
@@ -308,6 +308,47 @@ func TestSecretAgent_GetSecrets_NoInteractionFlag(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "NoSecrets")
|
assert.Contains(t, err.Error(), "NoSecrets")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSecretAgent_GetSecrets_OpenVPNDynamicChallenge(t *testing.T) {
|
||||||
|
conn := map[string]nmVariantMap{
|
||||||
|
"connection": {
|
||||||
|
"id": dbus.MakeVariant("Work VPN"),
|
||||||
|
"type": dbus.MakeVariant("vpn"),
|
||||||
|
},
|
||||||
|
"vpn": {
|
||||||
|
"service-type": dbus.MakeVariant("org.freedesktop.NetworkManager.openvpn"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hint string
|
||||||
|
isSecret bool
|
||||||
|
}{
|
||||||
|
{"noecho", "x-dynamic-challenge:challenge-response", true},
|
||||||
|
{"echo", "x-dynamic-challenge-echo:challenge-response", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
broker := &fakePromptBroker{
|
||||||
|
asked: make(chan PromptRequest, 1),
|
||||||
|
reply: PromptReply{Secrets: map[string]string{"challenge-response": "123456"}},
|
||||||
|
}
|
||||||
|
agent := &SecretAgent{prompts: broker}
|
||||||
|
hints := []string{tt.hint, "x-vpn-message:Enter your OTP"}
|
||||||
|
|
||||||
|
out, err := agent.GetSecrets(conn, "/test/path", "vpn", hints, nmSecretAgentFlagAllowInteraction)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
req := <-broker.asked
|
||||||
|
assert.Equal(t, []string{"challenge-response"}, req.Fields)
|
||||||
|
assert.Equal(t, []FieldInfo{{Name: "challenge-response", Label: "Challenge Response", IsSecret: tt.isSecret}}, req.FieldsInfo)
|
||||||
|
|
||||||
|
secrets, _ := out["vpn"]["secrets"].Value().(map[string]string)
|
||||||
|
assert.Equal(t, map[string]string{"challenge-response": "123456"}, secrets)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildWiFiSecretsResponse(t *testing.T) {
|
func TestBuildWiFiSecretsResponse(t *testing.T) {
|
||||||
t.Run("wpa-psk returns psk", func(t *testing.T) {
|
t.Run("wpa-psk returns psk", func(t *testing.T) {
|
||||||
out := buildWiFiSecretsResponse("802-11-wireless-security", map[string]string{"psk": "hunter2"})
|
out := buildWiFiSecretsResponse("802-11-wireless-security", map[string]string{"psk": "hunter2"})
|
||||||
|
|||||||
@@ -258,16 +258,11 @@ func (b *NetworkManagerBackend) listEthernetConnections() ([]WiredConnection, er
|
|||||||
return nil, fmt.Errorf("no ethernet device available")
|
return nil, fmt.Errorf("no ethernet device available")
|
||||||
}
|
}
|
||||||
|
|
||||||
s := b.settings
|
settingsMgr, err := b.networkManagerSettings()
|
||||||
if s == nil {
|
if err != nil {
|
||||||
s, err := gonetworkmanager.NewSettings()
|
return nil, err
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get settings: %w", err)
|
|
||||||
}
|
|
||||||
b.settings = s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
settingsMgr := s.(gonetworkmanager.Settings)
|
|
||||||
connections, err := settingsMgr.ListConnections()
|
connections, err := settingsMgr.ListConnections()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get connections: %w", err)
|
return nil, fmt.Errorf("failed to get connections: %w", err)
|
||||||
|
|||||||
@@ -305,6 +305,9 @@ func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) {
|
|||||||
}
|
}
|
||||||
b.updateHotspotState()
|
b.updateHotspotState()
|
||||||
b.listCellularConnections()
|
b.listCellularConnections()
|
||||||
|
// Device state does not change when an inactive wired profile is
|
||||||
|
// added or deleted, so the profile list must be refreshed here too.
|
||||||
|
b.listEthernetConnections()
|
||||||
if b.onStateChange != nil {
|
if b.onStateChange != nil {
|
||||||
b.onStateChange()
|
b.onStateChange()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,3 +130,57 @@ func TestNetworkManagerBackend_HandleWiFiChange_CoalescesAccessPointBurst(t *tes
|
|||||||
return stateChangeCalls.Load() == 1
|
return stateChangeCalls.Load() == 1
|
||||||
}, 4*wifiNetworksUpdateDelay, 10*time.Millisecond)
|
}, 4*wifiNetworksUpdateDelay, 10*time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNetworkManagerBackend_HandleDBusSignal_ConnectionRemovedRefreshesWiredConnections(t *testing.T) {
|
||||||
|
mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
|
||||||
|
mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil).Maybe()
|
||||||
|
|
||||||
|
remaining := mock_gonetworkmanager.NewMockConnection(t)
|
||||||
|
remaining.EXPECT().GetPath().Return(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings/1")).Maybe()
|
||||||
|
remaining.EXPECT().GetSettings().Return(gonetworkmanager.ConnectionSettings{
|
||||||
|
"connection": {
|
||||||
|
"id": "Wired connection 1",
|
||||||
|
"uuid": "uuid-1",
|
||||||
|
"type": "802-3-ethernet",
|
||||||
|
},
|
||||||
|
}, nil).Maybe()
|
||||||
|
|
||||||
|
mockSettings := mock_gonetworkmanager.NewMockSettings(t)
|
||||||
|
mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{remaining}, nil).Maybe()
|
||||||
|
|
||||||
|
backend, err := NewNetworkManagerBackend(mockNM)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
backend.settings = mockSettings
|
||||||
|
backend.ethernetDevice = mock_gonetworkmanager.NewMockDevice(t)
|
||||||
|
|
||||||
|
backend.stateMutex.Lock()
|
||||||
|
backend.state.WiredConnections = []WiredConnection{
|
||||||
|
{ID: "Wired connection 1", UUID: "uuid-1", Type: "802-3-ethernet"},
|
||||||
|
{ID: "Wired connection 2", UUID: "uuid-2", Type: "802-3-ethernet"},
|
||||||
|
}
|
||||||
|
backend.stateMutex.Unlock()
|
||||||
|
|
||||||
|
backend.handleDBusSignal(&dbus.Signal{
|
||||||
|
Name: "org.freedesktop.NetworkManager.Settings.ConnectionRemoved",
|
||||||
|
Body: []any{dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings/2")},
|
||||||
|
})
|
||||||
|
|
||||||
|
backend.stateMutex.RLock()
|
||||||
|
defer backend.stateMutex.RUnlock()
|
||||||
|
assert.Len(t, backend.state.WiredConnections, 1)
|
||||||
|
assert.Equal(t, "uuid-1", backend.state.WiredConnections[0].UUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNetworkManagerBackend_ListEthernetConnections_InvalidSettingsHandle(t *testing.T) {
|
||||||
|
mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
|
||||||
|
|
||||||
|
backend, err := NewNetworkManagerBackend(mockNM)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
backend.settings = "not a settings handle"
|
||||||
|
backend.ethernetDevice = mock_gonetworkmanager.NewMockDevice(t)
|
||||||
|
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
_, err = backend.listEthernetConnections()
|
||||||
|
})
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@ package network
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os/user"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"github.com/Wifx/gonetworkmanager/v2"
|
"github.com/Wifx/gonetworkmanager/v2"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||||
|
"github.com/godbus/dbus/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (b *NetworkManagerBackend) GetWiFiEnabled() (bool, error) {
|
func (b *NetworkManagerBackend) GetWiFiEnabled() (bool, error) {
|
||||||
@@ -768,6 +770,9 @@ func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionReque
|
|||||||
"type": "802-11-wireless",
|
"type": "802-11-wireless",
|
||||||
"autoconnect": true,
|
"autoconnect": true,
|
||||||
}
|
}
|
||||||
|
if !b.canModifySystemConnections() {
|
||||||
|
restrictConnectionToCurrentUser(settings["connection"])
|
||||||
|
}
|
||||||
|
|
||||||
settings["ipv4"] = map[string]any{"method": "auto"}
|
settings["ipv4"] = map[string]any{"method": "auto"}
|
||||||
settings["ipv6"] = map[string]any{"method": "auto"}
|
settings["ipv6"] = map[string]any{"method": "auto"}
|
||||||
@@ -934,6 +939,39 @@ func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionReque
|
|||||||
// updateConnectionCredentials applies user-supplied credentials to a saved
|
// updateConnectionCredentials applies user-supplied credentials to a saved
|
||||||
// profile before activation; without this a retype after a password change is
|
// profile before activation; without this a retype after a password change is
|
||||||
// silently ignored in favor of the stored (stale) secret.
|
// silently ignored in favor of the stored (stale) secret.
|
||||||
|
const nmPermissionModifySystem = dbusNMInterface + ".settings.modify.system"
|
||||||
|
|
||||||
|
// GetPermissions never prompts. Short of a silent yes, a shared profile prompts on
|
||||||
|
// creation and on every secret-agent request (NM re-checks the agent), so own it
|
||||||
|
// per user like gnome-control-center does.
|
||||||
|
func (b *NetworkManagerBackend) canModifySystemConnections() bool {
|
||||||
|
conn := b.dbusConn
|
||||||
|
if conn == nil {
|
||||||
|
var err error
|
||||||
|
conn, err = dbus.ConnectSystemBus()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
var permissions map[string]string
|
||||||
|
obj := conn.Object(dbusNMInterface, dbus.ObjectPath(dbusNMPath))
|
||||||
|
if err := obj.Call(dbusNMInterface+".GetPermissions", 0).Store(&permissions); err != nil {
|
||||||
|
log.Warnf("[createAndConnectWiFi] GetPermissions failed, creating a user-owned profile: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return permissions[nmPermissionModifySystem] == "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
func restrictConnectionToCurrentUser(connMeta map[string]any) {
|
||||||
|
current, err := user.Current()
|
||||||
|
if err != nil || current.Username == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connMeta["permissions"] = []string{"user:" + current.Username + ":"}
|
||||||
|
}
|
||||||
|
|
||||||
func updateConnectionCredentials(conn gonetworkmanager.Connection, req ConnectionRequest) error {
|
func updateConnectionCredentials(conn gonetworkmanager.Connection, req ConnectionRequest) error {
|
||||||
settings, err := conn.GetSettings()
|
settings, err := conn.GetSettings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -203,13 +203,13 @@ func newRequestMux(readiness *systemd.Readiness) *ipc.Mux {
|
|||||||
evdev.HandleRequest(conn, req, evdevManager)
|
evdev.HandleRequest(conn, req, evdevManager)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mux.HandlePrefix("dbus.", requestHandler(func(conn *ipc.ConnWriter, req ipc.Request) {
|
mux.HandlePrefix("dbus.", func(ctx context.Context, conn *ipc.ConnWriter, req ipc.Request, _ *ipc.Subscriber) {
|
||||||
if dbusManager == nil {
|
if dbusManager == nil {
|
||||||
conn.RespondError(req.ID, "dbus manager not initialized")
|
conn.RespondError(req.ID, "dbus manager not initialized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
serverDbus.HandleRequest(conn, req, dbusManager, dbusClientID)
|
serverDbus.HandleRequest(ctx, conn, req, dbusManager, dbusClientID)
|
||||||
}))
|
})
|
||||||
|
|
||||||
mux.HandlePrefix("clipboard.", requestHandler(func(conn *ipc.ConnWriter, req ipc.Request) {
|
mux.HandlePrefix("clipboard.", requestHandler(func(conn *ipc.ConnWriter, req ipc.Request) {
|
||||||
if clipboardManager == nil {
|
if clipboardManager == nil {
|
||||||
@@ -279,7 +279,11 @@ func handleClipboardSetConfig(conn *ipc.ConnWriter, req ipc.Request) {
|
|||||||
cfg.MaxPinned = int(v)
|
cfg.MaxPinned = int(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := clipboard.SaveConfig(cfg); err != nil {
|
save := clipboard.SaveConfig
|
||||||
|
if clipboardManager != nil {
|
||||||
|
save = clipboardManager.SetConfig
|
||||||
|
}
|
||||||
|
if err := save(cfg); err != nil {
|
||||||
conn.RespondError(req.ID, err.Error())
|
conn.RespondError(req.ID, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/duxlabs-dev/dux-go/ipc"
|
"github.com/duxlabs-dev/dux-go/ipc"
|
||||||
"github.com/duxlabs-dev/dux-go/syncmap"
|
"github.com/duxlabs-dev/dux-go/syncmap"
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/geolocation"
|
"github.com/duxlabs-dev/dux-shell/core/internal/geolocation"
|
||||||
"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/matugen"
|
"github.com/duxlabs-dev/dux-shell/core/internal/matugen"
|
||||||
@@ -37,6 +38,7 @@ import (
|
|||||||
"github.com/duxlabs-dev/dux-shell/core/internal/server/wlroutput"
|
"github.com/duxlabs-dev/dux-shell/core/internal/server/wlroutput"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/systemd"
|
"github.com/duxlabs-dev/dux-shell/core/internal/systemd"
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
||||||
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
const APIVersion = 38
|
const APIVersion = 38
|
||||||
@@ -108,7 +110,19 @@ func InitializeNetworkManager() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func InitializeLoginctlManager() error {
|
func InitializeLoginctlManager() error {
|
||||||
manager, err := loginctl.NewManager()
|
keepLockOnUnlock, err := clipolicy.LogindUnlockDisabled(afero.NewOsFs())
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("CLI policy unreadable, keeping locks through logind Unlock: %v", err)
|
||||||
|
keepLockOnUnlock = true
|
||||||
|
}
|
||||||
|
lockSettings, err := clipolicy.LockSettings(afero.NewOsFs())
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("CLI policy unreadable, ignoring its lock settings: %v", err)
|
||||||
|
}
|
||||||
|
manager, err := loginctl.NewManager(keepLockOnUnlock, loginctl.LockPolicy{
|
||||||
|
LockBeforeSuspend: clipolicy.LockSettingBool(lockSettings, "lockBeforeSuspend"),
|
||||||
|
LoginctlLockIntegration: clipolicy.LockSettingBool(lockSettings, "loginctlLockIntegration"),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("Failed to initialize loginctl manager: %v", err)
|
log.Warnf("Failed to initialize loginctl manager: %v", err)
|
||||||
return err
|
return err
|
||||||
@@ -781,7 +795,7 @@ func handleSubscribe(ctx context.Context, conn *ipc.ConnWriter, req ipc.Request)
|
|||||||
mgr := dbusManager
|
mgr := dbusManager
|
||||||
events := mgr.SubscribeSignals(dbusClient)
|
events := mgr.SubscribeSignals(dbusClient)
|
||||||
forwardSubscription(&wg, eventChan, stopChan, "dbus", events, func() {
|
forwardSubscription(&wg, eventChan, stopChan, "dbus", events, func() {
|
||||||
mgr.UnsubscribeSignals(dbusClient)
|
mgr.UnsubscribeSignals(dbusClient, events)
|
||||||
}, nil)
|
}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1119,7 +1133,7 @@ func (s *Server) Serve(printDocs bool) error {
|
|||||||
log.Info(" clipboard.paste - Get current clipboard text")
|
log.Info(" clipboard.paste - Get current clipboard text")
|
||||||
log.Info(" clipboard.search - Search history (params: query?, mimeType?, isImage?, limit?, offset?, before?, after?)")
|
log.Info(" clipboard.search - Search history (params: query?, mimeType?, isImage?, limit?, offset?, before?, after?)")
|
||||||
log.Info(" clipboard.getConfig - Get clipboard configuration")
|
log.Info(" clipboard.getConfig - Get clipboard configuration")
|
||||||
log.Info(" clipboard.setConfig - Set configuration (params: maxHistory?, maxEntrySize?, autoClearDays?, clearAtStartup?)")
|
log.Info(" clipboard.setConfig - Set configuration (params: maxHistory?, maxEntrySize?, autoClearDays?, clearAtStartup?, disabled?, maxPinned?)")
|
||||||
log.Info(" clipboard.subscribe - Subscribe to clipboard state changes (streaming)")
|
log.Info(" clipboard.subscribe - Subscribe to clipboard state changes (streaming)")
|
||||||
log.Info("Notify:")
|
log.Info("Notify:")
|
||||||
log.Info(" notify.watchAction - Open a file when a notification action fires (params: id, path)")
|
log.Info(" notify.watchAction - Open a file when a notification action fires (params: id, path)")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package sysupdate
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -21,18 +22,21 @@ func (flatpakBackend) RunsInTerminal() bool { return false }
|
|||||||
func (flatpakBackend) IsAvailable(_ context.Context) bool { return commandExists("flatpak") }
|
func (flatpakBackend) IsAvailable(_ context.Context) bool { return commandExists("flatpak") }
|
||||||
|
|
||||||
func (flatpakBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
func (flatpakBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
// Run `flatpak update`
|
// Without --no-deps an update needing an uninstalled runtime prompts first, and declining aborts before the table prints.
|
||||||
cmd := exec.CommandContext(ctx, "flatpak", "update")
|
cmd := exec.CommandContext(ctx, "flatpak", "update", "--no-deps")
|
||||||
cmd.Stdin = strings.NewReader("n\nn\n") // decline up to 2 installation prompts
|
cmd.Stdin = strings.NewReader("n\nn\n") // decline up to 2 installation prompts
|
||||||
out, err := cmd.Output()
|
out, err := cmd.Output()
|
||||||
if err != nil {
|
pkgs := parseFlatpakUpdateOutput(string(out), flatpakInstalled(ctx))
|
||||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 1 && len(out) > 0 {
|
// Declining the final prompt also exits non-zero, so only a failure without a table is an error.
|
||||||
} else if len(out) == 0 {
|
if err != nil && len(pkgs) == 0 {
|
||||||
return nil, err
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||||
|
if msg := lastNonEmptyLine(string(exitErr.Stderr)); msg != "" {
|
||||||
|
return nil, fmt.Errorf("flatpak update: %s", msg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
installed := flatpakInstalled(ctx)
|
return pkgs, nil
|
||||||
return parseFlatpakUpdateOutput(string(out), installed), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type flatpakInstalledEntry struct {
|
type flatpakInstalledEntry struct {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package sysupdate
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,3 +101,47 @@ func TestParseFlatpakUpdateOutput(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFlatpakCheckUpdates(t *testing.T) {
|
||||||
|
const runtimePrompt = `[ "$1" = list ] && exit 0
|
||||||
|
for a in "$@"; do
|
||||||
|
if [ "$a" = --no-deps ]; then
|
||||||
|
printf ' 1.\tVesktop\tdev.vencord.Vesktop\tstable\tu\tflathub\t< 130 MB\n\nProceed with these changes? [Y/n]: '
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
printf 'Required runtime for dev.vencord.Vesktop/x86_64/stable found in remote flathub\nDo you want to install it? [Y/n]: '
|
||||||
|
echo 'error: The application dev.vencord.Vesktop/x86_64/stable requires the runtime org.freedesktop.Platform/x86_64/26.08 which is not installed' >&2
|
||||||
|
exit 1`
|
||||||
|
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
script string
|
||||||
|
wantRef string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "update needing a new runtime", script: runtimePrompt, wantRef: "dev.vencord.Vesktop//stable"},
|
||||||
|
{
|
||||||
|
name: "failure without a table",
|
||||||
|
script: "[ \"$1\" = list ] && exit 0\necho 'Looking for updates…'\necho 'error: Unable to load summary from remote flathub' >&2\nexit 1",
|
||||||
|
wantErr: "Unable to load summary",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("PATH", dir)
|
||||||
|
writeUpdateExecutable(t, dir, "flatpak", tt.script)
|
||||||
|
|
||||||
|
pkgs, err := flatpakBackend{}.CheckUpdates(t.Context())
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Errorf("err = %v, want %q (a failed check must not read as up to date)", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil || len(pkgs) != 1 || pkgs[0].Ref != tt.wantRef {
|
||||||
|
t.Errorf("pkgs = %#v, err = %v, want %s", pkgs, err, tt.wantRef)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -183,13 +183,17 @@ func (m *Manager) Close() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zero turns scheduled checks off; manual and startup checks still run.
|
||||||
func (m *Manager) SetInterval(seconds int) {
|
func (m *Manager) SetInterval(seconds int) {
|
||||||
if seconds < minIntervalSeconds {
|
if seconds > 0 && seconds < minIntervalSeconds {
|
||||||
seconds = minIntervalSeconds
|
seconds = minIntervalSeconds
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.state.IntervalSeconds = seconds
|
m.state.IntervalSeconds = max(seconds, 0)
|
||||||
m.state.NextCheckUnix = m.nextCheckLocked()
|
m.state.NextCheckUnix = 0
|
||||||
|
if seconds > 0 {
|
||||||
|
m.state.NextCheckUnix = m.nextCheckLocked()
|
||||||
|
}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
m.wake()
|
m.wake()
|
||||||
m.markDirty()
|
m.markDirty()
|
||||||
@@ -284,7 +288,11 @@ func (m *Manager) wake() {
|
|||||||
func (m *Manager) scheduler() {
|
func (m *Manager) scheduler() {
|
||||||
defer m.schedulerWG.Done()
|
defer m.schedulerWG.Done()
|
||||||
for {
|
for {
|
||||||
if !m.held() {
|
m.mu.RLock()
|
||||||
|
interval := m.state.IntervalSeconds
|
||||||
|
next := m.state.NextCheckUnix
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if !m.held() || interval == 0 {
|
||||||
select {
|
select {
|
||||||
case <-m.stopChan:
|
case <-m.stopChan:
|
||||||
return
|
return
|
||||||
@@ -293,10 +301,6 @@ func (m *Manager) scheduler() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.RLock()
|
|
||||||
interval := m.state.IntervalSeconds
|
|
||||||
next := m.state.NextCheckUnix
|
|
||||||
m.mu.RUnlock()
|
|
||||||
if interval < minIntervalSeconds {
|
if interval < minIntervalSeconds {
|
||||||
interval = minIntervalSeconds
|
interval = minIntervalSeconds
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const ownerQueryTimeout = 10 * time.Second
|
|||||||
|
|
||||||
var dmsPackageNames = map[string]bool{
|
var dmsPackageNames = map[string]bool{
|
||||||
"dms": true, "dms-git": true,
|
"dms": true, "dms-git": true,
|
||||||
"dms-shell": true, "dms-shell-git": true, "dms-shell-bin": true,
|
"dux-shell": true, "dux-shell-git": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Arch/Debian/SUSE: 1.7.0+git4915.56234761; Fedora COPR: 0.0.git.4915.56234761
|
// Arch/Debian/SUSE: 1.7.0+git4915.56234761; Fedora COPR: 0.0.git.4915.56234761
|
||||||
@@ -114,7 +114,7 @@ func packageOwner(ctx context.Context, path string) (InstallMethod, string, stri
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case commandExists("pacman"):
|
case commandExists("pacman"):
|
||||||
// "/usr/bin/dms is owned by dms-shell-git 1.7.0+git4915-1"
|
// "/usr/bin/dms is owned by dux-shell-git 2.0.0+git4915-1"
|
||||||
out := runOutput(ctx, "pacman", "-Qo", path)
|
out := runOutput(ctx, "pacman", "-Qo", path)
|
||||||
fields := strings.Fields(out)
|
fields := strings.Fields(out)
|
||||||
if len(fields) < 2 {
|
if len(fields) < 2 {
|
||||||
|
|||||||
@@ -85,11 +85,11 @@ func TestDetectChannel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestFindShellPackagePrefersOwner(t *testing.T) {
|
func TestFindShellPackagePrefersOwner(t *testing.T) {
|
||||||
pkgs := []Package{{Name: "dms-shell"}, {Name: "dms-shell-git"}, {Name: "firefox"}}
|
pkgs := []Package{{Name: "dux-shell"}, {Name: "dux-shell-git"}, {Name: "firefox"}}
|
||||||
if got := findShellPackage(pkgs, "dms-shell-git"); got == nil || got.Name != "dms-shell-git" {
|
if got := findShellPackage(pkgs, "dux-shell-git"); got == nil || got.Name != "dux-shell-git" {
|
||||||
t.Errorf("owner match = %v", got)
|
t.Errorf("owner match = %v", got)
|
||||||
}
|
}
|
||||||
if got := findShellPackage(pkgs, ""); got == nil || got.Name != "dms-shell" {
|
if got := findShellPackage(pkgs, ""); got == nil || got.Name != "dux-shell" {
|
||||||
t.Errorf("fallback = %v", got)
|
t.Errorf("fallback = %v", got)
|
||||||
}
|
}
|
||||||
if got := findShellPackage([]Package{{Name: "firefox"}}, "dms"); got != nil {
|
if got := findShellPackage([]Package{{Name: "firefox"}}, "dms"); got != nil {
|
||||||
@@ -222,6 +222,11 @@ func TestSetIntervalSchedulesFromLastCheck(t *testing.T) {
|
|||||||
if next := m.GetState().NextCheckUnix; next < now+startupGraceSeconds-5 || next > now+startupGraceSeconds+5 {
|
if next := m.GetState().NextCheckUnix; next < now+startupGraceSeconds-5 || next > now+startupGraceSeconds+5 {
|
||||||
t.Errorf("overdue next = now+%d, want now+%d", next-now, startupGraceSeconds)
|
t.Errorf("overdue next = now+%d, want now+%d", next-now, startupGraceSeconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.SetInterval(0)
|
||||||
|
if state := m.GetState(); state.IntervalSeconds != 0 || state.NextCheckUnix != 0 {
|
||||||
|
t.Errorf("never: interval = %d, next = %d, want both 0", state.IntervalSeconds, state.NextCheckUnix)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRefreshWithoutBackendsAdvancesDeadline(t *testing.T) {
|
func TestRefreshWithoutBackendsAdvancesDeadline(t *testing.T) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package tailscale
|
package tailscale
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -103,35 +102,8 @@ func convertPeerStatus(ps *ipnstate.PeerStatus, users map[tailcfg.UserID]tailcfg
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !ps.LastSeen.IsZero() {
|
if !ps.LastSeen.IsZero() {
|
||||||
peer.LastSeen = formatRelativeTime(ps.LastSeen)
|
peer.LastSeen = ps.LastSeen.UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
|
||||||
return peer
|
return peer
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatRelativeTime formats a time as a human-readable relative duration (e.g., "5 minutes ago").
|
|
||||||
func formatRelativeTime(t time.Time) string {
|
|
||||||
d := time.Since(t)
|
|
||||||
switch {
|
|
||||||
case d < time.Minute:
|
|
||||||
return "just now"
|
|
||||||
case d < time.Hour:
|
|
||||||
m := int(d.Minutes())
|
|
||||||
if m == 1 {
|
|
||||||
return "1 minute ago"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d minutes ago", m)
|
|
||||||
case d < 24*time.Hour:
|
|
||||||
h := int(d.Hours())
|
|
||||||
if h == 1 {
|
|
||||||
return "1 hour ago"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d hours ago", h)
|
|
||||||
default:
|
|
||||||
days := int(d.Hours() / 24)
|
|
||||||
if days == 1 {
|
|
||||||
return "1 day ago"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d days ago", days)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -169,23 +169,3 @@ func TestPeerSorting(t *testing.T) {
|
|||||||
assert.Equal(t, "beta", state.Peers[1].Hostname)
|
assert.Equal(t, "beta", state.Peers[1].Hostname)
|
||||||
assert.Equal(t, "zebra", state.Peers[2].Hostname)
|
assert.Equal(t, "zebra", state.Peers[2].Hostname)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFormatRelativeTime(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
duration string
|
|
||||||
contains string
|
|
||||||
}{
|
|
||||||
{"minutes", "5m", "minutes ago"},
|
|
||||||
{"hours", "3h", "hours ago"},
|
|
||||||
{"days", "48h", "days ago"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
d, _ := time.ParseDuration(tt.duration)
|
|
||||||
result := formatRelativeTime(time.Now().Add(-d))
|
|
||||||
assert.Contains(t, result, tt.contains)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -194,3 +194,24 @@ func TestAlertRecordSurvivesRestart(t *testing.T) {
|
|||||||
assert.Nil(t, s.Limit, "the alert already fired before the restart")
|
assert.Nil(t, s.Limit, "the alert already fired before the restart")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenDBLockedByAnotherProcessKeepsData(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "db")
|
||||||
|
c := &clock{now: time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)}
|
||||||
|
now := func() time.Time { return c.now }
|
||||||
|
|
||||||
|
m, err := newManager(path, now)
|
||||||
|
require.NoError(t, err)
|
||||||
|
m.SetState("firefox", true, 1)
|
||||||
|
c.advance(10 * time.Second)
|
||||||
|
m.Flush()
|
||||||
|
|
||||||
|
_, err = newManager(path, now)
|
||||||
|
require.Error(t, err)
|
||||||
|
m.Close()
|
||||||
|
|
||||||
|
m, err = newManager(path, now)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer m.Close()
|
||||||
|
assert.Equal(t, int64(10), m.Summary(1)[0].Active)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package wellbeing
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"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/utils"
|
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
||||||
bolt "go.etcd.io/bbolt"
|
bolt "go.etcd.io/bbolt"
|
||||||
|
berrors "go.etcd.io/bbolt/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -31,6 +33,9 @@ func openDB(path string) (*bolt.DB, error) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, berrors.ErrTimeout) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
log.Errorf("Wellbeing db unreadable, starting over: %v", err)
|
log.Errorf("Wellbeing db unreadable, starting over: %v", err)
|
||||||
corruptPath := path + ".corrupt"
|
corruptPath := path + ".corrupt"
|
||||||
os.Remove(corruptPath)
|
os.Remove(corruptPath)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Model struct {
|
|||||||
state ApplicationState
|
state ApplicationState
|
||||||
|
|
||||||
osInfo *distros.OSInfo
|
osInfo *distros.OSInfo
|
||||||
|
resume *layerResume
|
||||||
dependencies []deps.Dependency
|
dependencies []deps.Dependency
|
||||||
err error
|
err error
|
||||||
|
|
||||||
@@ -137,6 +138,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m.updateDetectingDepsState(msg)
|
return m.updateDetectingDepsState(msg)
|
||||||
case StateDependencyReview:
|
case StateDependencyReview:
|
||||||
return m.updateDependencyReviewState(msg)
|
return m.updateDependencyReviewState(msg)
|
||||||
|
case StateLayerPackages:
|
||||||
|
return m.updateLayerPackagesState(msg)
|
||||||
case StateGentooUseFlags:
|
case StateGentooUseFlags:
|
||||||
return m.updateGentooUseFlagsState(msg)
|
return m.updateGentooUseFlagsState(msg)
|
||||||
case StateGentooGCCCheck:
|
case StateGentooGCCCheck:
|
||||||
@@ -180,6 +183,8 @@ func (m Model) View() string {
|
|||||||
return m.viewDetectingDeps()
|
return m.viewDetectingDeps()
|
||||||
case StateDependencyReview:
|
case StateDependencyReview:
|
||||||
return m.viewDependencyReview()
|
return m.viewDependencyReview()
|
||||||
|
case StateLayerPackages:
|
||||||
|
return m.viewLayerPackages()
|
||||||
case StateGentooUseFlags:
|
case StateGentooUseFlags:
|
||||||
return m.viewGentooUseFlags()
|
return m.viewGentooUseFlags()
|
||||||
case StateGentooGCCCheck:
|
case StateGentooGCCCheck:
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Carries the first run's choices across the reboot that layering needs.
|
||||||
|
type layerResume struct {
|
||||||
|
WindowManager string `json:"window_manager"`
|
||||||
|
Terminal string `json:"terminal"`
|
||||||
|
Selected []string `json:"selected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumePath() string {
|
||||||
|
dir := os.Getenv("XDG_STATE_HOME")
|
||||||
|
if dir == "" {
|
||||||
|
dir = filepath.Join(os.Getenv("HOME"), ".local", "state")
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "duxinstall", "resume.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveLayerResume(r layerResume) error {
|
||||||
|
path := resumePath()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadLayerResume() *layerResume {
|
||||||
|
data, err := os.ReadFile(resumePath())
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var r layerResume
|
||||||
|
if json.Unmarshal(data, &r) != nil || r.WindowManager == "" || r.Terminal == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &r
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearLayerResume() {
|
||||||
|
os.Remove(resumePath())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) currentLayerResume() layerResume {
|
||||||
|
r := layerResume{}
|
||||||
|
if wms := m.windowManagerChoices(); m.selectedWM >= 0 && m.selectedWM < len(wms) {
|
||||||
|
r.WindowManager = strings.ToLower(wms[m.selectedWM].label)
|
||||||
|
}
|
||||||
|
if terms := m.terminalChoices(); m.selectedTerminal >= 0 && m.selectedTerminal < len(terms) {
|
||||||
|
r.Terminal = strings.ToLower(terms[m.selectedTerminal].label)
|
||||||
|
}
|
||||||
|
for _, dep := range m.dependencies {
|
||||||
|
if !dep.Required && !m.disabledItems[dep.Name] {
|
||||||
|
r.Selected = append(r.Selected, dep.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// false: the saved choices no longer fit this host.
|
||||||
|
func (m *Model) applyLayerResume() bool {
|
||||||
|
if m.resume == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
wm, term := -1, -1
|
||||||
|
for i, c := range m.windowManagerChoices() {
|
||||||
|
if strings.ToLower(c.label) == m.resume.WindowManager {
|
||||||
|
wm = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, c := range m.terminalChoices() {
|
||||||
|
if strings.ToLower(c.label) == m.resume.Terminal {
|
||||||
|
term = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wm < 0 || term < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
m.selectedWM, m.selectedTerminal = wm, term
|
||||||
|
return true
|
||||||
|
}
|
||||||
Loaded 100 of 516 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user