Compare commits

..
55 Commits
Author SHA1 Message Date
purian23 e689cd2ce7 installer: fall back to dankinstall assets until 2.0.1 & update arch package compat
Port 2.0
2026-10-25 06:19:02 -04:00
purian23 6d65f304e5 rebrand(installer): convert dankinstall to duxinstall 2026-10-25 04:18:01 -04:00
purian23 696be97018 feat(duxinstall): config support for immutable image-based hosts
- detect ostree/bootc/transactional hosts
- official dms doctor & dms command support
- updated installation instructions on dux labs docs

Port 2.0
2026-10-25 03:19:41 -04:00
purian23 20bcf8559b ci: update dux-repo references 2026-10-24 16:35:18 -04:00
purian23 3388f78905 ci: dispatch distro builds from Release & guard shared Void index 2026-10-24 15:50:01 -04:00
purian23 c5b058c83e dep: bump qml-common 2026-10-24 04:13:27 -04:00
purian23 66382dfa0d refactor(default apps): rewrite w/value-keyed dropdowns & xdg data/display fixes
Port 2.0
2026-10-24 03:48:34 -04:00
purian23 4d49e126ab (deps): dux-go desktop fixes & qml-common 2026-10-24 03:42:55 -04:00
purian23 7e0f110fb8 fix(clipboard): bind & release the data device when tracking is toggled
Port 2.0
2026-10-24 03:39:08 -04:00
Emile Schup 52d6a46d89 battery popout: swap balanced and performance profile icons (#3895) 2026-10-23 23:45:53 -04:00
bbedward 6f36e49c68 lock: advertise hidden text from the ime sink
port 2.0
2026-10-23 14:15:20 -04:00
bbedward 2d16cc2f27 docs: point arch and aur badges at dux-shell packages 2026-10-23 13:40:03 -04:00
bbedward b7be4a344a bump qml-common
port 2.0
2026-10-23 10:57:59 -04:00
Huỳnh Thiện Lộc 1e31015acb feat(screenshot): add island snap targets (#3881)
port 2.0
2026-10-23 10:51:13 -04:00
bbedward 71417b7ca7 fix(apppicker): hide NoDisplay handlers except the shell's own
port 2.0
2026-10-23 10:06:12 -04:00
bbedward 6bd0e24bd4 cc: allow sliders to shrink to 0.5 height
related #3880
port 2.0
2026-10-23 09:55:05 -04:00
Matt Jiang e50bb088b6 powermenu: reboot into firmware setup from the power menu (#3877)
port 2.0
2026-10-23 09:29:31 -04:00
purian23 efd9324a4d sysupdate: hide flatpak updates when flatpak is excluded
Port 2.0
2026-10-23 06:28:01 -04:00
purian23 0cd03e0180 refactor(dot): major performance & stability improvements
- Hyprland & Mango users will benefit most while niri gains solid QOL updates
- New icon picker in dot settings
- Dragging, flinging and opening the dot now run on one static surface so they stay smooth on every compositor

Port 2.0
2026-10-23 05:38:46 -04:00
purian23 ba7b3620a8 fix(mango): keep layer animations off dms surfaces
- Stops Mango tweening the dot and island when layer_animations is on
Port 2.0
2026-10-23 04:40:54 -04:00
purian23 c7692f830c fix(core): prevent clipboard & wellbeing data loss from concurrent sessions
Port 2.0
2026-10-23 01:42:07 -04:00
purian23 5a20a8f162 systemd: start enabled service on restart when idle
Port 2.0
2026-10-23 00:26:01 -04:00
bbedward acc2c947e5 displays: ceil logical output sizes on niri so neighbours never overlap
fixes #3875
port 2.0
2026-10-23 00:00:13 -04:00
purian23 d8734bbe1a refactor(mango): deprecate pre-mango v18.0 binds & legacy management 2026-10-22 22:47:35 -04:00
Guillem CANAL 668dde2a33 idle: retry suspend when a logind sleep inhibitor is released (#3866) 2026-10-22 17:12:36 -04:00
Mario Mohar 900e6110d9 wallpaper: push the cycling config when the service is created (#3874)
port 2.0
2026-10-22 17:09:02 -04:00
bbedward 44d498e362 bar: drop pill blur region when a vertical pill collapses to zero height
fixes #3873
port 2.0
2026-10-22 17:00:53 -04:00
bbedward bf9a51c448 i18n: sync 2026-10-22 15:41:11 -04:00
bbedward 1a46db0517 i18n: sync 2026-10-22 15:13:00 -04:00
alvarosth 7781c207f1 Add opt-in administrator lock restrictions (#3869) 2026-10-22 14:57:29 -04:00
bbedward 5b7777785a shell: restore QT disable decoration pragma 2026-10-22 14:51:36 -04:00
Vaibhav Pratap Singh 0aab0ec09b battery: pulse only the icon when the bar has no background (#3861) 2026-10-22 12:13:48 -04:00
bbedward da79eca3c0 ci: disable background tasks in review job 2026-10-22 11:43:54 -04:00
bbedward aeacaf7afc distro/freebsd: drop elogind references 2026-10-22 10:38:15 -04:00
bbedward 14b3ddaf83 plugins: label declared permissions as unenforced 2026-10-22 09:44:13 -04:00
bbedward 6e86b53ccd plugins: keep manifest paths inside the plugin directory 2026-10-22 09:44:08 -04:00
bbedward c5d1d4fe46 greeter: remove the embedded legacy greeter (again) 2026-10-22 09:37:07 -04:00
purian23 39a58f77ec distos: compare ubuntu published commit by prefix 2026-10-22 06:06:38 -04:00
purian23 47c9d8d89b notifications: keep popup content while it exits after the notification closes
Port 2.0
2026-10-21 21:58:13 -04:00
bbedward d8786eaaf7 bar: keep far-edge island hosts at sheet size so the bar never jumps on expand
fixes #3855
port 2.0
2026-10-21 21:57:20 -04:00
Anton Kesy 4f969c8e10 docs: use dux project names in README (#3847) 2026-10-21 15:50:06 -04:00
bbedward 1b59a474dd battery: throttle pulse animation to 20fps
ref #3849
2026-10-21 13:59:43 -04:00
bbedward 28f921fa02 dash: hold panel height while dragging cards unless hung from a top bar
lyrics: ignore floored position samples and shell refresh emits
2026-10-21 13:15:53 -04:00
bbedward 8f2a486006 theme: publish typography for dux apps 2026-10-21 12:26:05 -04:00
bbedward 27a703367f core: only use the simple animation driver for the scrolling wallpaper
fixes #3837
2026-10-21 11:18:18 -04:00
bbedward 8e851b3dc4 bar: slide an auto-hidden island by its compact height
fixes #3838
2026-10-21 11:05:57 -04:00
bbedward 112d05901f workspaces: keep the pill highlight while hovering its app icons
fixes #3839
2026-10-21 10:39:40 -04:00
bbedward afeea489c9 theme: skip matugen runs whose inputs match the last applied run
fixes #3841
2026-10-21 10:34:22 -04:00
bbedward 90d3c9731c niri: only emit displayScales when a scale changed
ref #3842
port 2.0
2026-10-21 10:14:52 -04:00
Marlon Kranz b81954454a fix(notifications): close popups when their screen stops showing them (#3844) 2026-10-21 08:02:49 -04:00
Adarsh 479b1434de Theme Antigravity IDE via the VS Code extension (#3836) 2026-10-21 03:57:45 -04:00
Tomás 203b1369b3 Keep MangoWM workspace apps on their own monitor (#3832)
port 2.0
2026-10-20 20:54:58 -04:00
Leonard 5ffc581ecf refactor(ui): match the dock settings page with the bar settings page (#3829)
port 2.0
2026-10-20 20:54:40 -04:00
bbedward 3284acab28 matugen: neovim template reads the dms config dir"
port 2.0
2026-10-20 17:03:12 -04:00
bbedward 1fb9f5271a ci: cache go modules from core/go.sum, retry downloads 2026-10-20 15:24:59 -04:00
271 changed files with 8826 additions and 9488 deletions

No files matched your search

+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+69 -8
View File
@@ -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
+2
View File
@@ -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 }}
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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"
+5
View File
@@ -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
+50 -13
View File
@@ -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"
+1
View File
@@ -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: |
+1
View File
@@ -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
+4 -4
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
+12 -12
View File
@@ -13,8 +13,8 @@ Built with [Quickshell](https://quickshell.org/) and [Go](https://go.dev/)
[![GitHub stars](https://img.shields.io/github/stars/duxlabs-dev/dux-shell?style=for-the-badge&labelColor=101418&color=ffd700)](https://github.com/duxlabs-dev/dux-shell/stargazers) [![GitHub stars](https://img.shields.io/github/stars/duxlabs-dev/dux-shell?style=for-the-badge&labelColor=101418&color=ffd700)](https://github.com/duxlabs-dev/dux-shell/stargazers)
[![GitHub License](https://img.shields.io/github/license/duxlabs-dev/dux-shell?style=for-the-badge&labelColor=101418&color=b9c8da)](https://github.com/duxlabs-dev/dux-shell/blob/master/LICENSE) [![GitHub License](https://img.shields.io/github/license/duxlabs-dev/dux-shell?style=for-the-badge&labelColor=101418&color=b9c8da)](https://github.com/duxlabs-dev/dux-shell/blob/master/LICENSE)
[![GitHub release](https://img.shields.io/github/v/release/duxlabs-dev/dux-shell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://github.com/duxlabs-dev/dux-shell/releases) [![GitHub release](https://img.shields.io/github/v/release/duxlabs-dev/dux-shell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://github.com/duxlabs-dev/dux-shell/releases)
[![Arch version](https://img.shields.io/archlinux/v/extra/x86_64/dms-shell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://archlinux.org/packages/extra/x86_64/dms-shell/) [![Arch version](https://img.shields.io/archlinux/v/extra/x86_64/dux-shell?style=for-the-badge&labelColor=101418&color=9ccbfb)](https://archlinux.org/packages/extra/x86_64/dux-shell/)
[![AUR version (git)](<https://img.shields.io/aur/version/dms-shell-git?style=for-the-badge&labelColor=101418&color=9ccbfb&label=AUR%20(git)>)](https://aur.archlinux.org/packages/dms-shell-git) [![AUR version (git)](<https://img.shields.io/aur/version/dux-shell-git?style=for-the-badge&labelColor=101418&color=9ccbfb&label=AUR%20(git)>)](https://aur.archlinux.org/packages/dux-shell-git)
[![Ko-Fi donate](https://img.shields.io/badge/donate-kofi?style=for-the-badge&logo=ko-fi&logoColor=ffffff&label=ko-fi&labelColor=101418&color=f16061&link=https%3A%2F%2Fko-fi.com%2Fduxlabs)](https://ko-fi.com/duxlabs) [![Ko-Fi donate](https://img.shields.io/badge/donate-kofi?style=for-the-badge&logo=ko-fi&logoColor=ffffff&label=ko-fi&labelColor=101418&color=f16061&link=https%3A%2F%2Fko-fi.com%2Fduxlabs)](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
View File
@@ -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
View File
@@ -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
View File
@@ -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."
}
-24
View File
@@ -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 {
+71 -22
View File
@@ -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 {
+28 -8
View File
@@ -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)
}
} }
} }
+10 -2
View File
@@ -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...")
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -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",
+3 -87
View File
@@ -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.",
} }
+9 -5
View File
@@ -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")
} }
} }
+34 -1
View File
@@ -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) {
+28 -1
View File
@@ -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
}
+61
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+78 -13
View File
@@ -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
} }
+101
View File
@@ -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)
}
+13 -13
View File
@@ -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:.*
+1 -1
View File
@@ -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
+10 -12
View File
@@ -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))
+17 -17
View File
@@ -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")
+1 -1
View File
@@ -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)
+43 -20
View File
@@ -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",
+1 -1
View File
@@ -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)
} }
+93 -5
View File
@@ -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")
}
+127
View File
@@ -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
}
+218
View File
@@ -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])
}
}
+5 -5
View File
@@ -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)
} }
+1 -1
View File
@@ -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)
} }
+62 -31
View File
@@ -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
+105 -52
View File
@@ -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
}
+8 -5
View File
@@ -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()
+19
View File
@@ -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)
}
-216
View File
@@ -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)
}
-68
View File
@@ -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)
}
-57
View File
@@ -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"
}
+2 -9
View File
@@ -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"},
} }
+26 -1
View File
@@ -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 {
+105 -53
View File
@@ -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 {
+49 -1
View File
@@ -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())
}
+14 -4
View File
@@ -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{}
+3 -1
View File
@@ -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)
+16 -7
View File
@@ -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()
+30 -14
View File
@@ -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{
+1
View File
@@ -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
} }
+5 -1
View File
@@ -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
} }
+9 -2
View File
@@ -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)")
+1
View File
@@ -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)
}
+5
View File
@@ -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)
+5
View File
@@ -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:
+90
View File
@@ -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
}
+1
View File
@@ -8,6 +8,7 @@ const (
StateSelectTerminal StateSelectTerminal
StateDetectingDeps StateDetectingDeps
StateDependencyReview StateDependencyReview
StateLayerPackages
StateGentooUseFlags StateGentooUseFlags
StateGentooGCCCheck StateGentooGCCCheck
StateSelectPrivesc StateSelectPrivesc
+5 -9
View File
@@ -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":
+32 -4
View File
@@ -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
+27 -7
View File
@@ -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")
} }
} }
+110
View File
@@ -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()
}
+141
View File
@@ -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")
}
}
+3
View File
@@ -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 {
+78 -6
View File
@@ -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()
} }
+8 -7
View File
@@ -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)
+5 -8
View File
@@ -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`
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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.
Generated
+3 -3
View File
@@ -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": {
+1 -1
View File
@@ -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" ];
+12
View File
@@ -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"
}
+3 -11
View File
@@ -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");
} }
} }
+20 -19
View File
@@ -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 = [];
+93
View File
@@ -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)
+5 -52
View File
@@ -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
+14 -88
View File
@@ -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;
+3
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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