mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-25 12:37:47 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e689cd2ce7 | ||
|
|
6d65f304e5 | ||
|
|
696be97018 | ||
|
|
20bcf8559b | ||
|
|
3388f78905 | ||
|
|
c5b058c83e | ||
|
|
66382dfa0d | ||
|
|
4d49e126ab | ||
|
|
7e0f110fb8 | ||
|
|
52d6a46d89 | ||
|
|
6f36e49c68 | ||
|
|
2d16cc2f27 | ||
|
|
b7be4a344a | ||
|
|
1e31015acb | ||
|
|
71417b7ca7 | ||
|
|
6bd0e24bd4 | ||
|
|
e50bb088b6 | ||
|
|
efd9324a4d | ||
|
|
0cd03e0180 | ||
|
|
ba7b3620a8 | ||
|
|
c7692f830c | ||
|
|
5a20a8f162 | ||
|
|
acc2c947e5 | ||
|
|
d8734bbe1a | ||
|
|
668dde2a33 | ||
|
|
900e6110d9 | ||
|
|
44d498e362 | ||
|
|
bf9a51c448 | ||
|
|
1a46db0517 | ||
|
|
7781c207f1 | ||
|
|
5b7777785a | ||
|
|
0aab0ec09b | ||
|
|
da79eca3c0 | ||
|
|
aeacaf7afc | ||
|
|
14b3ddaf83 | ||
|
|
6e86b53ccd | ||
|
|
c5d1d4fe46 | ||
|
|
39a58f77ec | ||
|
|
47c9d8d89b | ||
|
|
d8786eaaf7 | ||
|
|
4f969c8e10 | ||
|
|
1b59a474dd | ||
|
|
28f921fa02 | ||
|
|
8f2a486006 | ||
|
|
27a703367f | ||
|
|
8e851b3dc4 | ||
|
|
112d05901f | ||
|
|
afeea489c9 | ||
|
|
90d3c9731c | ||
|
|
b81954454a | ||
|
|
479b1434de | ||
|
|
203b1369b3 | ||
|
|
5ffc581ecf | ||
|
|
3284acab28 | ||
|
|
1fb9f5271a |
No files matched your search
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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,8 +13,8 @@ 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)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -33,7 +33,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 +100,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 +138,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
|
||||||
|
|
||||||
@@ -159,12 +159,12 @@ See component-specific documentation:
|
|||||||
|
|
||||||
### 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:**
|
||||||
@@ -193,7 +193,7 @@ Contributions welcome. Bug fixes, widgets, features, documentation, and plugins
|
|||||||
3. Test thoroughly
|
3. Test thoroughly
|
||||||
4. Open a pull request
|
4. Open a pull request
|
||||||
|
|
||||||
For documentation contributions, see [DankLinux-Docs](https://github.com/duxlabs-dev/dux-docs).
|
For documentation contributions, see [dux-docs](https://github.com/duxlabs-dev/dux-docs).
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
|
|||||||
+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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,14 +128,18 @@ func updateArchLinux() error {
|
|||||||
|
|
||||||
var packageName string
|
var packageName string
|
||||||
var isAUR bool
|
var isAUR bool
|
||||||
if isArchPackageInstalled("dms-shell") {
|
// AUR names first: pacman -Q matches provides, and dux-shell-git provides dms-shell.
|
||||||
packageName = "dms-shell"
|
if isArchPackageInstalled("dux-shell-git") {
|
||||||
|
packageName = "dux-shell-git"
|
||||||
|
isAUR = true
|
||||||
} else if isArchPackageInstalled("dms-shell-git") {
|
} else if isArchPackageInstalled("dms-shell-git") {
|
||||||
packageName = "dms-shell-git"
|
packageName = "dms-shell-git"
|
||||||
isAUR = true
|
isAUR = true
|
||||||
} else if isArchPackageInstalled("dms-shell-bin") {
|
} else if isArchPackageInstalled("dms-shell-bin") {
|
||||||
packageName = "dms-shell-bin"
|
packageName = "dms-shell-bin"
|
||||||
isAUR = true
|
isAUR = true
|
||||||
|
} else if isArchPackageInstalled("dms-shell") {
|
||||||
|
packageName = "dms-shell"
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("Info: No dms-shell package found.")
|
fmt.Println("Info: No dms-shell package found.")
|
||||||
fmt.Println("Info: Falling back to git-based update method...")
|
fmt.Println("Info: Falling back to git-based update method...")
|
||||||
|
|||||||
@@ -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: %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 {
|
||||||
+1
-1
@@ -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,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
@@ -14,11 +15,28 @@ 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }},
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 +57,65 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShellEnv returns every shell switch as DMS_POLICY_<KEY>=1 or 0, so a value
|
||||||
|
// inherited by the shell never counts. A policy file that fails to load turns
|
||||||
|
// every switch on, and its error is returned with them.
|
||||||
|
func ShellEnv(fs afero.Fs) ([]string, error) {
|
||||||
|
policies, err := load(fs)
|
||||||
|
env := make([]string, 0, len(shellSwitches))
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return env, err
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package clipolicy
|
package clipolicy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@@ -45,3 +46,103 @@ 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
env, err := ShellEnv(fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.ElementsMatch(t, want, env)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellEnvMalformedPolicyTurnsEverySwitchOn(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte("{not json"), 0o644))
|
||||||
|
|
||||||
|
var want []string
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package distros
|
package distros
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -128,7 +129,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 +137,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 +156,40 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AUR packages renamed for Dux; installs from before the rename keep the old name.
|
||||||
|
var legacyAURNames = map[string]string{
|
||||||
|
"dux-shell-git": "dms-shell-git",
|
||||||
|
"dux-greeter-bin": "greetd-dms-greeter-bin",
|
||||||
|
"dux-greeter-git": "greetd-dms-greeter-git",
|
||||||
|
"dux-search-bin": "dsearch-bin",
|
||||||
|
"dux-search-git": "dsearch-git",
|
||||||
|
"dux-calendar-bin": "dankcalendar-bin",
|
||||||
|
"dux-calendar-git": "dankcalendar-git",
|
||||||
|
}
|
||||||
|
|
||||||
func (a *ArchDistribution) packageInstalled(pkg string) bool {
|
func (a *ArchDistribution) packageInstalled(pkg string) bool {
|
||||||
cmd := exec.Command("pacman", "-Q", pkg)
|
return a.installedName(pkg) != ""
|
||||||
err := cmd.Run()
|
}
|
||||||
return err == nil
|
|
||||||
|
// installedName returns the name pkg is installed under, which may be its pre-rename name.
|
||||||
|
func (a *ArchDistribution) installedName(pkg string) string {
|
||||||
|
if exec.Command("pacman", "-Q", pkg).Run() == nil {
|
||||||
|
return pkg
|
||||||
|
}
|
||||||
|
legacy, ok := legacyAURNames[pkg]
|
||||||
|
if ok && exec.Command("pacman", "-Q", legacy).Run() == nil {
|
||||||
|
return legacy
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSRCINFODeps reads a .SRCINFO file and returns runtime dep and makedep package
|
// parseSRCINFODeps reads a .SRCINFO file and returns runtime dep and makedep package
|
||||||
@@ -286,28 +308,28 @@ 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: "dms-shell", Repository: RepoTypeSystem}
|
||||||
}
|
}
|
||||||
@@ -587,7 +609,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 := cmp.Or(a.installedName(swap.From.Name), 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
|
||||||
@@ -658,7 +681,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 +722,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 +758,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,7 +804,7 @@ 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{
|
depsToRemove := []string{
|
||||||
"depends = quickshell",
|
"depends = quickshell",
|
||||||
|
|||||||
@@ -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,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"
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,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 +43,6 @@ const (
|
|||||||
TemplateKindGTK
|
TemplateKindGTK
|
||||||
TemplateKindVSCode
|
TemplateKindVSCode
|
||||||
TemplateKindEmacs
|
TemplateKindEmacs
|
||||||
TemplateKindMango
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type TemplateDef struct {
|
type TemplateDef struct {
|
||||||
@@ -63,7 +61,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"},
|
||||||
@@ -725,12 +723,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)
|
||||||
@@ -965,6 +957,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"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -29,6 +29,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 +56,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 +76,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 +86,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 +119,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)
|
||||||
|
|
||||||
@@ -272,17 +277,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 +355,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 +388,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 +451,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 +573,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,7 +630,8 @@ 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
|
||||||
}
|
}
|
||||||
c := b.Cursor()
|
c := b.Cursor()
|
||||||
@@ -579,7 +641,7 @@ func (m *Manager) trimLengthInTx(b *bolt.Bucket) error {
|
|||||||
if err == nil && entry.Pinned {
|
if err == nil && entry.Pinned {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if count < m.config.MaxHistory {
|
if count < maxHistory {
|
||||||
count++
|
count++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1336,19 +1398,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 +1431,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 +1580,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 +1780,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 +1826,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 +1848,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 +1861,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 {
|
||||||
|
|||||||
@@ -18,6 +18,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 +52,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) {
|
||||||
@@ -887,6 +890,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 +1061,30 @@ 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())
|
||||||
|
}
|
||||||
@@ -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{}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"github.com/godbus/dbus/v5"
|
"github.com/godbus/dbus/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewManager() (*Manager, error) {
|
func NewManager(keepLockOnUnlock bool) (*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,6 +25,8 @@ 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,
|
||||||
}
|
}
|
||||||
m.sleepInhibitorEnabled.Store(true)
|
m.sleepInhibitorEnabled.Store(true)
|
||||||
|
|
||||||
|
|||||||
@@ -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,5 @@ type Manager struct {
|
|||||||
lockTimer *time.Timer
|
lockTimer *time.Timer
|
||||||
sleepInhibitorEnabled atomic.Bool
|
sleepInhibitorEnabled atomic.Bool
|
||||||
fallbackDelay time.Duration
|
fallbackDelay time.Duration
|
||||||
|
keepLockOnUnlock bool
|
||||||
}
|
}
|
||||||
@@ -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,12 @@ 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
|
||||||
|
}
|
||||||
|
manager, err := loginctl.NewManager(keepLockOnUnlock)
|
||||||
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
|
||||||
@@ -1119,7 +1126,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)")
|
||||||
|
|||||||
@@ -18,6 +18,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,
|
"dms-shell": true, "dms-shell-git": true, "dms-shell-bin": 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ const (
|
|||||||
StateSelectTerminal
|
StateSelectTerminal
|
||||||
StateDetectingDeps
|
StateDetectingDeps
|
||||||
StateDependencyReview
|
StateDependencyReview
|
||||||
|
StateLayerPackages
|
||||||
StateGentooUseFlags
|
StateGentooUseFlags
|
||||||
StateGentooGCCCheck
|
StateGentooGCCCheck
|
||||||
StateSelectPrivesc
|
StateSelectPrivesc
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ func (m Model) updateDeployingConfigsState(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.installationLogs = append(m.installationLogs, logLine)
|
m.installationLogs = append(m.installationLogs, logLine)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.isImmutable() {
|
||||||
|
clearLayerResume()
|
||||||
|
}
|
||||||
m.state = StateInstallComplete
|
m.state = StateInstallComplete
|
||||||
m.isLoading = false
|
m.isLoading = false
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -101,14 +104,7 @@ func (m Model) optionalDepSelected(name string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) useSystemdConfig() bool {
|
func (m Model) useSystemdConfig() bool {
|
||||||
if m.osInfo == nil {
|
return distros.HasSystemd()
|
||||||
return true
|
|
||||||
}
|
|
||||||
distroConfig, exists := distros.Registry[m.osInfo.Distribution.ID]
|
|
||||||
if !exists {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return distroConfig.Family != distros.FamilyVoid
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewConfigConfirmation() string {
|
func (m Model) viewConfigConfirmation() string {
|
||||||
@@ -179,7 +175,7 @@ func (m Model) configReplacementNote() string {
|
|||||||
if m.useSystemdConfig() && mangoconf.SessionTargetInstalled() {
|
if m.useSystemdConfig() && mangoconf.SessionTargetInstalled() {
|
||||||
return "Replacing Mango writes the DMS Mango template and starts the user systemd dms service with mango-session.target. Existing binds, monitor and window rules move into the dms/ files."
|
return "Replacing Mango writes the DMS Mango template and starts the user systemd dms service with mango-session.target. Existing binds, monitor and window rules move into the dms/ files."
|
||||||
}
|
}
|
||||||
return "Replacing Mango writes the DMS Mango template and starts DMS from Mango with " + mangoconf.Detect().Key("exec_once") + "=dms run. Existing binds, monitor and window rules move into the dms/ files."
|
return "Replacing Mango writes the DMS Mango template and starts DMS from Mango with exec_once=dms run. Existing binds, monitor and window rules move into the dms/ files."
|
||||||
case config.BindsConfigType:
|
case config.BindsConfigType:
|
||||||
return "Keep uses your binds as they are; replace starts from the DMS stock binds. A timestamped backup is kept either way."
|
return "Keep uses your binds as they are; replace starts from the DMS stock binds. A timestamped backup is kept either way."
|
||||||
case "Ghostty":
|
case "Ghostty":
|
||||||
|
|||||||
@@ -89,13 +89,20 @@ func (m Model) dependencyStatus(dep deps.Dependency) (marker, status string) {
|
|||||||
switch {
|
switch {
|
||||||
case m.disabledItems[dep.Name]:
|
case m.disabledItems[dep.Name]:
|
||||||
return "✗ ", m.styles.Subtle.Render("Will skip")
|
return "✗ ", m.styles.Subtle.Render("Will skip")
|
||||||
|
case m.variantMarks[dep.Name]:
|
||||||
|
return "🔄 ", m.styles.Warning.Render("Will switch to " + variantLabel(dep.Variant))
|
||||||
case m.reinstallItems[dep.Name]:
|
case m.reinstallItems[dep.Name]:
|
||||||
return "🔄 ", m.styles.Warning.Render("Will upgrade")
|
return "🔄 ", m.styles.Warning.Render("Will upgrade")
|
||||||
|
case m.isImmutable() && !dep.Required && dep.Status == deps.StatusInstalled:
|
||||||
|
return "✓ ", m.styles.Success.Render("✓ Selected (installed)")
|
||||||
case dep.Name == dmsDepName:
|
case dep.Name == dmsDepName:
|
||||||
switch dep.Status {
|
switch dep.Status {
|
||||||
case deps.StatusInstalled:
|
case deps.StatusInstalled:
|
||||||
return "⚡ ", m.styles.Success.Render("✓ Required (installed)")
|
return "⚡ ", m.styles.Success.Render("✓ Required (installed)")
|
||||||
case deps.StatusMissing:
|
case deps.StatusMissing:
|
||||||
|
if m.isImmutable() {
|
||||||
|
return "⚡ ", m.styles.Warning.Render("○ Required (not installed)")
|
||||||
|
}
|
||||||
return "⚡ ", m.styles.Warning.Render("○ Required (will install)")
|
return "⚡ ", m.styles.Warning.Render("○ Required (will install)")
|
||||||
case deps.StatusNeedsUpdate:
|
case deps.StatusNeedsUpdate:
|
||||||
return "⚡ ", m.styles.Warning.Render("△ Required (needs update)")
|
return "⚡ ", m.styles.Warning.Render("△ Required (needs update)")
|
||||||
@@ -105,19 +112,30 @@ func (m Model) dependencyStatus(dep deps.Dependency) (marker, status string) {
|
|||||||
return "⚡ ", ""
|
return "⚡ ", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
verb := "Will install"
|
||||||
|
if m.isImmutable() {
|
||||||
|
verb = "Not installed"
|
||||||
|
}
|
||||||
switch dep.Status {
|
switch dep.Status {
|
||||||
case deps.StatusInstalled:
|
case deps.StatusInstalled:
|
||||||
return "", m.styles.Subtle.Render("✓ Already installed")
|
return "", m.styles.Subtle.Render("✓ Already installed")
|
||||||
case deps.StatusMissing:
|
case deps.StatusMissing:
|
||||||
return "", m.styles.Warning.Render("○ Will install")
|
return "", m.styles.Warning.Render("○ " + verb)
|
||||||
case deps.StatusNeedsUpdate:
|
case deps.StatusNeedsUpdate:
|
||||||
return "", m.styles.Warning.Render("△ Will install")
|
return "", m.styles.Warning.Render("△ " + verb)
|
||||||
case deps.StatusNeedsReinstall:
|
case deps.StatusNeedsReinstall:
|
||||||
return "", m.styles.Error.Render("! Will install")
|
return "", m.styles.Error.Render("! " + verb)
|
||||||
}
|
}
|
||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func variantLabel(variant deps.PackageVariant) string {
|
||||||
|
if variant == deps.VariantGit {
|
||||||
|
return "git"
|
||||||
|
}
|
||||||
|
return "stable"
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) dependencyNote(name string) string {
|
func (m Model) dependencyNote(name string) string {
|
||||||
switch name {
|
switch name {
|
||||||
case "dms-greeter":
|
case "dms-greeter":
|
||||||
@@ -144,6 +162,9 @@ func (m Model) updateDetectingDepsState(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, m.listenForLogs()
|
return m, m.listenForLogs()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.isImmutable() {
|
||||||
|
depsMsg.deps = distros.ApplyImmutableDetection(depsMsg.deps)
|
||||||
|
}
|
||||||
m.dependencies = partitionOptionalLast(depsMsg.deps)
|
m.dependencies = partitionOptionalLast(depsMsg.deps)
|
||||||
for _, dep := range m.dependencies {
|
for _, dep := range m.dependencies {
|
||||||
m.detectedVariants[dep.Name] = dep.Variant
|
m.detectedVariants[dep.Name] = dep.Variant
|
||||||
@@ -152,6 +173,9 @@ func (m Model) updateDetectingDepsState(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.disabledItems[dep.Name] = true
|
m.disabledItems[dep.Name] = true
|
||||||
}
|
}
|
||||||
|
if m.resume != nil {
|
||||||
|
return m.resumeAfterLayering()
|
||||||
|
}
|
||||||
m.state = StateDependencyReview
|
m.state = StateDependencyReview
|
||||||
return m, m.listenForLogs()
|
return m, m.listenForLogs()
|
||||||
}
|
}
|
||||||
@@ -172,6 +196,9 @@ func (m Model) updateDependencyReviewState(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case "g", "G":
|
case "g", "G":
|
||||||
m.toggleSelectedVariant()
|
m.toggleSelectedVariant()
|
||||||
case "enter":
|
case "enter":
|
||||||
|
if m.isImmutable() {
|
||||||
|
return m.continueOnImmutable()
|
||||||
|
}
|
||||||
if m.osInfo != nil {
|
if m.osInfo != nil {
|
||||||
if config, ok := distros.Registry[m.osInfo.Distribution.ID]; ok && config.Family == distros.FamilyGentoo {
|
if config, ok := distros.Registry[m.osInfo.Distribution.ID]; ok && config.Family == distros.FamilyGentoo {
|
||||||
m.state = StateGentooUseFlags
|
m.state = StateGentooUseFlags
|
||||||
@@ -197,7 +224,8 @@ func (m Model) toggleSelectedDependency() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
installed := dep.Status == deps.StatusInstalled || dep.Status == deps.StatusNeedsReinstall
|
installed := dep.Status == deps.StatusInstalled || dep.Status == deps.StatusNeedsReinstall
|
||||||
if installed {
|
// Nothing to reinstall on an image; enabling only drives the user-space setup.
|
||||||
|
if installed && !m.isImmutable() {
|
||||||
m.reinstallItems[dep.Name] = !m.reinstallItems[dep.Name]
|
m.reinstallItems[dep.Name] = !m.reinstallItems[dep.Name]
|
||||||
m.disabledItems[dep.Name] = false
|
m.disabledItems[dep.Name] = false
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ func dmsPackageName(distroID string, dependencies []deps.Dependency) string {
|
|||||||
switch config.Family {
|
switch config.Family {
|
||||||
case distros.FamilyArch:
|
case distros.FamilyArch:
|
||||||
if isGit {
|
if isGit {
|
||||||
return "dms-shell-git"
|
return "dux-shell-git"
|
||||||
}
|
}
|
||||||
return "dms-shell"
|
return "dms-shell"
|
||||||
case distros.FamilyFedora, distros.FamilyUbuntu, distros.FamilyDebian, distros.FamilySUSE, distros.FamilyVoid:
|
case distros.FamilyFedora, distros.FamilyUbuntu, distros.FamilyDebian, distros.FamilySUSE, distros.FamilyVoid:
|
||||||
@@ -136,6 +136,16 @@ func dmsPackageName(distroID string, dependencies []deps.Dependency) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) uninstallHint() string {
|
||||||
|
if m.isImmutable() {
|
||||||
|
if distros.Registry[m.osInfo.Distribution.ID].Family == distros.FamilyFedora {
|
||||||
|
return "sudo rpm-ostree uninstall " + dmsPackageName(m.osInfo.Distribution.ID, m.dependencies)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return uninstallCommand(m.osInfo.Distribution.ID, m.dependencies)
|
||||||
|
}
|
||||||
|
|
||||||
func uninstallCommand(distroID string, dependencies []deps.Dependency) string {
|
func uninstallCommand(distroID string, dependencies []deps.Dependency) string {
|
||||||
config, ok := distros.Registry[distroID]
|
config, ok := distros.Registry[distroID]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -197,7 +207,7 @@ func (m Model) troubleshootingHints() (autostart, logs string) {
|
|||||||
case deps.WindowManagerHyprland:
|
case deps.WindowManagerHyprland:
|
||||||
autostart = `remove hl.exec_cmd("dms run") from ~/.config/hypr/hyprland.lua`
|
autostart = `remove hl.exec_cmd("dms run") from ~/.config/hypr/hyprland.lua`
|
||||||
default:
|
default:
|
||||||
autostart = "remove '" + mangoconf.Detect().Key("exec_once") + "=dms run' from ~/.config/mango/config.conf"
|
autostart = "remove 'exec_once=dms run' from ~/.config/mango/config.conf"
|
||||||
}
|
}
|
||||||
return autostart, logs
|
return autostart, logs
|
||||||
}
|
}
|
||||||
@@ -206,7 +216,7 @@ func (m Model) troubleshootingHints() (autostart, logs string) {
|
|||||||
if mangoconf.SessionTargetInstalled() {
|
if mangoconf.SessionTargetInstalled() {
|
||||||
return "rm ~/.config/systemd/user/mango-session.target.wants/dms.service", "journalctl --user -u dms"
|
return "rm ~/.config/systemd/user/mango-session.target.wants/dms.service", "journalctl --user -u dms"
|
||||||
}
|
}
|
||||||
return "remove '" + mangoconf.Detect().Key("exec_once") + "=dms run' from ~/.config/mango/config.conf", "qs -p ~/.config/quickshell/dms log"
|
return "remove 'exec_once=dms run' from ~/.config/mango/config.conf", "qs -p ~/.config/quickshell/dms log"
|
||||||
}
|
}
|
||||||
return "systemctl --user disable dms", "journalctl --user -u dms"
|
return "systemctl --user disable dms", "journalctl --user -u dms"
|
||||||
}
|
}
|
||||||
@@ -218,15 +228,20 @@ func (m Model) viewInstallComplete() string {
|
|||||||
b.WriteByte('\n')
|
b.WriteByte('\n')
|
||||||
b.WriteString(m.styles.Success.Render("Setup Complete!"))
|
b.WriteString(m.styles.Success.Render("Setup Complete!"))
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
b.WriteString(m.styles.Success.Render("✓ All packages installed and configurations deployed."))
|
summary := "✓ All packages installed and configurations deployed."
|
||||||
b.WriteString("\n\n")
|
|
||||||
|
|
||||||
accomplishments := []string{
|
accomplishments := []string{
|
||||||
"• Window manager and dependencies installed",
|
"• Window manager and dependencies installed",
|
||||||
"• Terminal and development tools configured",
|
"• Terminal and development tools configured",
|
||||||
"• Configuration files deployed with backups",
|
"• Configuration files deployed with backups",
|
||||||
"• System optimized for Dux Shell",
|
"• System optimized for Dux Shell",
|
||||||
}
|
}
|
||||||
|
if m.isImmutable() {
|
||||||
|
summary = "✓ Configurations deployed. No packages were changed on this image."
|
||||||
|
accomplishments = accomplishments[1:]
|
||||||
|
}
|
||||||
|
b.WriteString(m.styles.Success.Render(summary))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
for _, item := range accomplishments {
|
for _, item := range accomplishments {
|
||||||
b.WriteString(m.styles.Subtle.Render(item))
|
b.WriteString(m.styles.Subtle.Render(item))
|
||||||
b.WriteByte('\n')
|
b.WriteByte('\n')
|
||||||
@@ -240,12 +255,17 @@ func (m Model) viewInstallComplete() string {
|
|||||||
cmd := m.styles.Accent
|
cmd := m.styles.Accent
|
||||||
autostart, logs := m.troubleshootingHints()
|
autostart, logs := m.troubleshootingHints()
|
||||||
|
|
||||||
|
if m.isImmutable() && m.optionalDepSelected("dms-greeter") {
|
||||||
|
b.WriteString(label.Render("Greeter:") + "\n")
|
||||||
|
b.WriteString(label.Render(" Configure greetd: ") + cmd.Render("dms-greeter install --yes") + "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
b.WriteString(label.Render("Troubleshooting:") + "\n")
|
b.WriteString(label.Render("Troubleshooting:") + "\n")
|
||||||
b.WriteString(label.Render(" Disable autostart: ") + cmd.Render(autostart) + "\n")
|
b.WriteString(label.Render(" Disable autostart: ") + cmd.Render(autostart) + "\n")
|
||||||
b.WriteString(label.Render(" View logs: ") + cmd.Render(logs) + "\n")
|
b.WriteString(label.Render(" View logs: ") + cmd.Render(logs) + "\n")
|
||||||
|
|
||||||
if m.osInfo != nil {
|
if m.osInfo != nil {
|
||||||
if uninstall := uninstallCommand(m.osInfo.Distribution.ID, m.dependencies); uninstall != "" {
|
if uninstall := m.uninstallHint(); uninstall != "" {
|
||||||
b.WriteString(label.Render(" Uninstall: ") + cmd.Render(uninstall) + "\n")
|
b.WriteString(label.Render(" Uninstall: ") + cmd.Render(uninstall) + "\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/distros"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Images skip the privileged phases: packages are present, or the user layers them and comes back after a reboot.
|
||||||
|
func (m Model) continueOnImmutable() (tea.Model, tea.Cmd) {
|
||||||
|
if len(distros.PendingDependencies(m.dependencies, m.reinstallItems, m.disabledItems)) > 0 {
|
||||||
|
if err := saveLayerResume(m.currentLayerResume()); err != nil {
|
||||||
|
m.logChan <- "could not save resume state: " + err.Error()
|
||||||
|
}
|
||||||
|
m.state = StateLayerPackages
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.installationLogs = nil
|
||||||
|
m.state = StateConfigConfirmation
|
||||||
|
m.isLoading = true
|
||||||
|
return m, tea.Batch(m.spinner.Tick, m.setupUserServices(), m.checkExistingConfigurations())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) resumeAfterLayering() (tea.Model, tea.Cmd) {
|
||||||
|
for _, name := range m.resume.Selected {
|
||||||
|
if _, ok := m.disabledItems[name]; ok {
|
||||||
|
m.disabledItems[name] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m.continueOnImmutable()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) setupUserServices() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if !m.useSystemdConfig() || !m.optionalDepSelected("danksearch") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := distros.SetupDsearchService(context.Background(), func(line string) { m.logChan <- line }); err != nil {
|
||||||
|
m.logChan <- "danksearch service setup warning (non-fatal): " + err.Error()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) layerCommands() []string {
|
||||||
|
if m.osInfo == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
distro, err := distros.NewDistribution(m.osInfo.Distribution.ID, m.logChan)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
layerer, ok := distro.(distros.PackageLayerer)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return layerer.LayerCommands(m.dependencies, m.chosenWindowManager(), m.reinstallItems, m.disabledItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) viewLayerPackages() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(m.renderBanner())
|
||||||
|
b.WriteByte('\n')
|
||||||
|
b.WriteString(m.styles.Title.Render("Packages To Layer"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
for _, dep := range distros.PendingDependencies(m.dependencies, m.reinstallItems, m.disabledItems) {
|
||||||
|
line := " ○ " + dep.Name
|
||||||
|
if m.variantMarks[dep.Name] {
|
||||||
|
line = " ⇄ " + dep.Name + " → " + variantLabel(dep.Variant)
|
||||||
|
}
|
||||||
|
b.WriteString(m.styles.Warning.Render(line))
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
b.WriteByte('\n')
|
||||||
|
|
||||||
|
commands := m.layerCommands()
|
||||||
|
if len(commands) == 0 {
|
||||||
|
b.WriteString(m.styles.Normal.Render(wrapText("This image has no known way to add these packages. Install them from the image's own tooling, reboot, and run duxinstall again.", 70)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString(m.styles.Normal.Render("Run these, reboot, then run duxinstall again. It picks up from here and deploys configuration:"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
for _, cmd := range commands {
|
||||||
|
b.WriteString(" " + m.styles.Accent.Render(cmd))
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.osInfo != nil && distros.Registry[m.osInfo.Distribution.ID].Family == distros.FamilyFedora {
|
||||||
|
b.WriteString(m.styles.Subtle.Render("Prefer a ready-made image? Zirconium ships niri and Dux Shell:"))
|
||||||
|
b.WriteByte('\n')
|
||||||
|
b.WriteString(" " + m.styles.Subtle.Render(distros.ZirconiumSwitchCommand))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(m.styles.Subtle.Render("Esc: Back, Ctrl+C: Quit"))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) updateLayerPackagesState(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "esc" {
|
||||||
|
m.state = StateDependencyReview
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, m.listenForLogs()
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/deps"
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/distros"
|
||||||
|
)
|
||||||
|
|
||||||
|
func immutableFedoraModel(dependencies []deps.Dependency) Model {
|
||||||
|
m := NewModel("test", "")
|
||||||
|
m.osInfo = &distros.OSInfo{PrettyName: "Fedora Linux 44 (Silverblue)", Distribution: distros.DistroInfo{ID: "fedora"}, Immutable: true, ImmutableReason: "ostree-based system"}
|
||||||
|
m.dependencies = dependencies
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImmutableHostStopsAtLayerScreen(t *testing.T) {
|
||||||
|
m := immutableFedoraModel([]deps.Dependency{
|
||||||
|
{Name: dmsDepName, Status: deps.StatusMissing, Required: true},
|
||||||
|
{Name: "niri", Status: deps.StatusInstalled, Required: true},
|
||||||
|
})
|
||||||
|
next, _ := m.continueOnImmutable()
|
||||||
|
if got := next.(Model).state; got != StateLayerPackages {
|
||||||
|
t.Fatalf("state = %v, want StateLayerPackages", got)
|
||||||
|
}
|
||||||
|
view := next.(Model).viewLayerPackages()
|
||||||
|
if !strings.Contains(view, "sudo rpm-ostree install dms") || !strings.Contains(view, "bootc switch") {
|
||||||
|
t.Fatalf("layer screen missing commands:\n%s", view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImmutableHostWithPackagesSkipsToConfigs(t *testing.T) {
|
||||||
|
m := immutableFedoraModel([]deps.Dependency{
|
||||||
|
{Name: dmsDepName, Status: deps.StatusInstalled, Required: true},
|
||||||
|
{Name: "niri", Status: deps.StatusInstalled, Required: true},
|
||||||
|
})
|
||||||
|
next, _ := m.continueOnImmutable()
|
||||||
|
if got := next.(Model).state; got != StateConfigConfirmation {
|
||||||
|
t.Fatalf("state = %v, want StateConfigConfirmation", got)
|
||||||
|
}
|
||||||
|
if m.cannotContinue() {
|
||||||
|
t.Fatal("registered immutable host must pass the welcome screen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImmutableSecondRunResumesPastReview(t *testing.T) {
|
||||||
|
t.Setenv("XDG_STATE_HOME", t.TempDir())
|
||||||
|
|
||||||
|
first := immutableFedoraModel([]deps.Dependency{
|
||||||
|
{Name: dmsDepName, Status: deps.StatusMissing, Required: true},
|
||||||
|
{Name: "danksearch", Status: deps.StatusMissing},
|
||||||
|
{Name: "dankcalendar", Status: deps.StatusMissing},
|
||||||
|
})
|
||||||
|
first.selectedWM, first.selectedTerminal = 0, 1 // niri, kitty
|
||||||
|
first.disabledItems["danksearch"] = false
|
||||||
|
first.disabledItems["dankcalendar"] = true
|
||||||
|
if next, _ := first.continueOnImmutable(); next.(Model).state != StateLayerPackages {
|
||||||
|
t.Fatal("first run should stop at the layer screen")
|
||||||
|
}
|
||||||
|
|
||||||
|
second := immutableFedoraModel(nil)
|
||||||
|
second.isLoading = false
|
||||||
|
second.resume = loadLayerResume()
|
||||||
|
if second.resume == nil {
|
||||||
|
t.Fatal("layer screen must save a resume file")
|
||||||
|
}
|
||||||
|
second = pressKey(t, second, enterKey)
|
||||||
|
if second.state != StateDetectingDeps || second.selectedTerminal != 1 {
|
||||||
|
t.Fatalf("state = %v, terminal = %d; want detection with the saved choices", second.state, second.selectedTerminal)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _ := second.Update(depsDetectedMsg{deps: []deps.Dependency{
|
||||||
|
{Name: dmsDepName, Status: deps.StatusInstalled, Required: true},
|
||||||
|
{Name: "danksearch", Status: deps.StatusInstalled},
|
||||||
|
{Name: "dankcalendar", Status: deps.StatusInstalled},
|
||||||
|
}})
|
||||||
|
second = updated.(Model)
|
||||||
|
if second.state != StateConfigConfirmation {
|
||||||
|
t.Fatalf("state = %v, want StateConfigConfirmation without a review stop", second.state)
|
||||||
|
}
|
||||||
|
if second.disabledItems["danksearch"] || !second.disabledItems["dankcalendar"] {
|
||||||
|
t.Fatalf("resume must restore the optional selection: %v", second.disabledItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImmutableStartOverClearsResume(t *testing.T) {
|
||||||
|
t.Setenv("XDG_STATE_HOME", t.TempDir())
|
||||||
|
if err := saveLayerResume(layerResume{WindowManager: "niri", Terminal: "ghostty"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := immutableFedoraModel(nil)
|
||||||
|
m.isLoading = false
|
||||||
|
m.resume = loadLayerResume()
|
||||||
|
m = pressKey(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s")})
|
||||||
|
if m.resume != nil || loadLayerResume() != nil {
|
||||||
|
t.Fatal("S must drop the saved run")
|
||||||
|
}
|
||||||
|
m = pressKey(t, m, enterKey)
|
||||||
|
if m.state != StateSelectWindowManager {
|
||||||
|
t.Fatalf("state = %v, want StateSelectWindowManager", m.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImmutableEnableInstalledOptionalIsNotPending(t *testing.T) {
|
||||||
|
m := immutableFedoraModel([]deps.Dependency{
|
||||||
|
{Name: dmsDepName, Status: deps.StatusInstalled, Required: true},
|
||||||
|
{Name: "dms-greeter", Status: deps.StatusInstalled},
|
||||||
|
})
|
||||||
|
m.selectedDep = 1
|
||||||
|
m.disabledItems["dms-greeter"] = true
|
||||||
|
m.toggleSelectedDependency()
|
||||||
|
if m.disabledItems["dms-greeter"] || m.reinstallItems["dms-greeter"] {
|
||||||
|
t.Fatalf("enable on an image must not mark a reinstall: disabled=%v reinstall=%v", m.disabledItems, m.reinstallItems)
|
||||||
|
}
|
||||||
|
if pending := distros.PendingDependencies(m.dependencies, m.reinstallItems, m.disabledItems); len(pending) != 0 {
|
||||||
|
t.Fatalf("installed package listed for layering: %v", pending)
|
||||||
|
}
|
||||||
|
if line := m.dependencyLine(m.dependencies[1], false); !strings.Contains(line, "Selected (installed)") {
|
||||||
|
t.Fatalf("status line = %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImmutableVariantToggleMarksSwitch(t *testing.T) {
|
||||||
|
m := immutableFedoraModel([]deps.Dependency{
|
||||||
|
{Name: dmsDepName, Status: deps.StatusInstalled, Required: true, CanToggle: true, Variant: deps.VariantGit},
|
||||||
|
})
|
||||||
|
m.detectedVariants[dmsDepName] = deps.VariantGit
|
||||||
|
m.toggleSelectedVariant()
|
||||||
|
if m.dependencies[0].Variant != deps.VariantStable || !m.reinstallItems[dmsDepName] || !m.variantMarks[dmsDepName] {
|
||||||
|
t.Fatalf("G on an image must mark a switch: %+v reinstall=%v marks=%v", m.dependencies[0], m.reinstallItems, m.variantMarks)
|
||||||
|
}
|
||||||
|
if line := m.dependencyLine(m.dependencies[0], false); !strings.Contains(line, "Will switch to stable") {
|
||||||
|
t.Fatalf("status line = %q", line)
|
||||||
|
}
|
||||||
|
m.toggleSelectedVariant()
|
||||||
|
if m.reinstallItems[dmsDepName] || m.variantMarks[dmsDepName] {
|
||||||
|
t.Fatal("toggling back must clear the switch mark")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,9 @@ func (m Model) windowManagerChoices() []wmChoice {
|
|||||||
if m.osInfo == nil || m.osInfo.Distribution.ID != "debian" {
|
if m.osInfo == nil || m.osInfo.Distribution.ID != "debian" {
|
||||||
choices = append(choices, wmChoice{deps.WindowManagerHyprland, "Hyprland", "Dynamic tiling Wayland compositor."})
|
choices = append(choices, wmChoice{deps.WindowManagerHyprland, "Hyprland", "Dynamic tiling Wayland compositor."})
|
||||||
}
|
}
|
||||||
|
if m.isImmutable() {
|
||||||
|
return choices
|
||||||
|
}
|
||||||
if m.osInfo != nil {
|
if m.osInfo != nil {
|
||||||
// No mango package mapping exists for these families; offering it would deploy a config with no compositor.
|
// No mango package mapping exists for these families; offering it would deploy a config with no compositor.
|
||||||
switch distros.Registry[m.osInfo.Distribution.ID].Family {
|
switch distros.Registry[m.osInfo.Distribution.ID].Family {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func (m Model) viewWelcome() string {
|
|||||||
b.WriteString(m.styles.Accent.Render(strings.Repeat("━", 58)))
|
b.WriteString(m.styles.Accent.Render(strings.Repeat("━", 58)))
|
||||||
b.WriteByte('\n')
|
b.WriteByte('\n')
|
||||||
b.WriteString(m.styles.TitleBox.Render(
|
b.WriteString(m.styles.TitleBox.Render(
|
||||||
m.styles.Highlight.Render("dankinstall") + m.styles.AccentItalic.Render(" // Dux Labs Installer")))
|
m.styles.Highlight.Render("duxinstall") + m.styles.AccentItalic.Render(" // Dux Labs Installer")))
|
||||||
b.WriteByte('\n')
|
b.WriteByte('\n')
|
||||||
b.WriteString(m.styles.SubtleItalic.Render("Quickstart for a Dux Desktop"))
|
b.WriteString(m.styles.SubtleItalic.Render("Quickstart for a Dux Desktop"))
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
@@ -26,6 +26,14 @@ func (m Model) viewWelcome() string {
|
|||||||
switch {
|
switch {
|
||||||
case m.osInfo == nil && m.isLoading:
|
case m.osInfo == nil && m.isLoading:
|
||||||
fmt.Fprintf(&b, "%s %s\n\n", m.spinner.View(), m.styles.Normal.Render("Detecting system..."))
|
fmt.Fprintf(&b, "%s %s\n\n", m.spinner.View(), m.styles.Normal.Render("Detecting system..."))
|
||||||
|
case m.osInfo != nil && m.osInfo.Immutable && !m.distroSupported():
|
||||||
|
m.writeImmutableSystem(&b)
|
||||||
|
case m.osInfo != nil && m.osInfo.Immutable && m.resume != nil:
|
||||||
|
m.writeSystemSummary(&b)
|
||||||
|
m.writeResumeNotice(&b)
|
||||||
|
case m.osInfo != nil && m.osInfo.Immutable:
|
||||||
|
m.writeSystemSummary(&b)
|
||||||
|
m.writeImmutableNotice(&b)
|
||||||
case m.osInfo != nil && distros.IsUnsupportedDistro(m.osInfo.Distribution.ID, m.osInfo.VersionID):
|
case m.osInfo != nil && distros.IsUnsupportedDistro(m.osInfo.Distribution.ID, m.osInfo.VersionID):
|
||||||
m.writeUnsupportedDistro(&b)
|
m.writeUnsupportedDistro(&b)
|
||||||
case m.osInfo != nil:
|
case m.osInfo != nil:
|
||||||
@@ -46,6 +54,49 @@ func (m Model) writeUnsupportedDistro(b *strings.Builder) {
|
|||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) writeImmutableSystem(b *strings.Builder) {
|
||||||
|
title := m.styles.Warning.Bold(true).Render("⚠ IMAGE-BASED SYSTEM")
|
||||||
|
var paragraphs []string
|
||||||
|
for _, p := range strings.Split(distros.ImmutableGuidance(m.osInfo), "\n\n") {
|
||||||
|
paragraphs = append(paragraphs, wrapText(p, 70))
|
||||||
|
}
|
||||||
|
msg := m.styles.Normal.Render(strings.Join(paragraphs, "\n\n"))
|
||||||
|
b.WriteString(m.styles.ErrorBox.Render(title + "\n\n" + msg))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) writeImmutableNotice(b *strings.Builder) {
|
||||||
|
title := m.styles.Warning.Bold(true).Render("Image-based system: " + m.osInfo.ImmutableReason)
|
||||||
|
msg := wrapText("duxinstall will not install packages or ask for a password here. It checks what is installed, lists anything missing with the command to layer it, and deploys configuration into your home directory.", 70)
|
||||||
|
b.WriteString(m.styles.InfoBox.Render(title + "\n" + m.styles.Normal.Render(msg)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) writeResumeNotice(b *strings.Builder) {
|
||||||
|
title := m.styles.Success.Bold(true).Render(fmt.Sprintf("▶ Resuming setup: %s with %s", m.resume.WindowManager, m.resume.Terminal))
|
||||||
|
msg := wrapText("Press Enter to finish. duxinstall checks that the layered packages are installed and deploys configuration into your home directory. No packages are installed and no password is needed.", 70)
|
||||||
|
b.WriteString(m.styles.InfoBox.Render(title + "\n" + m.styles.Normal.Render(msg)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) isImmutable() bool {
|
||||||
|
return m.osInfo != nil && m.osInfo.Immutable
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) distroSupported() bool {
|
||||||
|
return m.osInfo != nil && distros.IsDistroSupported(m.osInfo.Distribution.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) cannotContinue() bool {
|
||||||
|
if m.osInfo == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if m.osInfo.Immutable {
|
||||||
|
return !m.distroSupported()
|
||||||
|
}
|
||||||
|
return distros.IsUnsupportedDistro(m.osInfo.Distribution.ID, m.osInfo.VersionID)
|
||||||
|
}
|
||||||
|
|
||||||
func unsupportedDistroMessage(info *distros.OSInfo) string {
|
func unsupportedDistroMessage(info *distros.OSInfo) string {
|
||||||
switch info.Distribution.ID {
|
switch info.Distribution.ID {
|
||||||
case "ubuntu":
|
case "ubuntu":
|
||||||
@@ -114,12 +165,17 @@ func (m Model) writeWelcomeFooter(b *strings.Builder) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctrlKey := m.styles.Highlight.Render("Ctrl+C")
|
ctrlKey := m.styles.Highlight.Render("Ctrl+C")
|
||||||
if distros.IsUnsupportedDistro(m.osInfo.Distribution.ID, m.osInfo.VersionID) {
|
if m.cannotContinue() {
|
||||||
b.WriteString(m.styles.Subtle.Render("Press ") + ctrlKey + m.styles.Subtle.Render(" to quit"))
|
b.WriteString(m.styles.Subtle.Render("Press ") + ctrlKey + m.styles.Subtle.Render(" to quit"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
enterKey := m.styles.Highlight.Render("Enter")
|
enterKey := m.styles.Highlight.Render("Enter")
|
||||||
|
if m.resume != nil {
|
||||||
|
b.WriteString(m.styles.Subtle.Render("Press ") + enterKey + m.styles.Subtle.Render(" to resume setup, ") +
|
||||||
|
m.styles.Highlight.Render("S") + m.styles.Subtle.Render(" to start over, ") + ctrlKey + m.styles.Subtle.Render(" to quit"))
|
||||||
|
return
|
||||||
|
}
|
||||||
b.WriteString(m.styles.Subtle.Render("Press ") + enterKey +
|
b.WriteString(m.styles.Subtle.Render("Press ") + enterKey +
|
||||||
m.styles.Subtle.Render(" to choose window manager, ") + ctrlKey + m.styles.Subtle.Render(" to quit"))
|
m.styles.Subtle.Render(" to choose window manager, ") + ctrlKey + m.styles.Subtle.Render(" to quit"))
|
||||||
}
|
}
|
||||||
@@ -134,15 +190,31 @@ func (m Model) updateWelcomeState(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, m.listenForLogs()
|
return m, m.listenForLogs()
|
||||||
}
|
}
|
||||||
m.osInfo = msg.info
|
m.osInfo = msg.info
|
||||||
|
if m.isImmutable() && m.distroSupported() {
|
||||||
|
m.resume = loadLayerResume()
|
||||||
|
}
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
if msg.String() != "enter" {
|
if m.cannotContinue() {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if m.osInfo == nil || distros.IsUnsupportedDistro(m.osInfo.Distribution.ID, m.osInfo.VersionID) {
|
switch msg.String() {
|
||||||
break
|
case "s", "S":
|
||||||
|
if m.resume == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
clearLayerResume()
|
||||||
|
m.resume = nil
|
||||||
|
case "enter":
|
||||||
|
if m.applyLayerResume() {
|
||||||
|
m.state = StateDetectingDeps
|
||||||
|
m.isLoading = true
|
||||||
|
return m, tea.Batch(m.spinner.Tick, m.detectDependencies())
|
||||||
|
}
|
||||||
|
clearLayerResume()
|
||||||
|
m.resume = nil
|
||||||
|
m.state = StateSelectWindowManager
|
||||||
}
|
}
|
||||||
m.state = StateSelectWindowManager
|
|
||||||
}
|
}
|
||||||
return m, m.listenForLogs()
|
return m, m.listenForLogs()
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,11 @@ package utils
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
|
||||||
|
"github.com/duxlabs-dev/dux-shell/core/internal/privesc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunDmsGreeterInstall delegates greeter setup to the standalone dms-greeter
|
// RunDmsGreeterInstall delegates greeter setup to the standalone dms-greeter
|
||||||
@@ -15,12 +17,11 @@ func RunDmsGreeterInstall(sudoPassword string, logFunc func(string)) error {
|
|||||||
return fmt.Errorf("dms-greeter binary not found; install the dms-greeter package and run 'dms-greeter install'")
|
return fmt.Errorf("dms-greeter binary not found; install the dms-greeter package and run 'dms-greeter install'")
|
||||||
}
|
}
|
||||||
|
|
||||||
var cmd *exec.Cmd
|
// Without a password dms-greeter escalates on its own.
|
||||||
if sudoPassword == "" {
|
argv := []string{binary, "install", "--yes"}
|
||||||
cmd = exec.Command(binary, "install", "--yes")
|
cmd := exec.Command(argv[0], argv[1:]...)
|
||||||
} else {
|
if sudoPassword != "" {
|
||||||
cmd = exec.Command("sudo", "-S", "-p", "", binary, "install", "--yes")
|
cmd = privesc.ExecCommand(context.Background(), sudoPassword, privesc.QuoteArgsForShell(argv))
|
||||||
cmd.Stdin = strings.NewReader(sudoPassword + "\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stdout, err := cmd.StdoutPipe()
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
|||||||
@@ -11,19 +11,17 @@ import (
|
|||||||
"github.com/duxlabs-dev/dux-shell/core/internal/windowrules"
|
"github.com/duxlabs-dev/dux-shell/core/internal/windowrules"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Mango window rules are flat `windowrule=key:value,...` lines (`window_rule=`
|
// DMS-managed rules live in dms/windowrules.conf (sourced from config.conf),
|
||||||
// with snake_case fields after the upstream rename; both are read). DMS-managed rules
|
// each preceded by an `# @id=<id> @name=<name>` comment so they round-trip.
|
||||||
// live in dms/windowrules.conf (sourced from config.conf), each preceded by an
|
|
||||||
// `# @id=<id> @name=<name>` comment so they round-trip.
|
|
||||||
|
|
||||||
type MangoWindowRule struct {
|
type MangoWindowRule struct {
|
||||||
Source string
|
Source string
|
||||||
Fields map[string]string
|
Fields map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
var mangoWindowRuleRegex = regexp.MustCompile(`^(?:windowrule|window_rule)\s*=\s*(.+)$`)
|
var mangoWindowRuleRegex = regexp.MustCompile(`^window_rule\s*=\s*(.+)$`)
|
||||||
var mangoMetaCommentRegex = regexp.MustCompile(`^#\s*@id=(\S*)\s*@name=(.*)$`)
|
var mangoMetaCommentRegex = regexp.MustCompile(`^#\s*@id=(\S*)\s*@name=(.*)$`)
|
||||||
var mangoOnceRuleRegex = regexp.MustCompile(`^(?:windowrule-once|window_rule_once)\s*=`)
|
var mangoOnceRuleRegex = regexp.MustCompile(`^window_rule_once\s*=`)
|
||||||
|
|
||||||
// Fields the editor models; anything else on a DMS rule line is carried through saves.
|
// Fields the editor models; anything else on a DMS rule line is carried through saves.
|
||||||
var mangoModeledFields = map[string]bool{
|
var mangoModeledFields = map[string]bool{
|
||||||
@@ -42,7 +40,7 @@ func mangoUnmodeledFields(value string) [][2]string {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := mangoconf.Normalize(strings.TrimSpace(k))
|
key := strings.TrimSpace(k)
|
||||||
sized := (key == "width" || key == "height") && (!hasWidth || !hasHeight)
|
sized := (key == "width" || key == "height") && (!hasWidth || !hasHeight)
|
||||||
if key != "" && (!mangoModeledFields[key] || sized) {
|
if key != "" && (!mangoModeledFields[key] || sized) {
|
||||||
extra = append(extra, [2]string{key, strings.TrimSpace(v)})
|
extra = append(extra, [2]string{key, strings.TrimSpace(v)})
|
||||||
@@ -62,7 +60,7 @@ func parseMangoWindowRuleLine(value string) map[string]string {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := mangoconf.Normalize(strings.TrimSpace(before))
|
key := strings.TrimSpace(before)
|
||||||
val := strings.TrimSpace(after)
|
val := strings.TrimSpace(after)
|
||||||
if key != "" {
|
if key != "" {
|
||||||
fields[key] = val
|
fields[key] = val
|
||||||
@@ -89,7 +87,7 @@ func mangoOverridePath(configDir string) string {
|
|||||||
return filepath.Join(configDir, "dms", "windowrules.conf")
|
return filepath.Join(configDir, "dms", "windowrules.conf")
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseMangoRulesFile reads a config file and returns its windowrule= lines.
|
// parseMangoRulesFile reads a config file and returns its window_rule= lines.
|
||||||
func parseMangoRulesFile(path, source string) []MangoWindowRule {
|
func parseMangoRulesFile(path, source string) []MangoWindowRule {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -215,11 +213,11 @@ func ConvertMangoRulesToWindowRules(mangoRules []MangoWindowRule) []windowrules.
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatMangoRule(rule windowrules.WindowRule, dialect mangoconf.Dialect, extra ...[2]string) string {
|
func formatMangoRule(rule windowrules.WindowRule, extra ...[2]string) string {
|
||||||
var parts []string
|
var parts []string
|
||||||
add := func(k, v string) {
|
add := func(k, v string) {
|
||||||
if v != "" {
|
if v != "" {
|
||||||
parts = append(parts, dialect.Key(k)+":"+v)
|
parts = append(parts, k+":"+v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +233,7 @@ func formatMangoRule(rule windowrules.WindowRule, dialect mangoconf.Dialect, ext
|
|||||||
|
|
||||||
addBool := func(k string, b *bool) {
|
addBool := func(k string, b *bool) {
|
||||||
if b != nil {
|
if b != nil {
|
||||||
parts = append(parts, dialect.Key(k)+":"+mangoBoolStr(b))
|
parts = append(parts, k+":"+mangoBoolStr(b))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
addBool("is_floating", rule.Actions.OpenFloating)
|
addBool("is_floating", rule.Actions.OpenFloating)
|
||||||
@@ -249,19 +247,18 @@ func formatMangoRule(rule windowrules.WindowRule, dialect mangoconf.Dialect, ext
|
|||||||
add(kv[0], kv[1])
|
add(kv[0], kv[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
return dialect.Key("window_rule") + "=" + strings.Join(parts, ",")
|
return "window_rule=" + strings.Join(parts, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
type MangoWritableProvider struct {
|
type MangoWritableProvider struct {
|
||||||
configDir string
|
configDir string
|
||||||
dialect mangoconf.Dialect
|
|
||||||
// Filled by LoadDMSRules so the following write keeps what the editor cannot show.
|
// Filled by LoadDMSRules so the following write keeps what the editor cannot show.
|
||||||
extraFields map[string][][2]string
|
extraFields map[string][][2]string
|
||||||
onceLines []string
|
onceLines []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMangoWritableProvider(configDir string) *MangoWritableProvider {
|
func NewMangoWritableProvider(configDir string) *MangoWritableProvider {
|
||||||
return &MangoWritableProvider{configDir: configDir, dialect: mangoconf.Detect()}
|
return &MangoWritableProvider{configDir: configDir}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *MangoWritableProvider) Name() string { return "mango" }
|
func (p *MangoWritableProvider) Name() string { return "mango" }
|
||||||
@@ -360,7 +357,7 @@ func (p *MangoWritableProvider) WriteDMSRules(rules []windowrules.WindowRule) er
|
|||||||
if id == "" {
|
if id == "" {
|
||||||
id = fmt.Sprintf("rule_%d", i)
|
id = fmt.Sprintf("rule_%d", i)
|
||||||
}
|
}
|
||||||
line := formatMangoRule(r, p.dialect, p.extraFields[r.ID]...)
|
line := formatMangoRule(r, p.extraFields[r.ID]...)
|
||||||
if err := mangoconf.CheckLine(line); err != nil {
|
if err := mangoconf.CheckLine(line); err != nil {
|
||||||
return fmt.Errorf("rule %q: %w", r.Name, err)
|
return fmt.Errorf("rule %q: %w", r.Name, err)
|
||||||
}
|
}
|
||||||
@@ -369,7 +366,7 @@ func (p *MangoWritableProvider) WriteDMSRules(rules []windowrules.WindowRule) er
|
|||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
for _, line := range p.onceLines {
|
for _, line := range p.onceLines {
|
||||||
sb.WriteString(p.dialect.Translate(line))
|
sb.WriteString(line)
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,13 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
|
|
||||||
"github.com/duxlabs-dev/dux-shell/core/internal/windowrules"
|
"github.com/duxlabs-dev/dux-shell/core/internal/windowrules"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseMangoWindowRuleLineReadsBothDialects(t *testing.T) {
|
func TestParseMangoWindowRuleLineSplitsFields(t *testing.T) {
|
||||||
for _, line := range []string{
|
for _, line := range []string{
|
||||||
"appid:firefox,title:Gmail,isfloating:1,tags:2,monitor:HDMI-A-1",
|
|
||||||
"app_id:firefox,title:Gmail,is_floating:1,tags:2,monitor:HDMI-A-1",
|
"app_id:firefox,title:Gmail,is_floating:1,tags:2,monitor:HDMI-A-1",
|
||||||
|
"app_id: firefox , title:Gmail,is_floating:1,tags:2,monitor:HDMI-A-1",
|
||||||
} {
|
} {
|
||||||
fields := parseMangoWindowRuleLine(line)
|
fields := parseMangoWindowRuleLine(line)
|
||||||
want := map[string]string{"app_id": "firefox", "title": "Gmail", "is_floating": "1", "tags": "2", "monitor": "HDMI-A-1"}
|
want := map[string]string{"app_id": "firefox", "title": "Gmail", "is_floating": "1", "tags": "2", "monitor": "HDMI-A-1"}
|
||||||
@@ -25,13 +24,10 @@ func TestParseMangoWindowRuleLineReadsBothDialects(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMangoRulesWrittenInInstalledDialect(t *testing.T) {
|
func TestMangoRulesWrittenInSnakeCase(t *testing.T) {
|
||||||
floating := true
|
floating := true
|
||||||
rule := windowrules.WindowRule{MatchCriteria: windowrules.MatchCriteria{AppID: "discord"}, Actions: windowrules.Actions{OpenFloating: &floating, NoAnim: &floating}}
|
rule := windowrules.WindowRule{MatchCriteria: windowrules.MatchCriteria{AppID: "discord"}, Actions: windowrules.Actions{OpenFloating: &floating, NoAnim: &floating}}
|
||||||
if got, want := formatMangoRule(rule, mangoconf.Legacy), "windowrule=appid:discord,isfloating:1,isnoanimation:1"; got != want {
|
if got, want := formatMangoRule(rule), "window_rule=app_id:discord,is_floating:1,no_animation:1"; got != want {
|
||||||
t.Errorf("legacy = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
if got, want := formatMangoRule(rule, mangoconf.Snake), "window_rule=app_id:discord,is_floating:1,no_animation:1"; got != want {
|
|
||||||
t.Errorf("snake = %q, want %q", got, want)
|
t.Errorf("snake = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +35,7 @@ func TestMangoRulesWrittenInInstalledDialect(t *testing.T) {
|
|||||||
rulesPath := filepath.Join(dir, "dms", "windowrules.conf")
|
rulesPath := filepath.Join(dir, "dms", "windowrules.conf")
|
||||||
_ = os.MkdirAll(filepath.Dir(rulesPath), 0o755)
|
_ = os.MkdirAll(filepath.Dir(rulesPath), 0o755)
|
||||||
_ = os.WriteFile(rulesPath, []byte("# @id=a @name=A\nwindow_rule=app_id:discord,is_floating:1\n"), 0o644)
|
_ = os.WriteFile(rulesPath, []byte("# @id=a @name=A\nwindow_rule=app_id:discord,is_floating:1\n"), 0o644)
|
||||||
loaded, err := (&MangoWritableProvider{configDir: dir, dialect: mangoconf.Snake}).LoadDMSRules()
|
loaded, err := (&MangoWritableProvider{configDir: dir}).LoadDMSRules()
|
||||||
if err != nil || len(loaded) != 1 || loaded[0].MatchCriteria.AppID != "discord" || loaded[0].Actions.OpenFloating == nil {
|
if err != nil || len(loaded) != 1 || loaded[0].MatchCriteria.AppID != "discord" || loaded[0].Actions.OpenFloating == nil {
|
||||||
t.Fatalf("snake rule not loaded: %+v, %v", loaded, err)
|
t.Fatalf("snake rule not loaded: %+v, %v", loaded, err)
|
||||||
}
|
}
|
||||||
@@ -47,7 +43,7 @@ func TestMangoRulesWrittenInInstalledDialect(t *testing.T) {
|
|||||||
|
|
||||||
func TestConvertMangoRulesToWindowRules(t *testing.T) {
|
func TestConvertMangoRulesToWindowRules(t *testing.T) {
|
||||||
mangoRules := []MangoWindowRule{
|
mangoRules := []MangoWindowRule{
|
||||||
{Source: "config.conf", Fields: parseMangoWindowRuleLine("appid:discord,tags:9,isfloating:1,noblur:1")},
|
{Source: "config.conf", Fields: parseMangoWindowRuleLine("app_id:discord,tags:9,is_floating:1,no_blur:1")},
|
||||||
}
|
}
|
||||||
rules := ConvertMangoRulesToWindowRules(mangoRules)
|
rules := ConvertMangoRulesToWindowRules(mangoRules)
|
||||||
if len(rules) != 1 {
|
if len(rules) != 1 {
|
||||||
@@ -141,9 +137,9 @@ func TestMangoRuleSaveKeepsUnmodeledFieldsAndOnceRules(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "dms", "windowrules.conf")
|
path := filepath.Join(dir, "dms", "windowrules.conf")
|
||||||
_ = os.MkdirAll(filepath.Dir(path), 0o755)
|
_ = os.MkdirAll(filepath.Dir(path), 0o755)
|
||||||
_ = os.WriteFile(path, []byte("# @id=a @name=A\nwindowrule=appid:mpv,isglobal:1,offsetx:20,width:0.5\nwindowrule-once=appid:firefox,tags:2\n"), 0o644)
|
_ = os.WriteFile(path, []byte("# @id=a @name=A\nwindow_rule=app_id:mpv,is_global:1,offset_x:20,width:0.5\nwindow_rule_once=app_id:firefox,tags:2\n"), 0o644)
|
||||||
|
|
||||||
p := &MangoWritableProvider{configDir: dir, dialect: mangoconf.Legacy}
|
p := &MangoWritableProvider{configDir: dir}
|
||||||
floating := true
|
floating := true
|
||||||
if err := p.SetRule(windowrules.WindowRule{ID: "b", Name: "B", MatchCriteria: windowrules.MatchCriteria{AppID: "foot"}, Actions: windowrules.Actions{OpenFloating: &floating}}); err != nil {
|
if err := p.SetRule(windowrules.WindowRule{ID: "b", Name: "B", MatchCriteria: windowrules.MatchCriteria{AppID: "foot"}, Actions: windowrules.Actions{OpenFloating: &floating}}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -151,9 +147,9 @@ func TestMangoRuleSaveKeepsUnmodeledFieldsAndOnceRules(t *testing.T) {
|
|||||||
data, _ := os.ReadFile(path)
|
data, _ := os.ReadFile(path)
|
||||||
got := string(data)
|
got := string(data)
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"windowrule=appid:mpv,isglobal:1,offsetx:20,width:0.5\n",
|
"window_rule=app_id:mpv,is_global:1,offset_x:20,width:0.5\n",
|
||||||
"windowrule=appid:foot,isfloating:1\n",
|
"window_rule=app_id:foot,is_floating:1\n",
|
||||||
"windowrule-once=appid:firefox,tags:2\n",
|
"window_rule_once=app_id:firefox,tags:2\n",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(got, want) {
|
if !strings.Contains(got, want) {
|
||||||
t.Errorf("missing %q in:\n%s", want, got)
|
t.Errorf("missing %q in:\n%s", want, got)
|
||||||
|
|||||||
@@ -19,9 +19,7 @@ core shell features.
|
|||||||
- **Users:** `pw(8)`.
|
- **Users:** `pw(8)`.
|
||||||
- **Package updates:** `pkg(8)`.
|
- **Package updates:** `pkg(8)`.
|
||||||
- **Compositor/socket discovery:** `sockstat(1)` where Linux would use `/proc`.
|
- **Compositor/socket discovery:** `sockstat(1)` where Linux would use `/proc`.
|
||||||
- **Greeter state:** FreeBSD rc service detection for greetd.
|
- **Sessions:** FreeBSD has no logind. DMS lists the current session only.
|
||||||
- **Sessions:** elogind/loginctl is used when installed; otherwise DMS exposes
|
|
||||||
the current session without repeatedly executing a missing Linux command.
|
|
||||||
|
|
||||||
## Recommended runtime tools
|
## Recommended runtime tools
|
||||||
|
|
||||||
@@ -30,13 +28,12 @@ Quickshell, grim/slurp, wl-clipboard, and a notification/audio stack as desired)
|
|||||||
from FreeBSD packages/ports. DMS's native audio path itself only needs the base
|
from FreeBSD packages/ports. DMS's native audio path itself only needs the base
|
||||||
system `mixer` utility.
|
system `mixer` utility.
|
||||||
|
|
||||||
For session switching and login-manager integration, install/use elogind so
|
|
||||||
`loginctl` is available. Without elogind, locking and the current-session shell
|
|
||||||
remain usable, but switching between already-running graphical sessions is not
|
|
||||||
provided by the base system.
|
|
||||||
|
|
||||||
## FreeBSD limitations that DMS should not fake
|
## FreeBSD limitations that DMS should not fake
|
||||||
|
|
||||||
|
- No logind, so the daemon's `org.freedesktop.login1` integration is off: no
|
||||||
|
lock or unlock requests from the session manager, no sleep inhibitor, and no
|
||||||
|
switching between running sessions (`dms switch-user`).
|
||||||
|
- Greeter status detection uses `systemctl` and reports greetd as not enabled.
|
||||||
- FreeBSD ACPI S4 hibernation is hardware/kernel dependent and is not presented
|
- FreeBSD ACPI S4 hibernation is hardware/kernel dependent and is not presented
|
||||||
as generally available by DMS.
|
as generally available by DMS.
|
||||||
- Charge thresholds are vendor-specific; the Linux `/sys/class/power_supply`
|
- Charge thresholds are vendor-specific; the Linux `/sys/class/power_supply`
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ include_package() {
|
|||||||
[[ "$PACKAGE_FILTER" == "all" || "$PACKAGE_FILTER" == "$package" ]]
|
[[ "$PACKAGE_FILTER" == "all" || "$PACKAGE_FILTER" == "$package" ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
CURRENT_COMMIT="$(git rev-parse --short=8 HEAD)"
|
CURRENT_COMMIT="$(git rev-parse HEAD)"
|
||||||
LATEST_TAG=""
|
LATEST_TAG=""
|
||||||
TARGETS=()
|
TARGETS=()
|
||||||
|
|
||||||
@@ -118,9 +118,9 @@ for pkg_info in "${PACKAGES[@]}"; do
|
|||||||
reason="missing from ${series}"
|
reason="missing from ${series}"
|
||||||
elif [[ "$type" == "git" ]]; then
|
elif [[ "$type" == "git" ]]; then
|
||||||
ppa_commit="$(embedded_commit "$ppa_version")"
|
ppa_commit="$(embedded_commit "$ppa_version")"
|
||||||
if [[ "$ppa_commit" != "$CURRENT_COMMIT" ]]; then
|
if [[ -z "$ppa_commit" || "${CURRENT_COMMIT:0:${#ppa_commit}}" != "$ppa_commit" ]]; then
|
||||||
needs_update=true
|
needs_update=true
|
||||||
reason="commit ${ppa_commit:-none} -> ${CURRENT_COMMIT}"
|
reason="commit ${ppa_commit:-none} -> ${CURRENT_COMMIT:0:9}"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
if [[ -z "$LATEST_TAG" ]]; then
|
if [[ -z "$LATEST_TAG" ]]; then
|
||||||
|
|||||||
@@ -126,4 +126,4 @@ sudo ln -sf /etc/sv/dbus /var/service/dbus
|
|||||||
sudo ln -sf /etc/sv/elogind /var/service/elogind
|
sudo ln -sf /etc/sv/elogind /var/service/elogind
|
||||||
```
|
```
|
||||||
|
|
||||||
The `dankinstall` Void path enables both services after installing packages.
|
The `duxinstall` Void path enables both services after installing packages.
|
||||||
+1
-1
Submodule dux-qml-common updated: ebf5e6c002...bb917ea071.
Generated
+3
-3
@@ -3,11 +3,11 @@
|
|||||||
"dank-qml-common": {
|
"dank-qml-common": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1792513666,
|
"lastModified": 1792829559,
|
||||||
"narHash": "sha256-RDqPdPrM5lnwRrfFGLz3IwgTjCV2Ie+nrTD3ldj1t/s=",
|
"narHash": "sha256-8J9RuGpmYCFRcpLlRQunqL0FqjsNSzlsK/QFZV2hm1A=",
|
||||||
"owner": "duxlabs-dev",
|
"owner": "duxlabs-dev",
|
||||||
"repo": "dux-qml-common",
|
"repo": "dux-qml-common",
|
||||||
"rev": "ebf5e6c002da1bdaa4aaa2f319fea7b8507abc42",
|
"rev": "bb917ea0718892c875ca0955939f63b31dc64e1e",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -111,7 +111,7 @@
|
|||||||
inherit version;
|
inherit version;
|
||||||
pname = "dms-shell";
|
pname = "dms-shell";
|
||||||
src = ./core;
|
src = ./core;
|
||||||
vendorHash = "sha256-fN7v7DuWaqhtD28cTr0FZUIyW7nUqhQoVuatsL2fuo4=";
|
vendorHash = "sha256-TmW9OM3NK2HOJmQGYj9coRRr2MYXZNdPnQ4K/bEJxSM=";
|
||||||
|
|
||||||
subPackages = [ "cmd/dms" ];
|
subPackages = [ "cmd/dms" ];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
|
||||||
|
// cli-policy.json switches, set by `dms run` on each launch (core/internal/clipolicy).
|
||||||
|
Singleton {
|
||||||
|
readonly property bool disableLockIpcUnlock: Quickshell.env("DMS_POLICY_DISABLE_LOCK_IPC_UNLOCK") === "1"
|
||||||
|
readonly property bool disableLogindUnlock: Quickshell.env("DMS_POLICY_DISABLE_LOGIND_UNLOCK") === "1"
|
||||||
|
readonly property bool disableCustomLockPam: Quickshell.env("DMS_POLICY_DISABLE_CUSTOM_LOCK_PAM") === "1"
|
||||||
|
readonly property bool disableUserPlugins: Quickshell.env("DMS_POLICY_DISABLE_USER_PLUGINS") === "1"
|
||||||
|
readonly property bool disableSettingsIpcSet: Quickshell.env("DMS_POLICY_DISABLE_SETTINGS_IPC_SET") === "1"
|
||||||
|
}
|
||||||
@@ -13,8 +13,6 @@ Singleton {
|
|||||||
|
|
||||||
readonly property int cacheConfigVersion: 3
|
readonly property int cacheConfigVersion: 3
|
||||||
|
|
||||||
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
|
|
||||||
|
|
||||||
readonly property string _stateUrl: StandardPaths.writableLocation(StandardPaths.GenericCacheLocation)
|
readonly property string _stateUrl: StandardPaths.writableLocation(StandardPaths.GenericCacheLocation)
|
||||||
readonly property string _stateDir: Paths.strip(_stateUrl)
|
readonly property string _stateDir: Paths.strip(_stateUrl)
|
||||||
|
|
||||||
@@ -52,8 +50,6 @@ Singleton {
|
|||||||
property var fileBrowserSettings: ({})
|
property var fileBrowserSettings: ({})
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
if (isGreeterMode)
|
|
||||||
return;
|
|
||||||
loadCache();
|
loadCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +276,7 @@ Singleton {
|
|||||||
FileView {
|
FileView {
|
||||||
id: launcherCacheFile
|
id: launcherCacheFile
|
||||||
|
|
||||||
path: isGreeterMode ? "" : _stateDir + "/dms/launcher_cache.json"
|
path: _stateDir + "/dms/launcher_cache.json"
|
||||||
blockLoading: true
|
blockLoading: true
|
||||||
blockWrites: true
|
blockWrites: true
|
||||||
atomicWrites: true
|
atomicWrites: true
|
||||||
@@ -290,19 +286,15 @@ Singleton {
|
|||||||
FileView {
|
FileView {
|
||||||
id: cacheFile
|
id: cacheFile
|
||||||
|
|
||||||
path: isGreeterMode ? "" : _stateDir + "/dms/cache.json"
|
path: _stateDir + "/dms/cache.json"
|
||||||
blockLoading: true
|
blockLoading: true
|
||||||
blockWrites: true
|
blockWrites: true
|
||||||
atomicWrites: true
|
atomicWrites: true
|
||||||
watchChanges: !isGreeterMode
|
watchChanges: true
|
||||||
onLoaded: {
|
onLoaded: {
|
||||||
if (isGreeterMode)
|
|
||||||
return;
|
|
||||||
parseCache(cacheFile.text());
|
parseCache(cacheFile.text());
|
||||||
}
|
}
|
||||||
onLoadFailed: error => {
|
onLoadFailed: error => {
|
||||||
if (isGreeterMode)
|
|
||||||
return;
|
|
||||||
log.info("No cache file found, starting fresh");
|
log.info("No cache file found, starting fresh");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File renamed without changes.
@@ -420,7 +420,7 @@ function parseMangoOutputs(content) {
|
|||||||
const result = {};
|
const result = {};
|
||||||
for (const line of content.split("\n")) {
|
for (const line of content.split("\n")) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
const match = trimmed.match(/^(?:monitorrule|monitor_rule)\s*=\s*(.*)$/);
|
const match = trimmed.match(/^monitor_rule\s*=\s*(.*)$/);
|
||||||
if (!match)
|
if (!match)
|
||||||
continue;
|
continue;
|
||||||
const entry = parseMangoRule(match[1]);
|
const entry = parseMangoRule(match[1]);
|
||||||
@@ -445,10 +445,10 @@ function mangoRuleFields(rule) {
|
|||||||
|
|
||||||
// Mango applies only the first matching monitor rule and resets hdr/icc/disable a rule omits,
|
// Mango applies only the first matching monitor rule and resets hdr/icc/disable a rule omits,
|
||||||
// so unmodeled fields of every existing rule for an output are carried into DMS's rule, first one winning.
|
// so unmodeled fields of every existing rule for an output are carried into DMS's rule, first one winning.
|
||||||
function mangoMonitorRuleLines(outputsData, existingText, snakeKeys) {
|
function mangoMonitorRuleLines(outputsData, existingText) {
|
||||||
const carried = {};
|
const carried = {};
|
||||||
for (const line of (existingText || "").split("\n")) {
|
for (const line of (existingText || "").split("\n")) {
|
||||||
const match = line.trim().match(/^(?:monitorrule|monitor_rule)\s*=\s*(.*)$/);
|
const match = line.trim().match(/^monitor_rule\s*=\s*(.*)$/);
|
||||||
if (!match)
|
if (!match)
|
||||||
continue;
|
continue;
|
||||||
const fields = mangoRuleFields(match[1]);
|
const fields = mangoRuleFields(match[1]);
|
||||||
@@ -496,7 +496,7 @@ function mangoMonitorRuleLines(outputsData, existingText, snakeKeys) {
|
|||||||
}
|
}
|
||||||
if (output.enabled === false)
|
if (output.enabled === false)
|
||||||
fields.push("disable:1");
|
fields.push("disable:1");
|
||||||
lines.push((snakeKeys ? "monitor_rule=" : "monitorrule=") + fields.join(","));
|
lines.push("monitor_rule=" + fields.join(","));
|
||||||
}
|
}
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
@@ -833,6 +833,16 @@ function physicalSize(output) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function roundLogical(value, compositor) {
|
||||||
|
switch (compositor) {
|
||||||
|
case "niri":
|
||||||
|
// niri rounds the placed output, smithay's Space ceils mapped ones, only ceil never overlaps in either order (#3875)
|
||||||
|
return Math.ceil(value);
|
||||||
|
default:
|
||||||
|
return Math.round(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function logicalSize(output, compositor) {
|
function logicalSize(output, compositor) {
|
||||||
if (!output)
|
if (!output)
|
||||||
return {
|
return {
|
||||||
@@ -841,19 +851,10 @@ function logicalSize(output, compositor) {
|
|||||||
};
|
};
|
||||||
const phys = physicalSize(output);
|
const phys = physicalSize(output);
|
||||||
const scale = output.logical?.scale || 1.0;
|
const scale = output.logical?.scale || 1.0;
|
||||||
switch (compositor) {
|
return {
|
||||||
case "niri":
|
"w": roundLogical(phys.w / scale, compositor),
|
||||||
// niri floors logical sizes, rounding up by one leaves a dead seam at snapped edges (#2526)
|
"h": roundLogical(phys.h / scale, compositor)
|
||||||
return {
|
};
|
||||||
"w": Math.floor(phys.w / scale),
|
|
||||||
"h": Math.floor(phys.h / scale)
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
"w": Math.round(phys.w / scale),
|
|
||||||
"h": Math.round(phys.h / scale)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultBounds() {
|
function defaultBounds() {
|
||||||
@@ -991,8 +992,8 @@ function recalculateAdjacentPositions(outputs, pendingChanges, changedOutput, ne
|
|||||||
if (!output?.logical)
|
if (!output?.logical)
|
||||||
return [];
|
return [];
|
||||||
const oldPhys = physicalSize(output);
|
const oldPhys = physicalSize(output);
|
||||||
const oldLogicalW = Math.round(oldPhys.w / (output.logical.scale || 1.0));
|
const oldLogicalW = roundLogical(oldPhys.w / (output.logical.scale || 1.0), compositor);
|
||||||
const newLogicalW = Math.round(oldPhys.w / newScale);
|
const newLogicalW = roundLogical(oldPhys.w / newScale, compositor);
|
||||||
const changedX = pendingChanges[changedOutput]?.position?.x ?? output.logical.x;
|
const changedX = pendingChanges[changedOutput]?.position?.x ?? output.logical.x;
|
||||||
const changedY = pendingChanges[changedOutput]?.position?.y ?? output.logical.y;
|
const changedY = pendingChanges[changedOutput]?.position?.y ?? output.logical.y;
|
||||||
const moves = [];
|
const moves = [];
|
||||||
|
|||||||
@@ -338,9 +338,102 @@ Singleton {
|
|||||||
_requestPopout(popout, tabIndex, triggerSource, true);
|
_requestPopout(popout, tabIndex, triggerSource, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _islandBarBand(host, screen) {
|
||||||
|
const vertical = host.edge === "left" || host.edge === "right";
|
||||||
|
if (host.embedded) {
|
||||||
|
const start = vertical ? host.hostOriginX : host.hostOriginY;
|
||||||
|
return {
|
||||||
|
"start": start,
|
||||||
|
"end": start + host.bandThickness
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (host.freeMode)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const extent = vertical ? screen.width : screen.height;
|
||||||
|
const instances = ShellLayout.forScreen(screen)?.instances ?? [];
|
||||||
|
let start = Infinity;
|
||||||
|
let end = -Infinity;
|
||||||
|
for (const instance of instances) {
|
||||||
|
if (instance.edge !== host.edge || instance.barId === host.barId || (instance.kind !== "bar" && instance.kind !== "frame") || instance.rowThickness <= 0)
|
||||||
|
continue;
|
||||||
|
const config = SettingsData.getBarConfig(instance.barId);
|
||||||
|
if (!config || config.enabled === false || config.visible === false)
|
||||||
|
continue;
|
||||||
|
const near = instance.rowOffset ?? 0;
|
||||||
|
const far = near + instance.rowThickness;
|
||||||
|
const bandStart = host.edge === "top" || host.edge === "left" ? near : extent - far;
|
||||||
|
const bandEnd = host.edge === "top" || host.edge === "left" ? far : extent - near;
|
||||||
|
start = Math.min(start, bandStart);
|
||||||
|
end = Math.max(end, bandEnd);
|
||||||
|
}
|
||||||
|
return end > start ? {
|
||||||
|
"start": start,
|
||||||
|
"end": end
|
||||||
|
} : null;
|
||||||
|
}
|
||||||
|
|
||||||
function getActiveSurfaces() {
|
function getActiveSurfaces() {
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
|
// The host registry covers embedded, standalone, and free Islands with their live visual bounds.
|
||||||
|
for (const key of Object.keys(IslandHostRegistry.hosts || {})) {
|
||||||
|
const host = IslandHostRegistry.hosts[key];
|
||||||
|
const screen = host?.screen;
|
||||||
|
const controller = host?.islandController;
|
||||||
|
const snapVisible = controller?.expanded || (!host?.embedded && host?.motionRunning);
|
||||||
|
if (!screen?.name || !snapVisible || host.surfaceOpacity <= 0)
|
||||||
|
continue;
|
||||||
|
const rect = host.surfaceBodyRect?.();
|
||||||
|
if (!rect || rect.width <= 0 || rect.height <= 0)
|
||||||
|
continue;
|
||||||
|
let x = rect.x;
|
||||||
|
let y = rect.y;
|
||||||
|
let width = rect.width;
|
||||||
|
let height = rect.height;
|
||||||
|
const barBand = _islandBarBand(host, screen);
|
||||||
|
if (barBand) {
|
||||||
|
switch (host.edge) {
|
||||||
|
case "top":
|
||||||
|
if (y < barBand.end && y + height > barBand.start) {
|
||||||
|
const inset = Math.max(0, Math.min(height, barBand.end - y));
|
||||||
|
y += inset;
|
||||||
|
height -= inset;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "bottom":
|
||||||
|
if (y + height > barBand.start && y < barBand.end)
|
||||||
|
height = Math.max(0, barBand.start - y);
|
||||||
|
break;
|
||||||
|
case "left":
|
||||||
|
if (x < barBand.end && x + width > barBand.start) {
|
||||||
|
const inset = Math.max(0, Math.min(width, barBand.end - x));
|
||||||
|
x += inset;
|
||||||
|
width -= inset;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "right":
|
||||||
|
if (x + width > barBand.start && x < barBand.end)
|
||||||
|
width = Math.max(0, barBand.start - x);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const left = Math.max(0, x);
|
||||||
|
const top = Math.max(0, y);
|
||||||
|
const right = Math.min(screen.width, x + width);
|
||||||
|
const bottom = Math.min(screen.height, y + height);
|
||||||
|
if (right <= left || bottom <= top)
|
||||||
|
continue;
|
||||||
|
results.push({
|
||||||
|
"name": "dms:dankisland-surface",
|
||||||
|
"type": "island",
|
||||||
|
"x": Math.round((screen.x ?? 0) + left),
|
||||||
|
"y": Math.round((screen.y ?? 0) + top),
|
||||||
|
"width": Math.round(right - left),
|
||||||
|
"height": Math.round(bottom - top)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const screenName in currentPopoutsByScreen) {
|
for (const screenName in currentPopoutsByScreen) {
|
||||||
const popout = currentPopoutsByScreen[screenName];
|
const popout = currentPopoutsByScreen[screenName];
|
||||||
if (!popout || !popout.shouldBeVisible)
|
if (!popout || !popout.shouldBeVisible)
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ Singleton {
|
|||||||
|
|
||||||
readonly property int sessionConfigVersion: 7
|
readonly property int sessionConfigVersion: 7
|
||||||
|
|
||||||
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
|
|
||||||
|
|
||||||
signal loaded
|
signal loaded
|
||||||
signal brightnessDisplayHintChanged(string deviceName)
|
signal brightnessDisplayHintChanged(string deviceName)
|
||||||
signal loadErrorOccurred(string file, string message)
|
signal loadErrorOccurred(string file, string message)
|
||||||
@@ -326,11 +324,6 @@ Singleton {
|
|||||||
_hasUnsavedChanges = false;
|
_hasUnsavedChanges = false;
|
||||||
_pendingMigration = null;
|
_pendingMigration = null;
|
||||||
|
|
||||||
if (isGreeterMode) {
|
|
||||||
parseSettings(greeterSessionFile.text());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const txt = settingsFile.text();
|
const txt = settingsFile.text();
|
||||||
let obj = (txt && txt.trim()) ? JSON.parse(txt) : null;
|
let obj = (txt && txt.trim()) ? JSON.parse(txt) : null;
|
||||||
@@ -369,7 +362,7 @@ Singleton {
|
|||||||
_loadedSessionSnapshot = getCurrentSessionJson();
|
_loadedSessionSnapshot = getCurrentSessionJson();
|
||||||
_hasLoaded = true;
|
_hasLoaded = true;
|
||||||
|
|
||||||
if (!isGreeterMode && typeof Theme !== "undefined")
|
if (typeof Theme !== "undefined")
|
||||||
Theme.generateSystemThemesFromCurrentTheme();
|
Theme.generateSystemThemesFromCurrentTheme();
|
||||||
|
|
||||||
loaded();
|
loaded();
|
||||||
@@ -452,7 +445,7 @@ Singleton {
|
|||||||
_loadedSessionSnapshot = getCurrentSessionJson();
|
_loadedSessionSnapshot = getCurrentSessionJson();
|
||||||
_hasLoaded = true;
|
_hasLoaded = true;
|
||||||
|
|
||||||
if (!isGreeterMode && typeof Theme !== "undefined")
|
if (typeof Theme !== "undefined")
|
||||||
Theme.generateSystemThemesFromCurrentTheme();
|
Theme.generateSystemThemesFromCurrentTheme();
|
||||||
|
|
||||||
loaded();
|
loaded();
|
||||||
@@ -485,7 +478,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function saveSettings() {
|
function saveSettings() {
|
||||||
if (isGreeterMode || _parseError || !_hasLoaded)
|
if (_parseError || !_hasLoaded)
|
||||||
return;
|
return;
|
||||||
settingsFile.setText(getCurrentSessionJson());
|
settingsFile.setText(getCurrentSessionJson());
|
||||||
if (_isReadOnly)
|
if (_isReadOnly)
|
||||||
@@ -859,8 +852,6 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _screenShareShouldHold() {
|
function _screenShareShouldHold() {
|
||||||
if (isGreeterMode)
|
|
||||||
return false;
|
|
||||||
if (typeof SettingsData === "undefined" || !SettingsData.notificationDndWhileScreenSharing)
|
if (typeof SettingsData === "undefined" || !SettingsData.notificationDndWhileScreenSharing)
|
||||||
return false;
|
return false;
|
||||||
if (typeof PrivacyService === "undefined" || !PrivacyService.screensharingActive)
|
if (typeof PrivacyService === "undefined" || !PrivacyService.screensharingActive)
|
||||||
@@ -909,9 +900,6 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncScreenShareDnd() {
|
function syncScreenShareDnd() {
|
||||||
if (isGreeterMode)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (!_screenShareShouldHold()) {
|
if (!_screenShareShouldHold()) {
|
||||||
if (!doNotDisturbHeldByScreenShare && !screenShareDndDismissed)
|
if (!doNotDisturbHeldByScreenShare && !screenShareDndDismissed)
|
||||||
return;
|
return;
|
||||||
@@ -1883,14 +1871,12 @@ Singleton {
|
|||||||
FileView {
|
FileView {
|
||||||
id: settingsFile
|
id: settingsFile
|
||||||
|
|
||||||
path: isGreeterMode ? "" : StandardPaths.writableLocation(StandardPaths.GenericStateLocation) + "/dms/session.json"
|
path: StandardPaths.writableLocation(StandardPaths.GenericStateLocation) + "/dms/session.json"
|
||||||
blockLoading: true
|
blockLoading: true
|
||||||
blockWrites: true
|
blockWrites: true
|
||||||
atomicWrites: true
|
atomicWrites: true
|
||||||
watchChanges: !isGreeterMode
|
watchChanges: true
|
||||||
onLoaded: {
|
onLoaded: {
|
||||||
if (isGreeterMode)
|
|
||||||
return;
|
|
||||||
_hasUnsavedChanges = false;
|
_hasUnsavedChanges = false;
|
||||||
parseSettings(settingsFile.text());
|
parseSettings(settingsFile.text());
|
||||||
}
|
}
|
||||||
@@ -1900,39 +1886,6 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly property string _greeterCacheDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
|
|
||||||
|
|
||||||
property string greeterSessionBaseDir: root._greeterCacheDir
|
|
||||||
|
|
||||||
function setGreeterSessionBaseDir(dir) {
|
|
||||||
const next = dir || root._greeterCacheDir;
|
|
||||||
if (greeterSessionBaseDir === next)
|
|
||||||
return;
|
|
||||||
greeterSessionBaseDir = next;
|
|
||||||
if (isGreeterMode)
|
|
||||||
greeterSessionFile.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetGreeterSessionBaseDir() {
|
|
||||||
setGreeterSessionBaseDir(root._greeterCacheDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
FileView {
|
|
||||||
id: greeterSessionFile
|
|
||||||
|
|
||||||
path: isGreeterMode && root.greeterSessionBaseDir ? (root.greeterSessionBaseDir + "/session.json") : ""
|
|
||||||
preload: isGreeterMode
|
|
||||||
blockLoading: false
|
|
||||||
blockWrites: true
|
|
||||||
watchChanges: false
|
|
||||||
printErrors: true
|
|
||||||
onLoaded: {
|
|
||||||
if (isGreeterMode) {
|
|
||||||
parseSettings(greeterSessionFile.text());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: sessionWritableCheckProcess
|
id: sessionWritableCheckProcess
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ Singleton {
|
|||||||
|
|
||||||
readonly property int settingsConfigVersion: 42
|
readonly property int settingsConfigVersion: 42
|
||||||
|
|
||||||
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
|
|
||||||
|
|
||||||
enum Position {
|
enum Position {
|
||||||
Top,
|
Top,
|
||||||
Bottom,
|
Bottom,
|
||||||
@@ -1128,6 +1126,7 @@ Singleton {
|
|||||||
property string customPowerActionPowerOff: Spec.SPEC.customPowerActionPowerOff.def
|
property string customPowerActionPowerOff: Spec.SPEC.customPowerActionPowerOff.def
|
||||||
property var customPowerButtons: Spec.SPEC.customPowerButtons.def
|
property var customPowerButtons: Spec.SPEC.customPowerButtons.def
|
||||||
property var powerMenuBootEntries: Spec.SPEC.powerMenuBootEntries.def
|
property var powerMenuBootEntries: Spec.SPEC.powerMenuBootEntries.def
|
||||||
|
property bool powerMenuFirmwareSetup: Spec.SPEC.powerMenuFirmwareSetup.def
|
||||||
|
|
||||||
property bool updaterCheckOnStart: Spec.SPEC.updaterCheckOnStart.def
|
property bool updaterCheckOnStart: Spec.SPEC.updaterCheckOnStart.def
|
||||||
property bool updaterUseCustomCommand: Spec.SPEC.updaterUseCustomCommand.def
|
property bool updaterUseCustomCommand: Spec.SPEC.updaterUseCustomCommand.def
|
||||||
@@ -1435,8 +1434,6 @@ Singleton {
|
|||||||
signal notificationPopupsInvalidated
|
signal notificationPopupsInvalidated
|
||||||
|
|
||||||
function refreshAuthAvailability() {
|
function refreshAuthAvailability() {
|
||||||
if (isGreeterMode)
|
|
||||||
return;
|
|
||||||
Processes.detectAuthCapabilities();
|
Processes.detectAuthCapabilities();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1506,8 +1503,6 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function checkIconThemeDrift() {
|
function checkIconThemeDrift() {
|
||||||
if (isGreeterMode)
|
|
||||||
return;
|
|
||||||
if (resolveIconTheme() === "System Default")
|
if (resolveIconTheme() === "System Default")
|
||||||
return;
|
return;
|
||||||
if (!SessionData.lastAppliedIconTheme)
|
if (!SessionData.lastAppliedIconTheme)
|
||||||
@@ -1664,8 +1659,6 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduleAuthApply() {
|
function scheduleAuthApply() {
|
||||||
if (isGreeterMode)
|
|
||||||
return;
|
|
||||||
Qt.callLater(() => {
|
Qt.callLater(() => {
|
||||||
Processes.settingsRoot = root;
|
Processes.settingsRoot = root;
|
||||||
Processes.scheduleAuthApply();
|
Processes.scheduleAuthApply();
|
||||||
@@ -1673,8 +1666,6 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduleGreeterAutoLoginSync() {
|
function scheduleGreeterAutoLoginSync() {
|
||||||
if (isGreeterMode)
|
|
||||||
return;
|
|
||||||
Qt.callLater(() => {
|
Qt.callLater(() => {
|
||||||
Processes.settingsRoot = root;
|
Processes.settingsRoot = root;
|
||||||
Processes.scheduleGreeterAutoLoginSync();
|
Processes.scheduleGreeterAutoLoginSync();
|
||||||
@@ -1689,8 +1680,6 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function markGreeterSyncPending(who, key, oldValue) {
|
function markGreeterSyncPending(who, key, oldValue) {
|
||||||
if (isGreeterMode)
|
|
||||||
return;
|
|
||||||
if (!(key in SessionData.greeterSyncBaseline)) {
|
if (!(key in SessionData.greeterSyncBaseline)) {
|
||||||
var baseline = Object.assign({}, SessionData.greeterSyncBaseline);
|
var baseline = Object.assign({}, SessionData.greeterSyncBaseline);
|
||||||
baseline[key] = oldValue;
|
baseline[key] = oldValue;
|
||||||
@@ -1840,7 +1829,7 @@ Singleton {
|
|||||||
let loadedSettings = JSON.stringify(obj);
|
let loadedSettings = JSON.stringify(obj);
|
||||||
|
|
||||||
const oldVersion = obj?.configVersion ?? 0;
|
const oldVersion = obj?.configVersion ?? 0;
|
||||||
if (oldVersion > settingsConfigVersion && !isGreeterMode) {
|
if (oldVersion > settingsConfigVersion) {
|
||||||
newerSettingsBackup.path = `${_configDir}/dms/settings.v${oldVersion}.json`;
|
newerSettingsBackup.path = `${_configDir}/dms/settings.v${oldVersion}.json`;
|
||||||
newerSettingsBackup.setText(JSON.stringify(obj, null, 2));
|
newerSettingsBackup.setText(JSON.stringify(obj, null, 2));
|
||||||
}
|
}
|
||||||
@@ -2063,16 +2052,14 @@ Singleton {
|
|||||||
return splitSettings;
|
return splitSettings;
|
||||||
}
|
}
|
||||||
function saveSettings() {
|
function saveSettings() {
|
||||||
if (isGreeterMode || _loading || !_hasLoaded)
|
if (_loading || !_hasLoaded)
|
||||||
return;
|
return;
|
||||||
unsavedUserChanges = true;
|
unsavedUserChanges = true;
|
||||||
settingsSaveDebounce.restart();
|
settingsSaveDebounce.restart();
|
||||||
}
|
}
|
||||||
function _saveSettings(userChanges) {
|
function _saveSettings(userChanges) {
|
||||||
let reason = null;
|
let reason = null;
|
||||||
if (isGreeterMode) {
|
if (_loading) {
|
||||||
reason = "running in greeter mode.";
|
|
||||||
} else if (_loading) {
|
|
||||||
reason = "some files are being loaded";
|
reason = "some files are being loaded";
|
||||||
} else if (_parseError) {
|
} else if (_parseError) {
|
||||||
reason = "failed to parse settings.";
|
reason = "failed to parse settings.";
|
||||||
@@ -2092,7 +2079,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function savePluginSettings() {
|
function savePluginSettings() {
|
||||||
if (isGreeterMode || _pluginSettingsLoading || _pluginParseError)
|
if (_pluginSettingsLoading || _pluginParseError)
|
||||||
return;
|
return;
|
||||||
pluginSettingsFile.setText(JSON.stringify(pluginSettings, null, 2));
|
pluginSettingsFile.setText(JSON.stringify(pluginSettings, null, 2));
|
||||||
}
|
}
|
||||||
@@ -2402,7 +2389,7 @@ Singleton {
|
|||||||
let name = I18n.tr("Dock");
|
let name = I18n.tr("Dock");
|
||||||
let number = 2;
|
let number = 2;
|
||||||
while (dockConfigs.some(config => config.name === name))
|
while (dockConfigs.some(config => config.name === name))
|
||||||
name = I18n.tr("Dock") + " " + number++;
|
name = I18n.tr("Dock %1", "generated dock name, %1 is a number").arg(number++);
|
||||||
const config = DockConfig.create(id, name);
|
const config = DockConfig.create(id, name);
|
||||||
const edge = firstFreeDockEdge(id, config.screenPreferences);
|
const edge = firstFreeDockEdge(id, config.screenPreferences);
|
||||||
config.enabled = edge >= 0;
|
config.enabled = edge >= 0;
|
||||||
@@ -2859,6 +2846,9 @@ Singleton {
|
|||||||
id: "dot" + Date.now(),
|
id: "dot" + Date.now(),
|
||||||
name: I18n.tr("Dot", "bar layout: free-floating dot that opens island activities"),
|
name: I18n.tr("Dot", "bar layout: free-floating dot that opens island activities"),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
// A hidden or auto-hiding base bar would switch off the dot's OSD and notifications, and bar IPC refuses dot configs.
|
||||||
|
visible: true,
|
||||||
|
autoHide: false,
|
||||||
island: false,
|
island: false,
|
||||||
dot: true,
|
dot: true,
|
||||||
screenPreferences: ["all"],
|
screenPreferences: ["all"],
|
||||||
@@ -3725,11 +3715,11 @@ Singleton {
|
|||||||
property FileView fileView: FileView {
|
property FileView fileView: FileView {
|
||||||
id: settingsFileView
|
id: settingsFileView
|
||||||
|
|
||||||
path: isGreeterMode ? "" : filePath
|
path: filePath
|
||||||
blockLoading: false
|
blockLoading: false
|
||||||
blockWrites: true
|
blockWrites: true
|
||||||
atomicWrites: true
|
atomicWrites: true
|
||||||
watchChanges: !isGreeterMode
|
watchChanges: true
|
||||||
onFileChanged: {
|
onFileChanged: {
|
||||||
if (selfWrite) {
|
if (selfWrite) {
|
||||||
selfWrite = false;
|
selfWrite = false;
|
||||||
@@ -3740,9 +3730,6 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
onLoaded: {
|
onLoaded: {
|
||||||
if (isGreeterMode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
_loading = true;
|
_loading = true;
|
||||||
const hadParseFailed = hasParseFailed;
|
const hadParseFailed = hasParseFailed;
|
||||||
@@ -3776,9 +3763,6 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
onLoadFailed: error => {
|
onLoadFailed: error => {
|
||||||
if (isGreeterMode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
_loading = _anySettingsFile(file => file.isLoading);
|
_loading = _anySettingsFile(file => file.isLoading);
|
||||||
if (error === FileViewError.FileNotFound) {
|
if (error === FileViewError.FileNotFound) {
|
||||||
@@ -3922,7 +3906,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
property bool _settingsFolderExists: false
|
property bool _settingsFolderExists: false
|
||||||
property bool _settingsFolderChecked: isGreeterMode
|
property bool _settingsFolderChecked: false
|
||||||
function _syncSettingsFilesModels() {
|
function _syncSettingsFilesModels() {
|
||||||
if (!_settingsFolderChecked) {
|
if (!_settingsFolderChecked) {
|
||||||
settingsFilesModelSyncDebounce.restart();
|
settingsFilesModelSyncDebounce.restart();
|
||||||
@@ -3963,7 +3947,6 @@ Singleton {
|
|||||||
}
|
}
|
||||||
Loader {
|
Loader {
|
||||||
id: settingsFolderLoader
|
id: settingsFolderLoader
|
||||||
active: !isGreeterMode
|
|
||||||
function unloadModel() {
|
function unloadModel() {
|
||||||
active = false;
|
active = false;
|
||||||
}
|
}
|
||||||
@@ -4012,59 +3995,6 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly property string _greeterCacheDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
|
|
||||||
|
|
||||||
property string greeterSettingsBaseDir: root._greeterCacheDir
|
|
||||||
|
|
||||||
function setGreeterSettingsBaseDir(dir) {
|
|
||||||
const next = dir || root._greeterCacheDir;
|
|
||||||
if (greeterSettingsBaseDir === next)
|
|
||||||
return;
|
|
||||||
greeterSettingsBaseDir = next;
|
|
||||||
if (isGreeterMode)
|
|
||||||
greeterSettingsFile.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetGreeterSettingsBaseDir() {
|
|
||||||
setGreeterSettingsBaseDir(root._greeterCacheDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadGreeterSettings(txt) {
|
|
||||||
_loading = true;
|
|
||||||
_hasLoaded = false;
|
|
||||||
try {
|
|
||||||
Store.parse(root, (txt && txt.trim()) ? JSON.parse(txt) : {});
|
|
||||||
_parseError = false;
|
|
||||||
} catch (e) {
|
|
||||||
_parseError = true;
|
|
||||||
log.error("Failed to parse greeter settings.json:", e.message);
|
|
||||||
Store.parse(root, {});
|
|
||||||
} finally {
|
|
||||||
_loading = false;
|
|
||||||
}
|
|
||||||
_hasLoaded = true;
|
|
||||||
applyStoredTheme();
|
|
||||||
}
|
|
||||||
|
|
||||||
FileView {
|
|
||||||
id: greeterSettingsFile
|
|
||||||
|
|
||||||
path: isGreeterMode && root.greeterSettingsBaseDir ? (root.greeterSettingsBaseDir + "/settings.json") : ""
|
|
||||||
preload: isGreeterMode
|
|
||||||
blockLoading: false
|
|
||||||
blockWrites: true
|
|
||||||
watchChanges: false
|
|
||||||
printErrors: false
|
|
||||||
onLoaded: {
|
|
||||||
if (isGreeterMode)
|
|
||||||
loadGreeterSettings(greeterSettingsFile.text());
|
|
||||||
}
|
|
||||||
onLoadFailed: {
|
|
||||||
if (isGreeterMode)
|
|
||||||
loadGreeterSettings("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Our first save drops the keys a newer shell wrote, so its settings are kept for going back.
|
// Our first save drops the keys a newer shell wrote, so its settings are kept for going back.
|
||||||
FileView {
|
FileView {
|
||||||
id: newerSettingsBackup
|
id: newerSettingsBackup
|
||||||
@@ -4078,21 +4008,17 @@ Singleton {
|
|||||||
FileView {
|
FileView {
|
||||||
id: pluginSettingsFile
|
id: pluginSettingsFile
|
||||||
|
|
||||||
path: isGreeterMode ? "" : pluginSettingsPath
|
path: pluginSettingsPath
|
||||||
blockLoading: true
|
blockLoading: true
|
||||||
blockWrites: true
|
blockWrites: true
|
||||||
atomicWrites: true
|
atomicWrites: true
|
||||||
printErrors: false
|
printErrors: false
|
||||||
watchChanges: !isGreeterMode
|
watchChanges: true
|
||||||
onLoaded: {
|
onLoaded: {
|
||||||
if (isGreeterMode)
|
|
||||||
return;
|
|
||||||
pluginSettingsFileExists = true;
|
pluginSettingsFileExists = true;
|
||||||
parsePluginSettings(pluginSettingsFile.text());
|
parsePluginSettings(pluginSettingsFile.text());
|
||||||
}
|
}
|
||||||
onLoadFailed: error => {
|
onLoadFailed: error => {
|
||||||
if (isGreeterMode)
|
|
||||||
return;
|
|
||||||
const msg = String(error || "");
|
const msg = String(error || "");
|
||||||
const missing = _isMissingPluginSettingsError(error);
|
const missing = _isMissingPluginSettingsError(error);
|
||||||
pluginSettingsFileExists = !missing;
|
pluginSettingsFileExists = !missing;
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ Singleton {
|
|||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"id": "dock_general",
|
"id": "dock_general",
|
||||||
|
"hidden": true,
|
||||||
"text": I18n.tr("General"),
|
"text": I18n.tr("General"),
|
||||||
"icon": "tune",
|
"icon": "tune",
|
||||||
"tabIndex": 5,
|
"tabIndex": 5,
|
||||||
@@ -167,6 +168,7 @@ Singleton {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "dock_widgets",
|
"id": "dock_widgets",
|
||||||
|
"hidden": true,
|
||||||
"text": I18n.tr("Apps & widgets"),
|
"text": I18n.tr("Apps & widgets"),
|
||||||
"icon": "widgets",
|
"icon": "widgets",
|
||||||
"tabIndex": 57,
|
"tabIndex": 57,
|
||||||
@@ -174,6 +176,7 @@ Singleton {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "dock_appearance",
|
"id": "dock_appearance",
|
||||||
|
"hidden": true,
|
||||||
"text": I18n.tr("Appearance"),
|
"text": I18n.tr("Appearance"),
|
||||||
"icon": "palette",
|
"icon": "palette",
|
||||||
"tabIndex": 58,
|
"tabIndex": 58,
|
||||||
|
|||||||
+45
-53
@@ -13,7 +13,6 @@ import Quickshell.Io
|
|||||||
import qs.Common
|
import qs.Common
|
||||||
import qs.DCommon.Common as DCommon
|
import qs.DCommon.Common as DCommon
|
||||||
import qs.Services
|
import qs.Services
|
||||||
import qs.Modules.Greetd
|
|
||||||
import "StockThemes.js" as StockThemes
|
import "StockThemes.js" as StockThemes
|
||||||
import "GSettings.js" as GSettings
|
import "GSettings.js" as GSettings
|
||||||
|
|
||||||
@@ -1191,13 +1190,7 @@ Singleton {
|
|||||||
readonly property real groupedListInnerRadius: cornerRadiusXS
|
readonly property real groupedListInnerRadius: cornerRadiusXS
|
||||||
readonly property real groupedListOuterRadius: cornerRadiusL
|
readonly property real groupedListOuterRadius: cornerRadiusL
|
||||||
|
|
||||||
property string fontFamily: {
|
property string fontFamily: typeof SettingsData !== "undefined" ? resolvedFontFamily(SettingsData.fontFamily) : DCommon.Fonts.sans
|
||||||
if (typeof SettingsData === "undefined")
|
|
||||||
return DCommon.Fonts.sans;
|
|
||||||
if (SettingsData.isGreeterMode && SettingsData.lockScreenFontFamily !== "")
|
|
||||||
return resolvedFontFamily(SettingsData.lockScreenFontFamily);
|
|
||||||
return resolvedFontFamily(SettingsData.fontFamily);
|
|
||||||
}
|
|
||||||
|
|
||||||
property string monoFontFamily: typeof SettingsData !== "undefined" ? resolvedMonoFontFamily(SettingsData.monoFontFamily) : DCommon.Fonts.mono
|
property string monoFontFamily: typeof SettingsData !== "undefined" ? resolvedMonoFontFamily(SettingsData.monoFontFamily) : DCommon.Fonts.mono
|
||||||
property string displayFontFamily: typeof SettingsData !== "undefined" ? resolvedDisplayFontFamily(SettingsData.displayFontFamily) : DCommon.Fonts.display
|
property string displayFontFamily: typeof SettingsData !== "undefined" ? resolvedDisplayFontFamily(SettingsData.displayFontFamily) : DCommon.Fonts.display
|
||||||
@@ -1422,15 +1415,12 @@ Singleton {
|
|||||||
currentThemeCategory = "generic";
|
currentThemeCategory = "generic";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode);
|
if (savePrefs && typeof SettingsData !== "undefined") {
|
||||||
if (savePrefs && typeof SettingsData !== "undefined" && !isGreeterMode) {
|
|
||||||
SettingsData.set("currentThemeCategory", currentThemeCategory);
|
SettingsData.set("currentThemeCategory", currentThemeCategory);
|
||||||
SettingsData.set("currentThemeName", currentTheme);
|
SettingsData.set("currentThemeName", currentTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isGreeterMode) {
|
generateSystemThemesFromCurrentTheme();
|
||||||
generateSystemThemesFromCurrentTheme();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLightMode(light, savePrefs = true, enableTransition = false) {
|
function setLightMode(light, savePrefs = true, enableTransition = false) {
|
||||||
@@ -1447,17 +1437,14 @@ Singleton {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode);
|
if (savePrefs && typeof SessionData !== "undefined") {
|
||||||
if (savePrefs && typeof SessionData !== "undefined" && !isGreeterMode) {
|
|
||||||
SessionData.setLightMode(light);
|
SessionData.setLightMode(light);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isGreeterMode) {
|
if (typeof SettingsData !== "undefined") {
|
||||||
if (typeof SettingsData !== "undefined") {
|
SettingsData.updateCosmicThemeMode(light);
|
||||||
SettingsData.updateCosmicThemeMode(light);
|
|
||||||
}
|
|
||||||
generateSystemThemesFromCurrentTheme();
|
|
||||||
}
|
}
|
||||||
|
generateSystemThemesFromCurrentTheme();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLightMode(savePrefs = true) {
|
function toggleLightMode(savePrefs = true) {
|
||||||
@@ -1876,9 +1863,9 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const runKey = Qt.md5(JSON.stringify(args));
|
const runKey = Qt.md5(JSON.stringify(args));
|
||||||
const skipUnchanged = !_matugenRunKey && !colorsFileLoadFailed && typeof SettingsData !== "undefined" && !SettingsData.generateThemeAtStartup;
|
const startupRun = !_matugenRunKey && typeof SettingsData !== "undefined" && SettingsData.generateThemeAtStartup;
|
||||||
if (skipUnchanged && runKey === CacheData.matugenAppliedKey) {
|
if (!startupRun && !colorsFileLoadFailed && runKey === CacheData.matugenAppliedKey) {
|
||||||
log.info("Theme inputs unchanged since the last run, skipping startup generation");
|
log.info("Theme inputs unchanged since the last run, skipping generation");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1938,8 +1925,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function generateSystemThemesFromCurrentTheme() {
|
function generateSystemThemesFromCurrentTheme() {
|
||||||
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode);
|
if (!matugenAvailable)
|
||||||
if (!matugenAvailable || isGreeterMode)
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
_pendingGenerateParams = true;
|
_pendingGenerateParams = true;
|
||||||
@@ -2144,7 +2130,7 @@ Singleton {
|
|||||||
function applyGtkColors() {
|
function applyGtkColors() {
|
||||||
if (!matugenAvailable) {
|
if (!matugenAvailable) {
|
||||||
if (typeof ToastService !== "undefined") {
|
if (typeof ToastService !== "undefined") {
|
||||||
ToastService.showError(I18n.tr("matugen not available or disabled - cannot apply %1 colors", "error toast, %1 is GTK or Qt").arg("GTK"));
|
ToastService.showError(I18n.tr("%1 not available or disabled - cannot apply %2 colors", "error toast, %1 is matugen, %2 is GTK or Qt").arg("matugen").arg("GTK"));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2166,7 +2152,7 @@ Singleton {
|
|||||||
function applyQtColors() {
|
function applyQtColors() {
|
||||||
if (!matugenAvailable) {
|
if (!matugenAvailable) {
|
||||||
if (typeof ToastService !== "undefined") {
|
if (typeof ToastService !== "undefined") {
|
||||||
ToastService.showError(I18n.tr("matugen not available or disabled - cannot apply %1 colors").arg("Qt"));
|
ToastService.showError(I18n.tr("%1 not available or disabled - cannot apply %2 colors", "error toast, %1 is matugen, %2 is GTK or Qt").arg("matugen").arg("Qt"));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2395,32 +2381,11 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly property string _greeterCacheDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter"
|
|
||||||
|
|
||||||
property string greeterColorsBaseDir: root._greeterCacheDir
|
|
||||||
|
|
||||||
function setGreeterColorsBaseDir(dir) {
|
|
||||||
const next = dir || root._greeterCacheDir;
|
|
||||||
if (greeterColorsBaseDir === next)
|
|
||||||
return;
|
|
||||||
greeterColorsBaseDir = next;
|
|
||||||
if (typeof SessionData !== "undefined" && SessionData.isGreeterMode)
|
|
||||||
dynamicColorsFileView.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetGreeterColorsBaseDir() {
|
|
||||||
setGreeterColorsBaseDir(root._greeterCacheDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
FileView {
|
FileView {
|
||||||
id: dynamicColorsFileView
|
id: dynamicColorsFileView
|
||||||
path: {
|
path: stateDir + "/dms-colors.json"
|
||||||
if (SessionData.isGreeterMode)
|
|
||||||
return root.greeterColorsBaseDir ? (root.greeterColorsBaseDir + "/colors.json") : "";
|
|
||||||
return stateDir + "/dms-colors.json";
|
|
||||||
}
|
|
||||||
blockLoading: false
|
blockLoading: false
|
||||||
watchChanges: !SessionData.isGreeterMode
|
watchChanges: true
|
||||||
|
|
||||||
function parseAndLoadColors() {
|
function parseAndLoadColors() {
|
||||||
try {
|
try {
|
||||||
@@ -2462,9 +2427,6 @@ Singleton {
|
|||||||
if (currentTheme !== dynamic)
|
if (currentTheme !== dynamic)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (SessionData.isGreeterMode)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (workerRunning) {
|
if (workerRunning) {
|
||||||
colorsReloadRetry.restart();
|
colorsReloadRetry.restart();
|
||||||
return;
|
return;
|
||||||
@@ -2496,6 +2458,36 @@ Singleton {
|
|||||||
onTriggered: dynamicColorsFileView.reload()
|
onTriggered: dynamicColorsFileView.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readonly property bool publishTypography: typeof SettingsData !== "undefined" && SettingsData._hasLoaded
|
||||||
|
readonly property string typographyJson: JSON.stringify({
|
||||||
|
"fontFamily": fontFamily,
|
||||||
|
"monoFontFamily": monoFontFamily,
|
||||||
|
"displayFontFamily": displayFontFamily,
|
||||||
|
"fontWeight": fontWeight,
|
||||||
|
"fontScale": fontScale
|
||||||
|
}, null, 2)
|
||||||
|
|
||||||
|
onPublishTypographyChanged: typographyPublishDebounce.restart()
|
||||||
|
onTypographyJsonChanged: typographyPublishDebounce.restart()
|
||||||
|
|
||||||
|
FileView {
|
||||||
|
id: typographyFileView
|
||||||
|
path: root.publishTypography ? root.stateDir + "/dms-typography.json" : ""
|
||||||
|
blockLoading: false
|
||||||
|
printErrors: false
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: typographyPublishDebounce
|
||||||
|
interval: 250
|
||||||
|
repeat: false
|
||||||
|
onTriggered: {
|
||||||
|
if (!root.publishTypography)
|
||||||
|
return;
|
||||||
|
typographyFileView.setText(root.typographyJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
IpcHandler {
|
IpcHandler {
|
||||||
target: "theme"
|
target: "theme"
|
||||||
|
|
||||||
|
|||||||
@@ -439,7 +439,7 @@ function mangoActiveWorkspace(raw, screenName) {
|
|||||||
|
|
||||||
function mangoWindowsOnWorkspace(windows, workspace) {
|
function mangoWindowsOnWorkspace(windows, workspace) {
|
||||||
// mangoTags are 1-based; workspace.id is 0-based.
|
// mangoTags are 1-based; workspace.id is 0-based.
|
||||||
return windows.filter(win => win && (win.mangoTags || []).includes(workspace.id + 1));
|
return windows.filter(win => win && win.mangoMonitor === workspace.output && (win.mangoTags || []).includes(workspace.id + 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
function i3Key(ws) {
|
function i3Key(ws) {
|
||||||
|
|||||||
Loaded 100 of 271 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user