Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dev:

test:
go test ./internal/... ./cli/...
go test -ldflags "-X aether/internal/update.packageUpdateCommand=omarchy-update" ./internal/update ./cli

clean:
rm -rf build/bin
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ git clone https://github.com/omacom/aether.git
cd aether && make build
```

### Package-managed installs

When pacman owns the `aether` binary, Aether does not check for releases or upgrade itself. `aether upgrade` prints the update command instead: `omarchy-update`, `yay -Syu`, `paru -Syu` or `sudo pacman -Syu`.

To set this behavior in other packages, give the update command at build time:

```bash
wails build -tags webkit2_41 -ldflags "-X 'aether/internal/update.packageUpdateCommand=sudo dnf upgrade aether'"
```

### Basic Usage

1. Select a wallpaper (drag & drop, file picker, or wallhaven browser)
Expand Down
1 change: 1 addition & 0 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ func (a *App) GetReleaseStatus(currentVersion string) (map[string]interface{}, e
"latestVersion": release.LatestVersion,
"releaseURL": release.ReleaseURL,
"updateAvailable": release.UpdateAvailable,
"updateCommand": release.UpdateCommand,
}, nil
}

Expand Down
4 changes: 4 additions & 0 deletions cli/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import (
)

func runUpgrade(args []string) int {
if err := update.ManagedUpgradeError(); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
if len(args) > 0 {
fmt.Fprintln(os.Stderr, "Usage: aether upgrade")
return 1
Expand Down
35 changes: 35 additions & 0 deletions cli/upgrade_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package cli

import (
"io"
"os"
"strings"
"testing"

"aether/internal/update"
)

// Run with -ldflags '-X aether/internal/update.packageUpdateCommand=omarchy-update'.
func TestManagedUpgradeBeforeDevVersion(t *testing.T) {
if update.UpdateCommand() == "" {
t.Skip("run with the package update linker flag")
}
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
previous := os.Stderr
os.Stderr = w
defer func() { os.Stderr = previous }()

code := runUpgrade(nil)
_ = w.Close()
output, err := io.ReadAll(r)
_ = r.Close()
if err != nil {
t.Fatal(err)
}
if code != 1 || !strings.Contains(string(output), "To update it, run: "+update.UpdateCommand()) || strings.Contains(string(output), "development build") {
t.Fatalf("code = %d, stderr = %q", code, output)
}
}
77 changes: 43 additions & 34 deletions frontend/src/lib/components/layout/ReleaseIndicator.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
latestVersion: string;
releaseURL: string;
updateAvailable: boolean;
updateCommand: string;
};

type Status = 'checking' | 'current' | 'available' | 'error';
type Status = 'checking' | 'current' | 'available' | 'managed' | 'error';

let {isMac = false}: {isMac?: boolean} = $props();

Expand Down Expand Up @@ -44,7 +45,11 @@
release = (await GetReleaseStatus(
__APP_VERSION__
)) as ReleaseStatus;
status = release.updateAvailable ? 'available' : 'current';
status = release.updateCommand
? 'managed'
: release.updateAvailable
? 'available'
: 'current';
} catch {
status = 'error';
}
Expand Down Expand Up @@ -75,38 +80,42 @@
});
</script>

<button
type="button"
class="text-fg-dimmed hover:bg-bg-hover relative flex h-[30px] w-[30px] items-center justify-center transition-colors"
class:mb-0.5={isMac}
onclick={handleClick}
aria-label={title}
{title}
>
{#if status === 'available'}
<span
class="bg-warning absolute h-3.5 w-3.5 animate-ping opacity-45"
style="border-radius: 9999px !important"
aria-hidden="true"
></span>
{/if}
<span
class="{signalClass} relative flex h-2.5 w-2.5 items-center justify-center"
style="border-radius: 9999px !important"
aria-hidden="true"
<!-- A package manager owns managed installs, so the indicator has nothing to
offer there. -->
{#if status !== 'managed'}
<button
type="button"
class="text-fg-dimmed hover:bg-bg-hover relative flex h-[30px] w-[30px] items-center justify-center transition-colors"
class:mb-0.5={isMac}
onclick={handleClick}
aria-label={title}
{title}
>
{#if status === 'available'}
<svg
class="h-2 w-2 text-[#111116]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 19V5M6 11l6-6 6 6"></path>
</svg>
<span
class="bg-warning absolute h-3.5 w-3.5 animate-ping opacity-45"
style="border-radius: 9999px !important"
aria-hidden="true"
></span>
{/if}
</span>
</button>
<span
class="{signalClass} relative flex h-2.5 w-2.5 items-center justify-center"
style="border-radius: 9999px !important"
aria-hidden="true"
>
{#if status === 'available'}
<svg
class="h-2 w-2 text-[#111116]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 19V5M6 11l6-6 6 6"></path>
</svg>
{/if}
</span>
</button>
{/if}
41 changes: 41 additions & 0 deletions frontend/tests/release-indicator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {beforeEach, expect, test, vi} from 'vitest';
import ReleaseIndicator from '../src/lib/components/layout/ReleaseIndicator.svelte';
import {GetReleaseStatus, StartUpgrade} from '../wailsjs/go/main/App';
import {render, settle} from './setup';

vi.mock('../wailsjs/go/main/App', () => ({
GetReleaseStatus: vi.fn(),
StartUpgrade: vi.fn(),
}));

beforeEach(() => {
vi.mocked(GetReleaseStatus).mockReset().mockResolvedValue({
currentVersion: '4.30.0',
latestVersion: '',
releaseURL: '',
updateAvailable: false,
updateCommand: 'omarchy-update',
});
vi.mocked(StartUpgrade).mockReset();
});

test('managed installs hide the indicator and never start an upgrade', async () => {
const {target} = render(ReleaseIndicator, {});
await settle();
expect(target.querySelector('button')).toBeNull();
expect(StartUpgrade).not.toHaveBeenCalled();
expect(GetReleaseStatus).toHaveBeenCalledTimes(1);
});

test('standalone installs keep the indicator', async () => {
vi.mocked(GetReleaseStatus).mockResolvedValue({
currentVersion: '4.30.0',
latestVersion: '4.30.0',
releaseURL: '',
updateAvailable: false,
updateCommand: '',
});
const {target} = render(ReleaseIndicator, {});
await settle();
expect(target.querySelector('button')?.title).toContain('up to date');
});
68 changes: 68 additions & 0 deletions internal/update/managed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package update

import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"sync"
"time"
)

// packageUpdateCommand is set at build time with
// -ldflags "-X aether/internal/update.packageUpdateCommand=<command>".
// It overrides the runtime package detection.
var packageUpdateCommand string

var detectedUpdateCommand = sync.OnceValue(detectPackageUpdateCommand)

// UpdateCommand returns the command that updates a package-managed Aether.
// It returns "" when Aether updates itself.
func UpdateCommand() string {
if packageUpdateCommand != "" {
return packageUpdateCommand
}
return detectedUpdateCommand()
}

// ManagedUpgradeError returns an error when a package manager owns Aether,
// because a self-upgrade would overwrite a file that the package owns.
func ManagedUpgradeError() error {
command := UpdateCommand()
if command == "" {
return nil
}
return fmt.Errorf("aether is managed by your package manager. To update it, run: %s", command)
}

// detectPackageUpdateCommand returns an update command when pacman owns the
// running executable. Omarchy and AUR helpers also update AUR packages, so
// they come before plain pacman.
func detectPackageUpdateCommand() string {
if runtime.GOOS != "linux" || !commandExists("pacman") {
return ""
}
executable, err := os.Executable()
if err != nil {
return ""
}
if resolved, err := filepath.EvalSymlinks(executable); err == nil {
executable = resolved
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if exec.CommandContext(ctx, "pacman", "-Qqo", executable).Run() != nil {
return ""
}
if commandExists("omarchy-update") {
return "omarchy-update"
}
for _, helper := range []string{"yay", "paru"} {
if commandExists(helper) {
return helper + " -Syu"
}
}
return "sudo pacman -Syu"
}
60 changes: 60 additions & 0 deletions internal/update/managed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package update

import (
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
)

func writeFakeCommand(t *testing.T, dir, name string, exitCode int) {
t.Helper()
script := []byte(fmt.Sprintf("#!/bin/sh\nexit %d\n", exitCode))
if err := os.WriteFile(filepath.Join(dir, name), script, 0o755); err != nil {
t.Fatal(err)
}
}

func TestDetectPackageUpdateCommand(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("package detection runs on Linux only")
}
tests := []struct {
name string
pacman int // -1 means no pacman on PATH
commands []string
want string
}{
{"no pacman", -1, []string{"omarchy-update"}, ""},
{"not owned by a package", 1, []string{"omarchy-update"}, ""},
{"omarchy", 0, []string{"omarchy-update", "yay"}, "omarchy-update"},
{"aur helper", 0, []string{"paru", "yay"}, "yay -Syu"},
{"plain pacman", 0, nil, "sudo pacman -Syu"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
t.Setenv("PATH", dir)
if tt.pacman >= 0 {
writeFakeCommand(t, dir, "pacman", tt.pacman)
}
for _, command := range tt.commands {
writeFakeCommand(t, dir, command, 0)
}
if got := detectPackageUpdateCommand(); got != tt.want {
t.Fatalf("detectPackageUpdateCommand() = %q, want %q", got, tt.want)
}
})
}
}

func TestUpdateCommandPrefersBuildSetting(t *testing.T) {
previous := packageUpdateCommand
packageUpdateCommand = "apt upgrade"
t.Cleanup(func() { packageUpdateCommand = previous })

if got := UpdateCommand(); got != "apt upgrade" {
t.Fatalf("UpdateCommand() = %q, want %q", got, "apt upgrade")
}
}
4 changes: 4 additions & 0 deletions internal/update/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type Release struct {
LatestVersion string `json:"latestVersion"`
ReleaseURL string `json:"releaseURL"`
UpdateAvailable bool `json:"updateAvailable"`
UpdateCommand string `json:"updateCommand"`
assets []asset
}

Expand All @@ -38,6 +39,9 @@ type githubRelease struct {
// currentVersion. The check intentionally does not cache so the caller can
// explicitly refresh the status.
func Check(ctx context.Context, currentVersion string) (Release, error) {
if command := UpdateCommand(); command != "" {
return Release{CurrentVersion: normalizeVersion(currentVersion), UpdateCommand: command}, nil
}
client := &http.Client{Timeout: 8 * time.Second}
return check(ctx, currentVersion, latestReleaseURL, client)
}
Expand Down
Loading