detached e9c5b34

Merge pull request #78 from bnema/feat/aur-support

feat(aur): add AUR packaging support with CI automation
commit
e9c5b347c7520d3c5fdddc32b6a0db4505aa1c9f
author
bnema · · 2025-12-30 21:50 UTC
committer
GitHub · · 2025-12-30 21:50 UTC
merge of
6c872d5 + b461107 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
14 files · +616 -2
A .github/workflows/aur.yml +123 -0
@@ -0,0 +1,123 @@
1+name: AUR Publish
2+
3+# This workflow only publishes the dumber-browser-bin package to AUR.
4+# The dumber-browser-git package does not need automated updates since it
5+# always builds from the latest source using `git+https://` in its PKGBUILD.
6+
7+on:
8+ workflow_run:
9+ workflows: ["Flatpak"]
10+ types:
11+ - completed
12+
13+jobs:
14+ publish-aur:
15+ name: Publish to AUR
16+ runs-on: ubuntu-latest
17+ if: ${{ github.event.workflow_run.conclusion == 'success' }}
18+
19+ steps:
20+ - uses: actions/checkout@v4
21+ with:
22+ fetch-depth: 0
23+
24+ - name: Get release info
25+ id: release
26+ run: |
27+ TAG=$(git describe --tags --abbrev=0)
28+ VERSION=${TAG#v}
29+ echo "tag=${TAG}" >> $GITHUB_OUTPUT
30+ echo "version=${VERSION}" >> $GITHUB_OUTPUT
31+ echo "Publishing AUR package for $TAG (version $VERSION)"
32+
33+ - name: Wait 1 hour before publishing
34+ run: |
35+ echo "Waiting 1 hour before publishing to AUR..."
36+ echo "This gives time to cancel if there are issues with the release."
37+ echo "Started at: $(date -u)"
38+ sleep 3600
39+ echo "Wait complete at: $(date -u)"
40+
41+ - name: Verify release still exists
42+ run: |
43+ TAG="${{ steps.release.outputs.tag }}"
44+ if ! gh release view "$TAG" &>/dev/null; then
45+ echo "::error::Release $TAG no longer exists. Aborting AUR publish."
46+ exit 1
47+ fi
48+ echo "Release $TAG verified, proceeding with AUR publish."
49+ env:
50+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
51+
52+ - name: Download checksums
53+ id: checksum
54+ run: |
55+ TAG="${{ steps.release.outputs.tag }}"
56+ curl -sL "https://github.com/bnema/dumber/releases/download/${TAG}/checksums.txt" -o checksums.txt
57+ cat checksums.txt
58+ SHA256=$(grep 'dumber_linux_x86_64.tar.gz' checksums.txt | cut -d' ' -f1)
59+ if [ -z "$SHA256" ]; then
60+ echo "::error::Failed to extract SHA256 from checksums.txt"
61+ exit 1
62+ fi
63+ echo "sha256=${SHA256}" >> $GITHUB_OUTPUT
64+ echo "SHA256: ${SHA256}"
65+
66+ - name: Generate PKGBUILD for dumber-browser-bin
67+ run: |
68+ VERSION="${{ steps.release.outputs.version }}"
69+ SHA256="${{ steps.checksum.outputs.sha256 }}"
70+
71+ mkdir -p pkgbuild/dumber-browser-bin
72+
73+ # Write PKGBUILD using printf to avoid heredoc issues
74+ printf '%s\n' \
75+ '# Maintainer: bnema <b at bnema dot dev>' \
76+ 'pkgname=dumber-browser-bin' \
77+ "pkgver=${VERSION}" \
78+ 'pkgrel=1' \
79+ 'pkgdesc="A minimal keyboard-driven browser for tiling WMs"' \
80+ "arch=('x86_64')" \
81+ 'url="https://github.com/bnema/dumber"' \
82+ "license=('MIT')" \
83+ "depends=('gtk4' 'webkitgtk-6.0')" \
84+ 'optdepends=(' \
85+ " 'gstreamer: Media playback support'" \
86+ " 'gst-plugins-base: Base media codecs'" \
87+ " 'gst-plugins-good: Additional media codecs'" \
88+ " 'gst-plugins-bad: Extended media codecs'" \
89+ " 'gst-libav: FFmpeg-based codecs'" \
90+ ')' \
91+ "provides=('dumber-browser' 'dumber')" \
92+ "conflicts=('dumber-browser' 'dumber-browser-git')" \
93+ 'source=("${pkgname}-${pkgver}.tar.gz::https://github.com/bnema/dumber/releases/download/v${pkgver}/dumber_linux_x86_64.tar.gz"' \
94+ ' "dev.bnema.Dumber.desktop::https://raw.githubusercontent.com/bnema/dumber/v${pkgver}/dev.bnema.Dumber.desktop"' \
95+ ' "logo-512.png::https://raw.githubusercontent.com/bnema/dumber/v${pkgver}/assets/logo-512.png"' \
96+ ' "LICENSE::https://raw.githubusercontent.com/bnema/dumber/v${pkgver}/LICENSE")' \
97+ "sha256sums=('${SHA256}'" \
98+ " 'SKIP'" \
99+ " 'SKIP'" \
100+ " 'SKIP')" \
101+ '' \
102+ 'package() {' \
103+ ' cd "${srcdir}"' \
104+ ' install -Dm755 dumber "${pkgdir}/usr/bin/dumber"' \
105+ ' install -Dm644 "${srcdir}/dev.bnema.Dumber.desktop" "${pkgdir}/usr/share/applications/dev.bnema.Dumber.desktop"' \
106+ ' install -Dm644 "${srcdir}/logo-512.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/dev.bnema.Dumber.png"' \
107+ ' install -Dm644 "${srcdir}/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"' \
108+ '}' \
109+ > pkgbuild/dumber-browser-bin/PKGBUILD
110+
111+ echo "Generated PKGBUILD:"
112+ cat pkgbuild/dumber-browser-bin/PKGBUILD
113+
114+ - name: Publish dumber-browser-bin to AUR
115+ uses: KSXGitHub/github-actions-deploy-aur@v4.1.1
116+ with:
117+ pkgname: dumber-browser-bin
118+ pkgbuild: ./pkgbuild/dumber-browser-bin/PKGBUILD
119+ commit_username: ${{ secrets.AUR_USERNAME }}
120+ commit_email: ${{ secrets.AUR_EMAIL }}
121+ ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
122+ commit_message: "Update to ${{ steps.release.outputs.version }}"
123+ ssh_keyscan_types: rsa,ecdsa,ed25519
@@ -0,0 +1,123 @@
1+name: AUR Publish
2+
3+# This workflow only publishes the dumber-browser-bin package to AUR.
4+# The dumber-browser-git package does not need automated updates since it
5+# always builds from the latest source using `git+https://` in its PKGBUILD.
6+
7+on:
8+ workflow_run:
9+ workflows: ["Flatpak"]
10+ types:
11+ - completed
12+
13+jobs:
14+ publish-aur:
15+ name: Publish to AUR
16+ runs-on: ubuntu-latest
17+ if: ${{ github.event.workflow_run.conclusion == 'success' }}
18+
19+ steps:
20+ - uses: actions/checkout@v4
21+ with:
22+ fetch-depth: 0
23+
24+ - name: Get release info
25+ id: release
26+ run: |
27+ TAG=$(git describe --tags --abbrev=0)
28+ VERSION=${TAG#v}
29+ echo "tag=${TAG}" >> $GITHUB_OUTPUT
30+ echo "version=${VERSION}" >> $GITHUB_OUTPUT
31+ echo "Publishing AUR package for $TAG (version $VERSION)"
32+
33+ - name: Wait 1 hour before publishing
34+ run: |
35+ echo "Waiting 1 hour before publishing to AUR..."
36+ echo "This gives time to cancel if there are issues with the release."
37+ echo "Started at: $(date -u)"
38+ sleep 3600
39+ echo "Wait complete at: $(date -u)"
40+
41+ - name: Verify release still exists
42+ run: |
43+ TAG="${{ steps.release.outputs.tag }}"
44+ if ! gh release view "$TAG" &>/dev/null; then
45+ echo "::error::Release $TAG no longer exists. Aborting AUR publish."
46+ exit 1
47+ fi
48+ echo "Release $TAG verified, proceeding with AUR publish."
49+ env:
50+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
51+
52+ - name: Download checksums
53+ id: checksum
54+ run: |
55+ TAG="${{ steps.release.outputs.tag }}"
56+ curl -sL "https://github.com/bnema/dumber/releases/download/${TAG}/checksums.txt" -o checksums.txt
57+ cat checksums.txt
58+ SHA256=$(grep 'dumber_linux_x86_64.tar.gz' checksums.txt | cut -d' ' -f1)
59+ if [ -z "$SHA256" ]; then
60+ echo "::error::Failed to extract SHA256 from checksums.txt"
61+ exit 1
62+ fi
63+ echo "sha256=${SHA256}" >> $GITHUB_OUTPUT
64+ echo "SHA256: ${SHA256}"
65+
66+ - name: Generate PKGBUILD for dumber-browser-bin
67+ run: |
68+ VERSION="${{ steps.release.outputs.version }}"
69+ SHA256="${{ steps.checksum.outputs.sha256 }}"
70+
71+ mkdir -p pkgbuild/dumber-browser-bin
72+
73+ # Write PKGBUILD using printf to avoid heredoc issues
74+ printf '%s\n' \
75+ '# Maintainer: bnema <b at bnema dot dev>' \
76+ 'pkgname=dumber-browser-bin' \
77+ "pkgver=${VERSION}" \
78+ 'pkgrel=1' \
79+ 'pkgdesc="A minimal keyboard-driven browser for tiling WMs"' \
80+ "arch=('x86_64')" \
81+ 'url="https://github.com/bnema/dumber"' \
82+ "license=('MIT')" \
83+ "depends=('gtk4' 'webkitgtk-6.0')" \
84+ 'optdepends=(' \
85+ " 'gstreamer: Media playback support'" \
86+ " 'gst-plugins-base: Base media codecs'" \
87+ " 'gst-plugins-good: Additional media codecs'" \
88+ " 'gst-plugins-bad: Extended media codecs'" \
89+ " 'gst-libav: FFmpeg-based codecs'" \
90+ ')' \
91+ "provides=('dumber-browser' 'dumber')" \
92+ "conflicts=('dumber-browser' 'dumber-browser-git')" \
93+ 'source=("${pkgname}-${pkgver}.tar.gz::https://github.com/bnema/dumber/releases/download/v${pkgver}/dumber_linux_x86_64.tar.gz"' \
94+ ' "dev.bnema.Dumber.desktop::https://raw.githubusercontent.com/bnema/dumber/v${pkgver}/dev.bnema.Dumber.desktop"' \
95+ ' "logo-512.png::https://raw.githubusercontent.com/bnema/dumber/v${pkgver}/assets/logo-512.png"' \
96+ ' "LICENSE::https://raw.githubusercontent.com/bnema/dumber/v${pkgver}/LICENSE")' \
97+ "sha256sums=('${SHA256}'" \
98+ " 'SKIP'" \
99+ " 'SKIP'" \
100+ " 'SKIP')" \
101+ '' \
102+ 'package() {' \
103+ ' cd "${srcdir}"' \
104+ ' install -Dm755 dumber "${pkgdir}/usr/bin/dumber"' \
105+ ' install -Dm644 "${srcdir}/dev.bnema.Dumber.desktop" "${pkgdir}/usr/share/applications/dev.bnema.Dumber.desktop"' \
106+ ' install -Dm644 "${srcdir}/logo-512.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/dev.bnema.Dumber.png"' \
107+ ' install -Dm644 "${srcdir}/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"' \
108+ '}' \
109+ > pkgbuild/dumber-browser-bin/PKGBUILD
110+
111+ echo "Generated PKGBUILD:"
112+ cat pkgbuild/dumber-browser-bin/PKGBUILD
113+
114+ - name: Publish dumber-browser-bin to AUR
115+ uses: KSXGitHub/github-actions-deploy-aur@v4.1.1
116+ with:
117+ pkgname: dumber-browser-bin
118+ pkgbuild: ./pkgbuild/dumber-browser-bin/PKGBUILD
119+ commit_username: ${{ secrets.AUR_USERNAME }}
120+ commit_email: ${{ secrets.AUR_EMAIL }}
121+ ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
122+ commit_message: "Update to ${{ steps.release.outputs.version }}"
123+ ssh_keyscan_types: rsa,ecdsa,ed25519
M .gitignore +11 -0
@@ -96,3 +96,14 @@ flatpak-repo/
9696 .flatpak-builder/
9797 *.flatpak
9898 builddir/
99+
100+# AUR build artifacts
101+aur/aur-*/
102+aur/*/src/
103+aur/*/pkg/
104+aur/*/*.tar.gz
105+aur/*/*.tar.zst
106+aur/*/*.pkg.tar.zst
107+aur/*/LICENSE
108+aur/*/dev.bnema.Dumber.desktop
109+aur/*/logo-512.png
@@ -96,3 +96,14 @@ flatpak-repo/
96 .flatpak-builder/96 .flatpak-builder/
97 *.flatpak97 *.flatpak
98 builddir/98 builddir/
99+
100+# AUR build artifacts
101+aur/aur-*/
102+aur/*/src/
103+aur/*/pkg/
104+aur/*/*.tar.gz
105+aur/*/*.tar.zst
106+aur/*/*.pkg.tar.zst
107+aur/*/LICENSE
108+aur/*/dev.bnema.Dumber.desktop
109+aur/*/logo-512.png
M CHANGELOG.md +11 -0
@@ -10,6 +10,17 @@ All notable changes to this project will be documented in this file.
1010
1111 ### Fixed
1212
13+## [0.23.2] - 2025-12-31
14+
15+### Added
16+- **AUR packaging support**: Dumber is now available on the Arch User Repository (AUR).
17+ - `dumber-browser-bin`: Pre-built binary package from GitHub releases (recommended).
18+ - `dumber-browser-git`: Build from source, tracks latest git.
19+ - Install with `yay -S dumber-browser-bin` or `paru -S dumber-browser-bin`.
20+ - Automatic AUR updates via GitHub Actions (1 hour delay after Flatpak build for safety).
21+- **Pacman/AUR install detection**: Self-updater is disabled when installed via pacman/AUR (uses `pacman -Qo` to detect package ownership). Shows specific message: "use your package manager instead".
22+- **Improved update error messages**: Self-update now shows specific messages for Flatpak ("use 'flatpak update'") and pacman/AUR ("use your package manager") installs instead of generic "binary not writable" error.
23+
1324 ## [0.23.1] - 2025-12-30
1425
1526 ### Added
@@ -10,6 +10,17 @@ All notable changes to this project will be documented in this file.
10 10
11 ### Fixed11 ### Fixed
12 12
13+## [0.23.2] - 2025-12-31
14+
15+### Added
16+- **AUR packaging support**: Dumber is now available on the Arch User Repository (AUR).
17+ - `dumber-browser-bin`: Pre-built binary package from GitHub releases (recommended).
18+ - `dumber-browser-git`: Build from source, tracks latest git.
19+ - Install with `yay -S dumber-browser-bin` or `paru -S dumber-browser-bin`.
20+ - Automatic AUR updates via GitHub Actions (1 hour delay after Flatpak build for safety).
21+- **Pacman/AUR install detection**: Self-updater is disabled when installed via pacman/AUR (uses `pacman -Qo` to detect package ownership). Shows specific message: "use your package manager instead".
22+- **Improved update error messages**: Self-update now shows specific messages for Flatpak ("use 'flatpak update'") and pacman/AUR ("use your package manager") installs instead of generic "binary not writable" error.
23+
13 ## [0.23.1] - 2025-12-3024 ## [0.23.1] - 2025-12-30
14 25
15 ### Added26 ### Added
M README.md +16 -0
@@ -178,6 +178,22 @@ Development:
178178
179179 Pre-built binaries are available for Linux (x86_64/amd64) in the [releases page](https://github.com/bnema/dumber/releases). See [Quick Start](#quick-start) for installation instructions.
180180
181+### Arch Linux (AUR)
182+
183+Dumber is available on the [Arch User Repository (AUR)](https://aur.archlinux.org/packages?K=dumber-browser):
184+
185+```bash
186+# Binary package (pre-built, recommended - fastest install)
187+yay -S dumber-browser-bin
188+
189+# Or build from source (latest git - bleeding edge)
190+yay -S dumber-browser-git
191+```
192+
193+Both packages install to `/usr/bin/dumber` and include desktop integration. The binary package downloads pre-built releases from GitHub, while the git package builds from the latest source code.
194+
195+> **Note:** When installed via AUR, the self-updater is automatically disabled. Use `yay -Syu` or your preferred AUR helper to update.
196+
181197 ### Flatpak
182198
183199 A Flatpak bundle is available in each release. This is the easiest way to install Dumber with all dependencies included:
@@ -178,6 +178,22 @@ Development:
178 178
179 Pre-built binaries are available for Linux (x86_64/amd64) in the [releases page](https://github.com/bnema/dumber/releases). See [Quick Start](#quick-start) for installation instructions.179 Pre-built binaries are available for Linux (x86_64/amd64) in the [releases page](https://github.com/bnema/dumber/releases). See [Quick Start](#quick-start) for installation instructions.
180 180
181+### Arch Linux (AUR)
182+
183+Dumber is available on the [Arch User Repository (AUR)](https://aur.archlinux.org/packages?K=dumber-browser):
184+
185+```bash
186+# Binary package (pre-built, recommended - fastest install)
187+yay -S dumber-browser-bin
188+
189+# Or build from source (latest git - bleeding edge)
190+yay -S dumber-browser-git
191+```
192+
193+Both packages install to `/usr/bin/dumber` and include desktop integration. The binary package downloads pre-built releases from GitHub, while the git package builds from the latest source code.
194+
195+> **Note:** When installed via AUR, the self-updater is automatically disabled. Use `yay -Syu` or your preferred AUR helper to update.
196+
181 ### Flatpak197 ### Flatpak
182 198
183 A Flatpak bundle is available in each release. This is the easiest way to install Dumber with all dependencies included:199 A Flatpak bundle is available in each release. This is the easiest way to install Dumber with all dependencies included:
A aur/dumber-browser-bin/.SRCINFO +28 -0
@@ -0,0 +1,28 @@
1+pkgbase = dumber-browser-bin
2+ pkgdesc = A minimal keyboard-driven browser for tiling WMs
3+ pkgver = 0.23.1
4+ pkgrel = 1
5+ url = https://github.com/bnema/dumber
6+ arch = x86_64
7+ license = MIT
8+ depends = gtk4
9+ depends = webkitgtk-6.0
10+ optdepends = gstreamer: Media playback support
11+ optdepends = gst-plugins-base: Base media codecs
12+ optdepends = gst-plugins-good: Additional media codecs
13+ optdepends = gst-plugins-bad: Extended media codecs
14+ optdepends = gst-libav: FFmpeg-based codecs
15+ provides = dumber-browser
16+ provides = dumber
17+ conflicts = dumber-browser
18+ conflicts = dumber-browser-git
19+ source = dumber-browser-bin-0.23.1.tar.gz::https://github.com/bnema/dumber/releases/download/v0.23.1/dumber_linux_x86_64.tar.gz
20+ source = dev.bnema.Dumber.desktop::https://raw.githubusercontent.com/bnema/dumber/v0.23.1/dev.bnema.Dumber.desktop
21+ source = logo-512.png::https://raw.githubusercontent.com/bnema/dumber/v0.23.1/assets/logo-512.png
22+ source = LICENSE::https://raw.githubusercontent.com/bnema/dumber/v0.23.1/LICENSE
23+ sha256sums = c8230e3a5258333fed401439b08c6ef7b0681dd14f4e97a7e837dfc2c4cc83a0
24+ sha256sums = SKIP
25+ sha256sums = SKIP
26+ sha256sums = SKIP
27+
28+pkgname = dumber-browser-bin
@@ -0,0 +1,28 @@
1+pkgbase = dumber-browser-bin
2+ pkgdesc = A minimal keyboard-driven browser for tiling WMs
3+ pkgver = 0.23.1
4+ pkgrel = 1
5+ url = https://github.com/bnema/dumber
6+ arch = x86_64
7+ license = MIT
8+ depends = gtk4
9+ depends = webkitgtk-6.0
10+ optdepends = gstreamer: Media playback support
11+ optdepends = gst-plugins-base: Base media codecs
12+ optdepends = gst-plugins-good: Additional media codecs
13+ optdepends = gst-plugins-bad: Extended media codecs
14+ optdepends = gst-libav: FFmpeg-based codecs
15+ provides = dumber-browser
16+ provides = dumber
17+ conflicts = dumber-browser
18+ conflicts = dumber-browser-git
19+ source = dumber-browser-bin-0.23.1.tar.gz::https://github.com/bnema/dumber/releases/download/v0.23.1/dumber_linux_x86_64.tar.gz
20+ source = dev.bnema.Dumber.desktop::https://raw.githubusercontent.com/bnema/dumber/v0.23.1/dev.bnema.Dumber.desktop
21+ source = logo-512.png::https://raw.githubusercontent.com/bnema/dumber/v0.23.1/assets/logo-512.png
22+ source = LICENSE::https://raw.githubusercontent.com/bnema/dumber/v0.23.1/LICENSE
23+ sha256sums = c8230e3a5258333fed401439b08c6ef7b0681dd14f4e97a7e837dfc2c4cc83a0
24+ sha256sums = SKIP
25+ sha256sums = SKIP
26+ sha256sums = SKIP
27+
28+pkgname = dumber-browser-bin
A aur/dumber-browser-bin/PKGBUILD +34 -0
@@ -0,0 +1,34 @@
1+# Maintainer: bnema <b at bnema dot dev>
2+pkgname=dumber-browser-bin
3+pkgver=0.23.1
4+pkgrel=1
5+pkgdesc="A minimal keyboard-driven browser for tiling WMs"
6+arch=('x86_64')
7+url="https://github.com/bnema/dumber"
8+license=('MIT')
9+depends=('gtk4' 'webkitgtk-6.0')
10+optdepends=(
11+ 'gstreamer: Media playback support'
12+ 'gst-plugins-base: Base media codecs'
13+ 'gst-plugins-good: Additional media codecs'
14+ 'gst-plugins-bad: Extended media codecs'
15+ 'gst-libav: FFmpeg-based codecs'
16+)
17+provides=('dumber-browser' 'dumber')
18+conflicts=('dumber-browser' 'dumber-browser-git')
19+source=("${pkgname}-${pkgver}.tar.gz::https://github.com/bnema/dumber/releases/download/v${pkgver}/dumber_linux_x86_64.tar.gz"
20+ "dev.bnema.Dumber.desktop::https://raw.githubusercontent.com/bnema/dumber/v${pkgver}/dev.bnema.Dumber.desktop"
21+ "logo-512.png::https://raw.githubusercontent.com/bnema/dumber/v${pkgver}/assets/logo-512.png"
22+ "LICENSE::https://raw.githubusercontent.com/bnema/dumber/v${pkgver}/LICENSE")
23+sha256sums=('c8230e3a5258333fed401439b08c6ef7b0681dd14f4e97a7e837dfc2c4cc83a0'
24+ 'SKIP'
25+ 'SKIP'
26+ 'SKIP')
27+
28+package() {
29+ cd "${srcdir}"
30+ install -Dm755 dumber "${pkgdir}/usr/bin/dumber"
31+ install -Dm644 "${srcdir}/dev.bnema.Dumber.desktop" "${pkgdir}/usr/share/applications/dev.bnema.Dumber.desktop"
32+ install -Dm644 "${srcdir}/logo-512.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/dev.bnema.Dumber.png"
33+ install -Dm644 "${srcdir}/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
34+}
@@ -0,0 +1,34 @@
1+# Maintainer: bnema <b at bnema dot dev>
2+pkgname=dumber-browser-bin
3+pkgver=0.23.1
4+pkgrel=1
5+pkgdesc="A minimal keyboard-driven browser for tiling WMs"
6+arch=('x86_64')
7+url="https://github.com/bnema/dumber"
8+license=('MIT')
9+depends=('gtk4' 'webkitgtk-6.0')
10+optdepends=(
11+ 'gstreamer: Media playback support'
12+ 'gst-plugins-base: Base media codecs'
13+ 'gst-plugins-good: Additional media codecs'
14+ 'gst-plugins-bad: Extended media codecs'
15+ 'gst-libav: FFmpeg-based codecs'
16+)
17+provides=('dumber-browser' 'dumber')
18+conflicts=('dumber-browser' 'dumber-browser-git')
19+source=("${pkgname}-${pkgver}.tar.gz::https://github.com/bnema/dumber/releases/download/v${pkgver}/dumber_linux_x86_64.tar.gz"
20+ "dev.bnema.Dumber.desktop::https://raw.githubusercontent.com/bnema/dumber/v${pkgver}/dev.bnema.Dumber.desktop"
21+ "logo-512.png::https://raw.githubusercontent.com/bnema/dumber/v${pkgver}/assets/logo-512.png"
22+ "LICENSE::https://raw.githubusercontent.com/bnema/dumber/v${pkgver}/LICENSE")
23+sha256sums=('c8230e3a5258333fed401439b08c6ef7b0681dd14f4e97a7e837dfc2c4cc83a0'
24+ 'SKIP'
25+ 'SKIP'
26+ 'SKIP')
27+
28+package() {
29+ cd "${srcdir}"
30+ install -Dm755 dumber "${pkgdir}/usr/bin/dumber"
31+ install -Dm644 "${srcdir}/dev.bnema.Dumber.desktop" "${pkgdir}/usr/share/applications/dev.bnema.Dumber.desktop"
32+ install -Dm644 "${srcdir}/logo-512.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/dev.bnema.Dumber.png"
33+ install -Dm644 "${srcdir}/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
34+}
A aur/dumber-browser-git/.SRCINFO +25 -0
@@ -0,0 +1,25 @@
1+pkgbase = dumber-browser-git
2+ pkgdesc = A minimal keyboard-driven browser for tiling WMs (git version)
3+ pkgver = 0.23.1.r0.g6c872d5
4+ pkgrel = 1
5+ url = https://github.com/bnema/dumber
6+ arch = x86_64
7+ license = MIT
8+ makedepends = go>=1.25
9+ makedepends = npm
10+ makedepends = git
11+ depends = gtk4
12+ depends = webkitgtk-6.0
13+ optdepends = gstreamer: Media playback support
14+ optdepends = gst-plugins-base: Base media codecs
15+ optdepends = gst-plugins-good: Additional media codecs
16+ optdepends = gst-plugins-bad: Extended media codecs
17+ optdepends = gst-libav: FFmpeg-based codecs
18+ provides = dumber-browser
19+ provides = dumber
20+ conflicts = dumber-browser
21+ conflicts = dumber-browser-bin
22+ source = git+https://github.com/bnema/dumber.git
23+ sha256sums = SKIP
24+
25+pkgname = dumber-browser-git
@@ -0,0 +1,25 @@
1+pkgbase = dumber-browser-git
2+ pkgdesc = A minimal keyboard-driven browser for tiling WMs (git version)
3+ pkgver = 0.23.1.r0.g6c872d5
4+ pkgrel = 1
5+ url = https://github.com/bnema/dumber
6+ arch = x86_64
7+ license = MIT
8+ makedepends = go>=1.25
9+ makedepends = npm
10+ makedepends = git
11+ depends = gtk4
12+ depends = webkitgtk-6.0
13+ optdepends = gstreamer: Media playback support
14+ optdepends = gst-plugins-base: Base media codecs
15+ optdepends = gst-plugins-good: Additional media codecs
16+ optdepends = gst-plugins-bad: Extended media codecs
17+ optdepends = gst-libav: FFmpeg-based codecs
18+ provides = dumber-browser
19+ provides = dumber
20+ conflicts = dumber-browser
21+ conflicts = dumber-browser-bin
22+ source = git+https://github.com/bnema/dumber.git
23+ sha256sums = SKIP
24+
25+pkgname = dumber-browser-git
A aur/dumber-browser-git/PKGBUILD +41 -0
@@ -0,0 +1,41 @@
1+# Maintainer: bnema <b at bnema dot dev>
2+pkgname=dumber-browser-git
3+pkgver=0.23.1.r0.g6c872d5
4+pkgrel=1
5+pkgdesc="A minimal keyboard-driven browser for tiling WMs (git version)"
6+arch=('x86_64')
7+url="https://github.com/bnema/dumber"
8+license=('MIT')
9+makedepends=('go>=1.25' 'npm' 'git')
10+depends=('gtk4' 'webkitgtk-6.0')
11+optdepends=(
12+ 'gstreamer: Media playback support'
13+ 'gst-plugins-base: Base media codecs'
14+ 'gst-plugins-good: Additional media codecs'
15+ 'gst-plugins-bad: Extended media codecs'
16+ 'gst-libav: FFmpeg-based codecs'
17+)
18+provides=('dumber-browser' 'dumber')
19+conflicts=('dumber-browser' 'dumber-browser-bin')
20+source=("git+https://github.com/bnema/dumber.git")
21+sha256sums=('SKIP')
22+
23+pkgver() {
24+ cd dumber
25+ git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || echo "0.0.0.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)"
26+}
27+
28+build() {
29+ cd dumber
30+ export CGO_ENABLED=0
31+ export GOFLAGS="-buildmode=pie -trimpath -mod=readonly -modcacherw"
32+ make build
33+}
34+
35+package() {
36+ cd dumber
37+ install -Dm755 dist/dumber "${pkgdir}/usr/bin/dumber"
38+ install -Dm644 dev.bnema.Dumber.desktop "${pkgdir}/usr/share/applications/dev.bnema.Dumber.desktop"
39+ install -Dm644 assets/logo-512.png "${pkgdir}/usr/share/icons/hicolor/512x512/apps/dev.bnema.Dumber.png"
40+ install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
41+}
@@ -0,0 +1,41 @@
1+# Maintainer: bnema <b at bnema dot dev>
2+pkgname=dumber-browser-git
3+pkgver=0.23.1.r0.g6c872d5
4+pkgrel=1
5+pkgdesc="A minimal keyboard-driven browser for tiling WMs (git version)"
6+arch=('x86_64')
7+url="https://github.com/bnema/dumber"
8+license=('MIT')
9+makedepends=('go>=1.25' 'npm' 'git')
10+depends=('gtk4' 'webkitgtk-6.0')
11+optdepends=(
12+ 'gstreamer: Media playback support'
13+ 'gst-plugins-base: Base media codecs'
14+ 'gst-plugins-good: Additional media codecs'
15+ 'gst-plugins-bad: Extended media codecs'
16+ 'gst-libav: FFmpeg-based codecs'
17+)
18+provides=('dumber-browser' 'dumber')
19+conflicts=('dumber-browser' 'dumber-browser-bin')
20+source=("git+https://github.com/bnema/dumber.git")
21+sha256sums=('SKIP')
22+
23+pkgver() {
24+ cd dumber
25+ git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || echo "0.0.0.r$(git rev-list --count HEAD).g$(git rev-parse --short HEAD)"
26+}
27+
28+build() {
29+ cd dumber
30+ export CGO_ENABLED=0
31+ export GOFLAGS="-buildmode=pie -trimpath -mod=readonly -modcacherw"
32+ make build
33+}
34+
35+package() {
36+ cd dumber
37+ install -Dm755 dist/dumber "${pkgdir}/usr/bin/dumber"
38+ install -Dm644 dev.bnema.Dumber.desktop "${pkgdir}/usr/share/applications/dev.bnema.Dumber.desktop"
39+ install -Dm644 assets/logo-512.png "${pkgdir}/usr/share/icons/hicolor/512x512/apps/dev.bnema.Dumber.png"
40+ install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
41+}
M internal/application/port/updater.go +17 -0
@@ -25,12 +25,29 @@ type UpdateDownloader interface {
2525 Extract(ctx context.Context, archivePath string, destDir string) (binaryPath string, err error)
2626 }
2727
28+// SelfUpdateBlockedReason indicates why self-update is not available.
29+type SelfUpdateBlockedReason string
30+
31+const (
32+ // SelfUpdateAllowed means self-update is possible.
33+ SelfUpdateAllowed SelfUpdateBlockedReason = ""
34+ // SelfUpdateBlockedFlatpak means updates are managed by Flatpak.
35+ SelfUpdateBlockedFlatpak SelfUpdateBlockedReason = "flatpak"
36+ // SelfUpdateBlockedPacman means updates are managed by pacman/AUR.
37+ SelfUpdateBlockedPacman SelfUpdateBlockedReason = "pacman"
38+ // SelfUpdateBlockedNotWritable means the binary is not writable.
39+ SelfUpdateBlockedNotWritable SelfUpdateBlockedReason = "not_writable"
40+)
41+
2842 // UpdateApplier stages and applies updates.
2943 type UpdateApplier interface {
3044 // CanSelfUpdate checks if the current binary is writable by the current user.
3145 // Returns true if auto-update is possible, false if user lacks write permission.
3246 CanSelfUpdate(ctx context.Context) bool
3347
48+ // SelfUpdateBlockedReason returns why self-update is blocked, or empty if allowed.
49+ SelfUpdateBlockedReason(ctx context.Context) SelfUpdateBlockedReason
50+
3451 // GetBinaryPath returns the path to the currently running binary.
3552 GetBinaryPath() (string, error)
3653
@@ -25,12 +25,29 @@ type UpdateDownloader interface {
25 Extract(ctx context.Context, archivePath string, destDir string) (binaryPath string, err error)25 Extract(ctx context.Context, archivePath string, destDir string) (binaryPath string, err error)
26 }26 }
27 27
28+// SelfUpdateBlockedReason indicates why self-update is not available.
29+type SelfUpdateBlockedReason string
30+
31+const (
32+ // SelfUpdateAllowed means self-update is possible.
33+ SelfUpdateAllowed SelfUpdateBlockedReason = ""
34+ // SelfUpdateBlockedFlatpak means updates are managed by Flatpak.
35+ SelfUpdateBlockedFlatpak SelfUpdateBlockedReason = "flatpak"
36+ // SelfUpdateBlockedPacman means updates are managed by pacman/AUR.
37+ SelfUpdateBlockedPacman SelfUpdateBlockedReason = "pacman"
38+ // SelfUpdateBlockedNotWritable means the binary is not writable.
39+ SelfUpdateBlockedNotWritable SelfUpdateBlockedReason = "not_writable"
40+)
41+
28 // UpdateApplier stages and applies updates.42 // UpdateApplier stages and applies updates.
29 type UpdateApplier interface {43 type UpdateApplier interface {
30 // CanSelfUpdate checks if the current binary is writable by the current user.44 // CanSelfUpdate checks if the current binary is writable by the current user.
31 // Returns true if auto-update is possible, false if user lacks write permission.45 // Returns true if auto-update is possible, false if user lacks write permission.
32 CanSelfUpdate(ctx context.Context) bool46 CanSelfUpdate(ctx context.Context) bool
33 47
48+ // SelfUpdateBlockedReason returns why self-update is blocked, or empty if allowed.
49+ SelfUpdateBlockedReason(ctx context.Context) SelfUpdateBlockedReason
50+
34 // GetBinaryPath returns the path to the currently running binary.51 // GetBinaryPath returns the path to the currently running binary.
35 GetBinaryPath() (string, error)52 GetBinaryPath() (string, error)
36 53
M internal/application/usecase/apply_update.go +11 -1
@@ -58,9 +58,19 @@ func (uc *ApplyUpdateUseCase) Execute(ctx context.Context, input ApplyUpdateInpu
5858
5959 // Check if we can self-update.
6060 if !uc.applier.CanSelfUpdate(ctx) {
61+ reason := uc.applier.SelfUpdateBlockedReason(ctx)
62+ var message string
63+ switch reason {
64+ case port.SelfUpdateBlockedFlatpak:
65+ message = "Cannot auto-update: installed via Flatpak (use 'flatpak update' instead)"
66+ case port.SelfUpdateBlockedPacman:
67+ message = "Cannot auto-update: installed via pacman/AUR (use your package manager instead)"
68+ default:
69+ message = "Cannot auto-update: binary is not writable"
70+ }
6171 return &ApplyUpdateOutput{
6272 Status: entity.UpdateStatusFailed,
63- Message: "Cannot auto-update: binary is not writable",
73+ Message: message,
6474 }, nil
6575 }
6676
@@ -58,9 +58,19 @@ func (uc *ApplyUpdateUseCase) Execute(ctx context.Context, input ApplyUpdateInpu
58 58
59 // Check if we can self-update.59 // Check if we can self-update.
60 if !uc.applier.CanSelfUpdate(ctx) {60 if !uc.applier.CanSelfUpdate(ctx) {
61+ reason := uc.applier.SelfUpdateBlockedReason(ctx)
62+ var message string
63+ switch reason {
64+ case port.SelfUpdateBlockedFlatpak:
65+ message = "Cannot auto-update: installed via Flatpak (use 'flatpak update' instead)"
66+ case port.SelfUpdateBlockedPacman:
67+ message = "Cannot auto-update: installed via pacman/AUR (use your package manager instead)"
68+ default:
69+ message = "Cannot auto-update: binary is not writable"
70+ }
61 return &ApplyUpdateOutput{71 return &ApplyUpdateOutput{
62 Status: entity.UpdateStatusFailed,72 Status: entity.UpdateStatusFailed,
63- Message: "Cannot auto-update: binary is not writable",73+ Message: message,
64 }, nil74 }, nil
65 }75 }
66 76
M internal/infrastructure/env/manager.go +25 -0
@@ -5,6 +5,7 @@ import (
55 "context"
66 "fmt"
77 "os"
8+ "os/exec"
89 "path/filepath"
910 "strings"
1011
@@ -20,6 +21,30 @@ func IsFlatpak() bool {
2021 return err == nil
2122 }
2223
24+// IsPacman returns true if the application was installed via pacman (including AUR).
25+// Packages managed by pacman should not self-update; use pacman/AUR helpers instead.
26+// Detection uses `pacman -Qo` to check if the binary is owned by a package.
27+func IsPacman() bool {
28+ exe, err := os.Executable()
29+ if err != nil {
30+ return false
31+ }
32+ resolved, err := filepath.EvalSymlinks(exe)
33+ if err != nil {
34+ return false
35+ }
36+
37+ // Check if pacman exists and if it owns this binary
38+ pacman, err := exec.LookPath("pacman")
39+ if err != nil {
40+ return false
41+ }
42+
43+ // pacman -Qo returns 0 if the file is owned by a package
44+ cmd := exec.Command(pacman, "-Qo", resolved)
45+ return cmd.Run() == nil
46+}
47+
2348 // Manager implements port.RenderingEnvManager for configuring
2449 // environment variables for GStreamer, WebKit, and GTK/GSK.
2550 type Manager struct {
@@ -5,6 +5,7 @@ import (
5 "context"5 "context"
6 "fmt"6 "fmt"
7 "os"7 "os"
8+ "os/exec"
8 "path/filepath"9 "path/filepath"
9 "strings"10 "strings"
10 11
@@ -20,6 +21,30 @@ func IsFlatpak() bool {
20 return err == nil21 return err == nil
21 }22 }
22 23
24+// IsPacman returns true if the application was installed via pacman (including AUR).
25+// Packages managed by pacman should not self-update; use pacman/AUR helpers instead.
26+// Detection uses `pacman -Qo` to check if the binary is owned by a package.
27+func IsPacman() bool {
28+ exe, err := os.Executable()
29+ if err != nil {
30+ return false
31+ }
32+ resolved, err := filepath.EvalSymlinks(exe)
33+ if err != nil {
34+ return false
35+ }
36+
37+ // Check if pacman exists and if it owns this binary
38+ pacman, err := exec.LookPath("pacman")
39+ if err != nil {
40+ return false
41+ }
42+
43+ // pacman -Qo returns 0 if the file is owned by a package
44+ cmd := exec.Command(pacman, "-Qo", resolved)
45+ return cmd.Run() == nil
46+}
47+
23 // Manager implements port.RenderingEnvManager for configuring48 // Manager implements port.RenderingEnvManager for configuring
24 // environment variables for GStreamer, WebKit, and GTK/GSK.49 // environment variables for GStreamer, WebKit, and GTK/GSK.
25 type Manager struct {50 type Manager struct {
M internal/infrastructure/env/manager_test.go +59 -0
@@ -3,6 +3,7 @@ package env_test
33 import (
44 "context"
55 "os"
6+ "os/exec"
67 "testing"
78
89 "github.com/bnema/dumber/internal/application/port"
@@ -170,3 +171,61 @@ func restoreEnv(key, value string) {
170171 os.Setenv(key, value)
171172 }
172173 }
174+
175+func TestIsFlatpak(t *testing.T) {
176+ // IsFlatpak checks for /.flatpak-info file existence
177+ // In normal test environment, this should return false
178+ result := env.IsFlatpak()
179+
180+ // We can't easily mock filesystem, so just verify it returns a boolean
181+ // and doesn't panic. In CI/dev environment it should be false.
182+ assert.False(t, result, "should not detect Flatpak in test environment")
183+}
184+
185+func TestIsPacman(t *testing.T) {
186+ // Only run these tests on Arch-based systems with pacman
187+ if _, err := os.Stat("/usr/bin/pacman"); os.IsNotExist(err) {
188+ t.Skip("pacman not found, skipping Arch-specific tests")
189+ }
190+
191+ tests := []struct {
192+ name string
193+ binary string
194+ expected bool
195+ }{
196+ {
197+ name: "system binary owned by pacman",
198+ binary: "/usr/bin/bash",
199+ expected: true, // bash is always installed via pacman on Arch
200+ },
201+ {
202+ name: "non-existent binary",
203+ binary: "/nonexistent/path/to/binary",
204+ expected: false,
205+ },
206+ }
207+
208+ for _, tt := range tests {
209+ t.Run(tt.name, func(t *testing.T) {
210+ // We can't easily test IsPacman directly since it uses os.Executable()
211+ // but we can test the underlying pacman -Qo logic with exec.Command
212+ cmd := exec.Command("pacman", "-Qo", tt.binary)
213+ err := cmd.Run()
214+ if tt.expected {
215+ assert.NoError(t, err, "pacman should own %s", tt.binary)
216+ } else {
217+ assert.Error(t, err, "pacman should not own %s", tt.binary)
218+ }
219+ })
220+ }
221+}
222+
223+func TestIsPacman_NonArchSystem(t *testing.T) {
224+ // On non-Arch systems, IsPacman should return false (pacman not found)
225+ if _, err := os.Stat("/usr/bin/pacman"); err == nil {
226+ t.Skip("pacman found, skipping non-Arch test")
227+ }
228+
229+ result := env.IsPacman()
230+ assert.False(t, result, "should return false when pacman is not installed")
231+}
@@ -3,6 +3,7 @@ package env_test
3 import (3 import (
4 "context"4 "context"
5 "os"5 "os"
6+ "os/exec"
6 "testing"7 "testing"
7 8
8 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
@@ -170,3 +171,61 @@ func restoreEnv(key, value string) {
170 os.Setenv(key, value)171 os.Setenv(key, value)
171 }172 }
172 }173 }
174+
175+func TestIsFlatpak(t *testing.T) {
176+ // IsFlatpak checks for /.flatpak-info file existence
177+ // In normal test environment, this should return false
178+ result := env.IsFlatpak()
179+
180+ // We can't easily mock filesystem, so just verify it returns a boolean
181+ // and doesn't panic. In CI/dev environment it should be false.
182+ assert.False(t, result, "should not detect Flatpak in test environment")
183+}
184+
185+func TestIsPacman(t *testing.T) {
186+ // Only run these tests on Arch-based systems with pacman
187+ if _, err := os.Stat("/usr/bin/pacman"); os.IsNotExist(err) {
188+ t.Skip("pacman not found, skipping Arch-specific tests")
189+ }
190+
191+ tests := []struct {
192+ name string
193+ binary string
194+ expected bool
195+ }{
196+ {
197+ name: "system binary owned by pacman",
198+ binary: "/usr/bin/bash",
199+ expected: true, // bash is always installed via pacman on Arch
200+ },
201+ {
202+ name: "non-existent binary",
203+ binary: "/nonexistent/path/to/binary",
204+ expected: false,
205+ },
206+ }
207+
208+ for _, tt := range tests {
209+ t.Run(tt.name, func(t *testing.T) {
210+ // We can't easily test IsPacman directly since it uses os.Executable()
211+ // but we can test the underlying pacman -Qo logic with exec.Command
212+ cmd := exec.Command("pacman", "-Qo", tt.binary)
213+ err := cmd.Run()
214+ if tt.expected {
215+ assert.NoError(t, err, "pacman should own %s", tt.binary)
216+ } else {
217+ assert.Error(t, err, "pacman should not own %s", tt.binary)
218+ }
219+ })
220+ }
221+}
222+
223+func TestIsPacman_NonArchSystem(t *testing.T) {
224+ // On non-Arch systems, IsPacman should return false (pacman not found)
225+ if _, err := os.Stat("/usr/bin/pacman"); err == nil {
226+ t.Skip("pacman found, skipping non-Arch test")
227+ }
228+
229+ result := env.IsPacman()
230+ assert.False(t, result, "should return false when pacman is not installed")
231+}
M internal/infrastructure/updater/applier.go +40 -1
@@ -8,6 +8,7 @@ import (
88
99 "golang.org/x/sys/unix"
1010
11+ "github.com/bnema/dumber/internal/application/port"
1112 "github.com/bnema/dumber/internal/infrastructure/config"
1213 "github.com/bnema/dumber/internal/infrastructure/env"
1314 "github.com/bnema/dumber/internal/logging"
@@ -59,7 +60,8 @@ func (a *Applier) stagedBinaryPath() string {
5960 }
6061
6162 // CanSelfUpdate checks if the current binary is writable by the current user.
62-// Returns false if running inside a Flatpak sandbox (updates handled by Flatpak).
63+// Returns false if running inside a Flatpak sandbox (updates handled by Flatpak)
64+// or if installed via pacman/AUR (updates handled by package manager).
6365 func (a *Applier) CanSelfUpdate(ctx context.Context) bool {
6466 log := logging.FromContext(ctx)
6567
@@ -69,6 +71,12 @@ func (a *Applier) CanSelfUpdate(ctx context.Context) bool {
6971 return false
7072 }
7173
74+ // Pacman/AUR packages should not self-update; use pacman or AUR helper.
75+ if env.IsPacman() {
76+ log.Debug().Msg("installed via pacman/AUR, self-update disabled")
77+ return false
78+ }
79+
7280 binaryPath, err := a.GetBinaryPath()
7381 if err != nil {
7482 log.Debug().Err(err).Msg("failed to get binary path")
@@ -89,6 +97,37 @@ func (a *Applier) CanSelfUpdate(ctx context.Context) bool {
8997 return true
9098 }
9199
100+// SelfUpdateBlockedReason returns why self-update is blocked, or empty if allowed.
101+func (a *Applier) SelfUpdateBlockedReason(ctx context.Context) port.SelfUpdateBlockedReason {
102+ log := logging.FromContext(ctx)
103+
104+ // Flatpak sandboxed apps should not self-update; Flatpak handles updates.
105+ if env.IsFlatpak() {
106+ log.Debug().Msg("self-update blocked: running in Flatpak sandbox")
107+ return port.SelfUpdateBlockedFlatpak
108+ }
109+
110+ // Pacman/AUR packages should not self-update; use pacman or AUR helper.
111+ if env.IsPacman() {
112+ log.Debug().Msg("self-update blocked: installed via pacman/AUR")
113+ return port.SelfUpdateBlockedPacman
114+ }
115+
116+ binaryPath, err := a.GetBinaryPath()
117+ if err != nil {
118+ log.Debug().Err(err).Msg("self-update blocked: failed to get binary path")
119+ return port.SelfUpdateBlockedNotWritable
120+ }
121+
122+ // Check if we have write permission on the binary file.
123+ if err := unix.Access(binaryPath, unix.W_OK); err != nil {
124+ log.Debug().Str("path", binaryPath).Err(err).Msg("self-update blocked: binary not writable")
125+ return port.SelfUpdateBlockedNotWritable
126+ }
127+
128+ return port.SelfUpdateAllowed
129+}
130+
92131 // GetBinaryPath returns the path to the currently running binary.
93132 func (*Applier) GetBinaryPath() (string, error) {
94133 path, err := os.Executable()
@@ -8,6 +8,7 @@ import (
8 8
9 "golang.org/x/sys/unix"9 "golang.org/x/sys/unix"
10 10
11+ "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/infrastructure/config"12 "github.com/bnema/dumber/internal/infrastructure/config"
12 "github.com/bnema/dumber/internal/infrastructure/env"13 "github.com/bnema/dumber/internal/infrastructure/env"
13 "github.com/bnema/dumber/internal/logging"14 "github.com/bnema/dumber/internal/logging"
@@ -59,7 +60,8 @@ func (a *Applier) stagedBinaryPath() string {
59 }60 }
60 61
61 // CanSelfUpdate checks if the current binary is writable by the current user.62 // CanSelfUpdate checks if the current binary is writable by the current user.
62-// Returns false if running inside a Flatpak sandbox (updates handled by Flatpak).63+// Returns false if running inside a Flatpak sandbox (updates handled by Flatpak)
64+// or if installed via pacman/AUR (updates handled by package manager).
63 func (a *Applier) CanSelfUpdate(ctx context.Context) bool {65 func (a *Applier) CanSelfUpdate(ctx context.Context) bool {
64 log := logging.FromContext(ctx)66 log := logging.FromContext(ctx)
65 67
@@ -69,6 +71,12 @@ func (a *Applier) CanSelfUpdate(ctx context.Context) bool {
69 return false71 return false
70 }72 }
71 73
74+ // Pacman/AUR packages should not self-update; use pacman or AUR helper.
75+ if env.IsPacman() {
76+ log.Debug().Msg("installed via pacman/AUR, self-update disabled")
77+ return false
78+ }
79+
72 binaryPath, err := a.GetBinaryPath()80 binaryPath, err := a.GetBinaryPath()
73 if err != nil {81 if err != nil {
74 log.Debug().Err(err).Msg("failed to get binary path")82 log.Debug().Err(err).Msg("failed to get binary path")
@@ -89,6 +97,37 @@ func (a *Applier) CanSelfUpdate(ctx context.Context) bool {
89 return true97 return true
90 }98 }
91 99
100+// SelfUpdateBlockedReason returns why self-update is blocked, or empty if allowed.
101+func (a *Applier) SelfUpdateBlockedReason(ctx context.Context) port.SelfUpdateBlockedReason {
102+ log := logging.FromContext(ctx)
103+
104+ // Flatpak sandboxed apps should not self-update; Flatpak handles updates.
105+ if env.IsFlatpak() {
106+ log.Debug().Msg("self-update blocked: running in Flatpak sandbox")
107+ return port.SelfUpdateBlockedFlatpak
108+ }
109+
110+ // Pacman/AUR packages should not self-update; use pacman or AUR helper.
111+ if env.IsPacman() {
112+ log.Debug().Msg("self-update blocked: installed via pacman/AUR")
113+ return port.SelfUpdateBlockedPacman
114+ }
115+
116+ binaryPath, err := a.GetBinaryPath()
117+ if err != nil {
118+ log.Debug().Err(err).Msg("self-update blocked: failed to get binary path")
119+ return port.SelfUpdateBlockedNotWritable
120+ }
121+
122+ // Check if we have write permission on the binary file.
123+ if err := unix.Access(binaryPath, unix.W_OK); err != nil {
124+ log.Debug().Str("path", binaryPath).Err(err).Msg("self-update blocked: binary not writable")
125+ return port.SelfUpdateBlockedNotWritable
126+ }
127+
128+ return port.SelfUpdateAllowed
129+}
130+
92 // GetBinaryPath returns the path to the currently running binary.131 // GetBinaryPath returns the path to the currently running binary.
93 func (*Applier) GetBinaryPath() (string, error) {132 func (*Applier) GetBinaryPath() (string, error) {
94 path, err := os.Executable()133 path, err := os.Executable()
A internal/infrastructure/updater/applier_test.go +175 -0
@@ -0,0 +1,175 @@
1+package updater
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestApplier_SelfUpdateBlockedReason(t *testing.T) {
15+ // Create a temporary directory for test state
16+ tmpDir := t.TempDir()
17+ applier := NewApplier(tmpDir)
18+
19+ ctx := context.Background()
20+
21+ // Get the reason - the actual result depends on the environment
22+ reason := applier.SelfUpdateBlockedReason(ctx)
23+
24+ // Verify it returns a valid reason type
25+ validReasons := []port.SelfUpdateBlockedReason{
26+ port.SelfUpdateAllowed,
27+ port.SelfUpdateBlockedFlatpak,
28+ port.SelfUpdateBlockedPacman,
29+ port.SelfUpdateBlockedNotWritable,
30+ }
31+
32+ found := false
33+ for _, valid := range validReasons {
34+ if reason == valid {
35+ found = true
36+ break
37+ }
38+ }
39+ assert.True(t, found, "SelfUpdateBlockedReason returned invalid reason: %q", reason)
40+}
41+
42+func TestApplier_CanSelfUpdate_ConsistentWithReason(t *testing.T) {
43+ tmpDir := t.TempDir()
44+ applier := NewApplier(tmpDir)
45+ ctx := context.Background()
46+
47+ canUpdate := applier.CanSelfUpdate(ctx)
48+ reason := applier.SelfUpdateBlockedReason(ctx)
49+
50+ // If CanSelfUpdate returns true, reason should be SelfUpdateAllowed
51+ if canUpdate {
52+ assert.Equal(t, port.SelfUpdateAllowed, reason,
53+ "CanSelfUpdate=true but SelfUpdateBlockedReason=%q", reason)
54+ } else {
55+ // If CanSelfUpdate returns false, reason should NOT be SelfUpdateAllowed
56+ assert.NotEqual(t, port.SelfUpdateAllowed, reason,
57+ "CanSelfUpdate=false but SelfUpdateBlockedReason is empty")
58+ }
59+}
60+
61+func TestApplier_StagingPaths(t *testing.T) {
62+ tmpDir := t.TempDir()
63+ applier := NewApplier(tmpDir)
64+
65+ // Test staging directory path
66+ stagingDir := applier.stagingDir()
67+ assert.Equal(t, filepath.Join(tmpDir, stagingDirName), stagingDir)
68+
69+ // Test staged binary path
70+ stagedPath := applier.stagedBinaryPath()
71+ assert.Equal(t, filepath.Join(tmpDir, stagingDirName, stagedBinaryName), stagedPath)
72+}
73+
74+func TestApplier_HasStagedUpdate(t *testing.T) {
75+ tmpDir := t.TempDir()
76+ applier := NewApplier(tmpDir)
77+ ctx := context.Background()
78+
79+ // Initially no staged update
80+ assert.False(t, applier.HasStagedUpdate(ctx), "should have no staged update initially")
81+
82+ // Create staging directory and file
83+ stagingDir := applier.stagingDir()
84+ require.NoError(t, os.MkdirAll(stagingDir, 0o755))
85+
86+ stagedPath := applier.stagedBinaryPath()
87+
88+ // Create a non-executable file (should not count as staged)
89+ require.NoError(t, os.WriteFile(stagedPath, []byte("test"), 0o644))
90+ assert.False(t, applier.HasStagedUpdate(ctx), "non-executable file should not count as staged")
91+
92+ // Make it executable
93+ require.NoError(t, os.Chmod(stagedPath, 0o755))
94+ assert.True(t, applier.HasStagedUpdate(ctx), "executable file should count as staged")
95+}
96+
97+func TestApplier_ClearStagedUpdate(t *testing.T) {
98+ tmpDir := t.TempDir()
99+ applier := NewApplier(tmpDir)
100+ ctx := context.Background()
101+
102+ // Create staged update
103+ stagingDir := applier.stagingDir()
104+ require.NoError(t, os.MkdirAll(stagingDir, 0o755))
105+ stagedPath := applier.stagedBinaryPath()
106+ require.NoError(t, os.WriteFile(stagedPath, []byte("test"), 0o755))
107+
108+ assert.True(t, applier.HasStagedUpdate(ctx), "should have staged update")
109+
110+ // Clear it
111+ require.NoError(t, applier.ClearStagedUpdate(ctx))
112+
113+ assert.False(t, applier.HasStagedUpdate(ctx), "should have no staged update after clear")
114+
115+ // Verify directory was removed
116+ _, err := os.Stat(stagingDir)
117+ assert.True(t, os.IsNotExist(err), "staging directory should be removed")
118+}
119+
120+func TestApplier_StageUpdate(t *testing.T) {
121+ tmpDir := t.TempDir()
122+ applier := NewApplier(tmpDir)
123+ ctx := context.Background()
124+
125+ // Create a fake binary to stage
126+ srcBinary := filepath.Join(tmpDir, "new-binary")
127+ binaryContent := []byte("#!/bin/sh\necho 'new version'\n")
128+ require.NoError(t, os.WriteFile(srcBinary, binaryContent, 0o755))
129+
130+ // Stage the update
131+ require.NoError(t, applier.StageUpdate(ctx, srcBinary))
132+
133+ // Verify it was staged
134+ assert.True(t, applier.HasStagedUpdate(ctx), "should have staged update")
135+
136+ // Verify content was copied
137+ stagedContent, err := os.ReadFile(applier.stagedBinaryPath())
138+ require.NoError(t, err)
139+ assert.Equal(t, binaryContent, stagedContent, "staged content should match source")
140+
141+ // Verify permissions
142+ info, err := os.Stat(applier.stagedBinaryPath())
143+ require.NoError(t, err)
144+ assert.Equal(t, os.FileMode(0o755), info.Mode().Perm(), "staged binary should be executable")
145+}
146+
147+func TestNewApplierFromXDG(t *testing.T) {
148+ // This test verifies NewApplierFromXDG doesn't panic and returns valid applier
149+ applier, err := NewApplierFromXDG()
150+ require.NoError(t, err)
151+ require.NotNil(t, applier)
152+
153+ // Verify it has a valid state directory
154+ assert.NotEmpty(t, applier.stateDir)
155+}
156+
157+func TestSelfUpdateBlockedReason_Constants(t *testing.T) {
158+ // Verify the constants are distinct
159+ reasons := []port.SelfUpdateBlockedReason{
160+ port.SelfUpdateAllowed,
161+ port.SelfUpdateBlockedFlatpak,
162+ port.SelfUpdateBlockedPacman,
163+ port.SelfUpdateBlockedNotWritable,
164+ }
165+
166+ seen := make(map[port.SelfUpdateBlockedReason]bool)
167+ for _, r := range reasons {
168+ assert.False(t, seen[r], "duplicate reason: %q", r)
169+ seen[r] = true
170+ }
171+
172+ // Verify SelfUpdateAllowed is empty string
173+ assert.Equal(t, port.SelfUpdateAllowed, port.SelfUpdateBlockedReason(""),
174+ "SelfUpdateAllowed should be empty string")
175+}
@@ -0,0 +1,175 @@
1+package updater
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestApplier_SelfUpdateBlockedReason(t *testing.T) {
15+ // Create a temporary directory for test state
16+ tmpDir := t.TempDir()
17+ applier := NewApplier(tmpDir)
18+
19+ ctx := context.Background()
20+
21+ // Get the reason - the actual result depends on the environment
22+ reason := applier.SelfUpdateBlockedReason(ctx)
23+
24+ // Verify it returns a valid reason type
25+ validReasons := []port.SelfUpdateBlockedReason{
26+ port.SelfUpdateAllowed,
27+ port.SelfUpdateBlockedFlatpak,
28+ port.SelfUpdateBlockedPacman,
29+ port.SelfUpdateBlockedNotWritable,
30+ }
31+
32+ found := false
33+ for _, valid := range validReasons {
34+ if reason == valid {
35+ found = true
36+ break
37+ }
38+ }
39+ assert.True(t, found, "SelfUpdateBlockedReason returned invalid reason: %q", reason)
40+}
41+
42+func TestApplier_CanSelfUpdate_ConsistentWithReason(t *testing.T) {
43+ tmpDir := t.TempDir()
44+ applier := NewApplier(tmpDir)
45+ ctx := context.Background()
46+
47+ canUpdate := applier.CanSelfUpdate(ctx)
48+ reason := applier.SelfUpdateBlockedReason(ctx)
49+
50+ // If CanSelfUpdate returns true, reason should be SelfUpdateAllowed
51+ if canUpdate {
52+ assert.Equal(t, port.SelfUpdateAllowed, reason,
53+ "CanSelfUpdate=true but SelfUpdateBlockedReason=%q", reason)
54+ } else {
55+ // If CanSelfUpdate returns false, reason should NOT be SelfUpdateAllowed
56+ assert.NotEqual(t, port.SelfUpdateAllowed, reason,
57+ "CanSelfUpdate=false but SelfUpdateBlockedReason is empty")
58+ }
59+}
60+
61+func TestApplier_StagingPaths(t *testing.T) {
62+ tmpDir := t.TempDir()
63+ applier := NewApplier(tmpDir)
64+
65+ // Test staging directory path
66+ stagingDir := applier.stagingDir()
67+ assert.Equal(t, filepath.Join(tmpDir, stagingDirName), stagingDir)
68+
69+ // Test staged binary path
70+ stagedPath := applier.stagedBinaryPath()
71+ assert.Equal(t, filepath.Join(tmpDir, stagingDirName, stagedBinaryName), stagedPath)
72+}
73+
74+func TestApplier_HasStagedUpdate(t *testing.T) {
75+ tmpDir := t.TempDir()
76+ applier := NewApplier(tmpDir)
77+ ctx := context.Background()
78+
79+ // Initially no staged update
80+ assert.False(t, applier.HasStagedUpdate(ctx), "should have no staged update initially")
81+
82+ // Create staging directory and file
83+ stagingDir := applier.stagingDir()
84+ require.NoError(t, os.MkdirAll(stagingDir, 0o755))
85+
86+ stagedPath := applier.stagedBinaryPath()
87+
88+ // Create a non-executable file (should not count as staged)
89+ require.NoError(t, os.WriteFile(stagedPath, []byte("test"), 0o644))
90+ assert.False(t, applier.HasStagedUpdate(ctx), "non-executable file should not count as staged")
91+
92+ // Make it executable
93+ require.NoError(t, os.Chmod(stagedPath, 0o755))
94+ assert.True(t, applier.HasStagedUpdate(ctx), "executable file should count as staged")
95+}
96+
97+func TestApplier_ClearStagedUpdate(t *testing.T) {
98+ tmpDir := t.TempDir()
99+ applier := NewApplier(tmpDir)
100+ ctx := context.Background()
101+
102+ // Create staged update
103+ stagingDir := applier.stagingDir()
104+ require.NoError(t, os.MkdirAll(stagingDir, 0o755))
105+ stagedPath := applier.stagedBinaryPath()
106+ require.NoError(t, os.WriteFile(stagedPath, []byte("test"), 0o755))
107+
108+ assert.True(t, applier.HasStagedUpdate(ctx), "should have staged update")
109+
110+ // Clear it
111+ require.NoError(t, applier.ClearStagedUpdate(ctx))
112+
113+ assert.False(t, applier.HasStagedUpdate(ctx), "should have no staged update after clear")
114+
115+ // Verify directory was removed
116+ _, err := os.Stat(stagingDir)
117+ assert.True(t, os.IsNotExist(err), "staging directory should be removed")
118+}
119+
120+func TestApplier_StageUpdate(t *testing.T) {
121+ tmpDir := t.TempDir()
122+ applier := NewApplier(tmpDir)
123+ ctx := context.Background()
124+
125+ // Create a fake binary to stage
126+ srcBinary := filepath.Join(tmpDir, "new-binary")
127+ binaryContent := []byte("#!/bin/sh\necho 'new version'\n")
128+ require.NoError(t, os.WriteFile(srcBinary, binaryContent, 0o755))
129+
130+ // Stage the update
131+ require.NoError(t, applier.StageUpdate(ctx, srcBinary))
132+
133+ // Verify it was staged
134+ assert.True(t, applier.HasStagedUpdate(ctx), "should have staged update")
135+
136+ // Verify content was copied
137+ stagedContent, err := os.ReadFile(applier.stagedBinaryPath())
138+ require.NoError(t, err)
139+ assert.Equal(t, binaryContent, stagedContent, "staged content should match source")
140+
141+ // Verify permissions
142+ info, err := os.Stat(applier.stagedBinaryPath())
143+ require.NoError(t, err)
144+ assert.Equal(t, os.FileMode(0o755), info.Mode().Perm(), "staged binary should be executable")
145+}
146+
147+func TestNewApplierFromXDG(t *testing.T) {
148+ // This test verifies NewApplierFromXDG doesn't panic and returns valid applier
149+ applier, err := NewApplierFromXDG()
150+ require.NoError(t, err)
151+ require.NotNil(t, applier)
152+
153+ // Verify it has a valid state directory
154+ assert.NotEmpty(t, applier.stateDir)
155+}
156+
157+func TestSelfUpdateBlockedReason_Constants(t *testing.T) {
158+ // Verify the constants are distinct
159+ reasons := []port.SelfUpdateBlockedReason{
160+ port.SelfUpdateAllowed,
161+ port.SelfUpdateBlockedFlatpak,
162+ port.SelfUpdateBlockedPacman,
163+ port.SelfUpdateBlockedNotWritable,
164+ }
165+
166+ seen := make(map[port.SelfUpdateBlockedReason]bool)
167+ for _, r := range reasons {
168+ assert.False(t, seen[r], "duplicate reason: %q", r)
169+ seen[r] = true
170+ }
171+
172+ // Verify SelfUpdateAllowed is empty string
173+ assert.Equal(t, port.SelfUpdateAllowed, port.SelfUpdateBlockedReason(""),
174+ "SelfUpdateAllowed should be empty string")
175+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close