From 23f3250630b2cfe65478f98a1b3bd04bd10cc148 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 26 Oct 2026 00:28:06 -0600 Subject: [PATCH 1/4] fix(desktop): export Flatpak icons and align Linux desktop identity Install 32px and 128px hicolor icons in the Flatpak manifest, set desktopName/syncDesktopName and StartupWMClass to sh.devsy.app, give the Linux window an explicit icon, and verify desktop entry and icons in the release workflow before upload. Refs #1652 --- .github/workflows/release.yml | 71 +++++++++ desktop/electron-builder.yml | 6 + desktop/flatpak/Devsy.desktop | 2 +- desktop/flatpak/sh.devsy.app.yml | 11 +- desktop/package.json | 1 + .../__tests__/linux-desktop-identity.test.ts | 31 ++++ desktop/src/main/index.ts | 7 + desktop/src/main/linux-desktop-identity.ts | 23 +++ hack/release_artifacts/main.go | 5 +- .../release_artifacts/verify_linux_desktop.go | 136 ++++++++++++++++++ .../verify_linux_desktop_test.go | 83 +++++++++++ .../troubleshooting/linux-troubleshooting.mdx | 8 ++ 12 files changed, 380 insertions(+), 4 deletions(-) create mode 100644 desktop/src/main/__tests__/linux-desktop-identity.test.ts create mode 100644 desktop/src/main/linux-desktop-identity.ts create mode 100644 hack/release_artifacts/verify_linux_desktop.go create mode 100644 hack/release_artifacts/verify_linux_desktop_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 17dfcbb3b6..0e96222115 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -225,6 +225,54 @@ jobs: --file "$app/Contents/Resources/bin/devsy" \ --goos "$MATRIX_GO_OS" --goarch "$MATRIX_GO_ARCH" + - name: install desktop-file-utils + if: matrix.name == 'linux-x64' + run: sudo apt-get install -y desktop-file-utils rpm + + - name: verify Linux desktop identity and icons in packages + if: matrix.name == 'linux-x64' + shell: bash + run: | + set -euo pipefail + app_id=sh.devsy.app + work="$RUNNER_TEMP/linux-package-check" + rm -rf "$work" + mkdir -p "$work" + + appimage=$(find desktop/release -maxdepth 1 -name '*.AppImage' -print -quit) + deb=$(find desktop/release -maxdepth 1 -name '*.deb' -print -quit) + rpm=$(find desktop/release -maxdepth 1 -name '*.rpm' -print -quit) + for pkg in "$appimage" "$deb" "$rpm"; do + [ -n "$pkg" ] || { echo "::error::missing Linux package in desktop/release"; exit 1; } + done + + mkdir "$work/appimage" && (cd "$work/appimage" && chmod +x "$GITHUB_WORKSPACE/$appimage" && "$GITHUB_WORKSPACE/$appimage" --appimage-extract >/dev/null) + mkdir "$work/deb" && dpkg-deb -x "$deb" "$work/deb" + mkdir "$work/rpm" && (cd "$work/rpm" && rpm2cpio "$GITHUB_WORKSPACE/$rpm" | cpio -idm --quiet) + + appimage_root="$work/appimage/squashfs-root" + go run ./hack/release_artifacts verify-linux-desktop \ + --app-id "$app_id" \ + --desktop-file "$appimage_root/$app_id.desktop" \ + --icon-root "$appimage_root" \ + --require-file "$(find "$appimage_root" -path '*/resources/app-icon.png' -print -quit)" + for root in "$work/deb" "$work/rpm"; do + go run ./hack/release_artifacts verify-linux-desktop \ + --app-id "$app_id" \ + --desktop-file "$root/usr/share/applications/$app_id.desktop" \ + --icon-root "$root/usr/share/icons" \ + --require-file "$(find "$root" -path '*/resources/app-icon.png' -print -quit)" + done + + desktop-file-validate \ + "$appimage_root/$app_id.desktop" \ + "$work/deb/usr/share/applications/$app_id.desktop" \ + "$work/rpm/usr/share/applications/$app_id.desktop" + + # The version step rewrites package.json in CI, so check what shipped. + packaged_name=$(cd desktop && node -e 'process.stdout.write(require("@electron/asar").extractFile(process.argv[1], "package.json"))' "$appimage_root/resources/app.asar" | jq -r .desktopName) + [ "$packaged_name" = "$app_id.desktop" ] || { echo "::error::bundled desktopName is '$packaged_name', expected $app_id.desktop"; exit 1; } + - name: upload desktop artifacts to release uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 with: @@ -378,6 +426,29 @@ jobs: cache-key: flatpak-builder-${{ github.sha }} upload-artifact: true + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: go.mod + + - name: verify Flatpak desktop identity and exported icons + run: | + set -euo pipefail + dnf install -y --setopt=install_weak_deps=False desktop-file-utils ostree + app_id=sh.devsy.app + work="$RUNNER_TEMP/flatpak-bundle-check" + rm -rf "$work" + ostree init --repo="$work/repo" --mode=archive-z2 + flatpak build-import-bundle "$work/repo" Devsy.flatpak + ref=$(ostree refs --repo="$work/repo" | grep "^app/$app_id/" | head -n 1) + ostree checkout --repo="$work/repo" --user-mode "$ref" "$work/files" + export_root="$work/files/export/share" + go run ./hack/release_artifacts verify-linux-desktop \ + --app-id "$app_id" \ + --desktop-file "$export_root/applications/$app_id.desktop" \ + --icon-root "$export_root/icons/hicolor" + test -s "$export_root/icons/hicolor/128x128/apps/$app_id.png" + desktop-file-validate "$export_root/applications/$app_id.desktop" + - name: upload flatpak bundle uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 with: diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml index edef55c5b1..1a5950d45a 100644 --- a/desktop/electron-builder.yml +++ b/desktop/electron-builder.yml @@ -17,6 +17,10 @@ extraResources: - "**/*" - from: resources/image-catalog-seed.json to: image-catalog-seed.json + # Linux windows read this at runtime; the builder icon directory only feeds + # the desktop entry and package icon theme. + - from: resources/icon.png + to: app-icon.png mac: category: public.app-category.developer-tools @@ -53,6 +57,8 @@ nsis: linux: icon: resources/icons + # Names the desktop entry after appId so it matches Electron's desktopName. + syncDesktopName: true category: Development artifactName: Devsy_${os}_${arch}.${ext} target: diff --git a/desktop/flatpak/Devsy.desktop b/desktop/flatpak/Devsy.desktop index ed1a547052..0daacfb29f 100644 --- a/desktop/flatpak/Devsy.desktop +++ b/desktop/flatpak/Devsy.desktop @@ -6,4 +6,4 @@ Icon=sh.devsy.app Terminal=false Type=Application Categories=Development; -StartupWMClass=Devsy +StartupWMClass=sh.devsy.app diff --git a/desktop/flatpak/sh.devsy.app.yml b/desktop/flatpak/sh.devsy.app.yml index f8fefdbeaf..d48d589b8f 100644 --- a/desktop/flatpak/sh.devsy.app.yml +++ b/desktop/flatpak/sh.devsy.app.yml @@ -34,8 +34,10 @@ modules: - cp -r opt/Devsy /app/devsy # Install the CLI binary - install -Dm755 opt/Devsy/resources/bin/devsy /app/bin/devsy - # Install desktop file, icon, and wrapper + # Install desktop file, exported icons, and wrapper - install -Dm644 Devsy.desktop /app/share/applications/sh.devsy.app.desktop + - install -Dm644 icon-32.png /app/share/icons/hicolor/32x32/apps/sh.devsy.app.png + - install -Dm644 icon-128.png /app/share/icons/hicolor/128x128/apps/sh.devsy.app.png - install -Dm755 devsy-wrapper /app/bin/devsy-wrapper sources: - type: file @@ -44,3 +46,10 @@ modules: path: Devsy.desktop - type: file path: devsy-wrapper + # Flatpak exports only icons up to 512x512 from share/icons/hicolor. + - type: file + path: ../resources/icons/32x32.png + dest-filename: icon-32.png + - type: file + path: ../resources/icons/128x128.png + dest-filename: icon-128.png diff --git a/desktop/package.json b/desktop/package.json index 6fe81c76b3..793dc37d8e 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,7 @@ { "name": "devsy-desktop", "version": "0.1.0", + "desktopName": "sh.devsy.app.desktop", "private": true, "homepage": "https://devsy.sh", "author": { diff --git a/desktop/src/main/__tests__/linux-desktop-identity.test.ts b/desktop/src/main/__tests__/linux-desktop-identity.test.ts new file mode 100644 index 0000000000..ec4f562faa --- /dev/null +++ b/desktop/src/main/__tests__/linux-desktop-identity.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment node +import { join } from "node:path" +import { describe, expect, it } from "vitest" +import { + linuxWindowIcon, + PACKAGED_ICON_FILE, +} from "../linux-desktop-identity.js" + +const context = { + isPackaged: true, + resourcesPath: "/opt/Devsy/resources", + appPath: "/work/desktop", +} + +describe("linuxWindowIcon", () => { + it("uses the icon shipped in resources when packaged", () => { + expect(linuxWindowIcon({ ...context, platform: "linux" })).toBe( + join("/opt/Devsy/resources", PACKAGED_ICON_FILE), + ) + }) + + it("uses the source tree icon in development", () => { + expect( + linuxWindowIcon({ ...context, platform: "linux", isPackaged: false }), + ).toBe(join("/work/desktop", "resources", "icon.png")) + }) + + it.each(["darwin", "win32"] as const)("sets no icon on %s", (platform) => { + expect(linuxWindowIcon({ ...context, platform })).toBeUndefined() + }) +}) diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index ae3939337b..95b124ac42 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -18,6 +18,7 @@ import { isAutomaticLoginLaunch, shouldSuppressInitialWindow, } from "./launch-context.js" +import { linuxWindowIcon } from "./linux-desktop-identity.js" import { LogStore } from "./log-store.js" import { mainLog, setMainLogLevel } from "./logging.js" import { MachineDiagnosticsManager } from "./machine-diagnostics-manager.js" @@ -102,6 +103,12 @@ function createWindow(): void { minHeight: 700, show: false, title: "Devsy", + icon: linuxWindowIcon({ + platform: process.platform, + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + appPath: app.getAppPath(), + }), webPreferences: { preload: join(__dirname, "../preload/index.js"), contextIsolation: true, diff --git a/desktop/src/main/linux-desktop-identity.ts b/desktop/src/main/linux-desktop-identity.ts new file mode 100644 index 0000000000..2f632560b0 --- /dev/null +++ b/desktop/src/main/linux-desktop-identity.ts @@ -0,0 +1,23 @@ +import { join } from "node:path" + +export const PACKAGED_ICON_FILE = "app-icon.png" + +interface WindowIconContext { + platform: NodeJS.Platform + isPackaged: boolean + resourcesPath: string + appPath: string +} + +// Wayland and X11 shells resolve the window icon from the installed desktop +// entry; this only supplies the icon Electron sets on the window itself. macOS +// and Windows take theirs from the packaged bundle. +export function linuxWindowIcon( + context: WindowIconContext, +): string | undefined { + if (context.platform !== "linux") return undefined + if (context.isPackaged) { + return join(context.resourcesPath, PACKAGED_ICON_FILE) + } + return join(context.appPath, "resources", "icon.png") +} diff --git a/hack/release_artifacts/main.go b/hack/release_artifacts/main.go index 92c702e4c2..36138d1ead 100644 --- a/hack/release_artifacts/main.go +++ b/hack/release_artifacts/main.go @@ -1,5 +1,5 @@ -// release_artifacts: subcommands for inspecting CLI binaries staged by the -// release pipeline. +// release_artifacts: subcommands for inspecting CLI binaries and Linux desktop +// packages staged by the release pipeline. package main import ( @@ -21,6 +21,7 @@ func main() { populateDistCmd(), stageCmd(), verifyCmd(), + verifyLinuxDesktopCmd(), ) if err := root.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) diff --git a/hack/release_artifacts/verify_linux_desktop.go b/hack/release_artifacts/verify_linux_desktop.go new file mode 100644 index 0000000000..ac43023daa --- /dev/null +++ b/hack/release_artifacts/verify_linux_desktop.go @@ -0,0 +1,136 @@ +package main + +import ( + "bufio" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +const desktopEntryGroup = "[Desktop Entry]" + +func verifyLinuxDesktopCmd() *cobra.Command { + var appID, desktopFile, iconRoot string + var requiredFiles []string + cmd := &cobra.Command{ + Use: "verify-linux-desktop", + Short: "assert a Linux package's desktop entry, window class and icon agree with the app ID", + RunE: func(_ *cobra.Command, _ []string) error { + return verifyLinuxDesktop(appID, desktopFile, iconRoot, requiredFiles) + }, + } + cmd.Flags().StringVar(&appID, "app-id", "", "application ID, e.g. sh.devsy.app") + cmd.Flags(). + StringVar(&desktopFile, "desktop-file", "", "desktop entry extracted from the package") + cmd.Flags(). + StringVar(&iconRoot, "icon-root", "", "directory searched for the PNG named by the entry's Icon key") + cmd.Flags(). + StringArrayVar(&requiredFiles, "require-file", nil, "additional file that must exist and be non-empty") + for _, f := range []string{"app-id", "desktop-file", "icon-root"} { + _ = cmd.MarkFlagRequired(f) + } + return cmd +} + +func verifyLinuxDesktop(appID, desktopFile, iconRoot string, requiredFiles []string) error { + if got, want := filepath.Base(desktopFile), appID+".desktop"; got != want { + return fmt.Errorf("::error::desktop entry is named %s, expected %s", got, want) + } + entry, err := readDesktopEntry(desktopFile) + if err != nil { + return fmt.Errorf("::error::%s: %w", desktopFile, err) + } + if got := entry["StartupWMClass"]; got != appID { + return fmt.Errorf("::error::%s: StartupWMClass is %q, expected %q", desktopFile, got, appID) + } + icon := entry["Icon"] + if icon == "" { + return fmt.Errorf("::error::%s: no Icon key", desktopFile) + } + iconPath, err := findNonEmptyFile(iconRoot, icon+".png") + if err != nil { + return fmt.Errorf("::error::icon %q named by %s: %w", icon, desktopFile, err) + } + fmt.Printf("desktop=%s class=%s icon=%s\n", desktopFile, appID, iconPath) + for _, rel := range requiredFiles { + if err := requireNonEmptyFile(rel); err != nil { + return fmt.Errorf("::error::%w", err) + } + } + return nil +} + +func readDesktopEntry(path string) (map[string]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + + entry := map[string]string{} + inGroup := false + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "[") { + inGroup = line == desktopEntryGroup + continue + } + if !inGroup { + continue + } + if key, value, ok := strings.Cut(line, "="); ok { + entry[key] = value + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + return entry, nil +} + +var errFileNotFound = errors.New("not found") + +func findNonEmptyFile(root, name string) (string, error) { + var found string + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() || d.Name() != name { + return nil + } + info, err := os.Stat(path) + if err != nil { + return err + } + if info.Size() > 0 { + found = path + return fs.SkipAll + } + return nil + }) + if err != nil { + return "", err + } + if found == "" { + return "", fmt.Errorf("no non-empty %s under %s: %w", name, root, errFileNotFound) + } + return found, nil +} + +func requireNonEmptyFile(path string) error { + info, err := os.Stat(path) + if err != nil { + return err + } + if info.IsDir() || info.Size() == 0 { + return fmt.Errorf("%s is not a non-empty file", path) + } + return nil +} diff --git a/hack/release_artifacts/verify_linux_desktop_test.go b/hack/release_artifacts/verify_linux_desktop_test.go new file mode 100644 index 0000000000..b3c61c361d --- /dev/null +++ b/hack/release_artifacts/verify_linux_desktop_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/suite" +) + +type VerifyLinuxDesktopSuite struct { + suite.Suite + root string +} + +func TestVerifyLinuxDesktopSuite(t *testing.T) { + suite.Run(t, new(VerifyLinuxDesktopSuite)) +} + +func (s *VerifyLinuxDesktopSuite) SetupTest() { + s.root = s.T().TempDir() +} + +const goodEntry = "[Desktop Entry]\nName=Devsy\nIcon=sh.devsy.app\nStartupWMClass=sh.devsy.app\n" + +func (s *VerifyLinuxDesktopSuite) TestAcceptsConsistentPackage() { + entry := s.write("share/applications/sh.devsy.app.desktop", goodEntry) + s.write("share/icons/hicolor/128x128/apps/sh.devsy.app.png", "png") + runtimeIcon := s.write("resources/app-icon.png", "png") + + s.Require().NoError(verifyLinuxDesktop("sh.devsy.app", entry, s.root, []string{runtimeIcon})) +} + +func (s *VerifyLinuxDesktopSuite) TestRejectsWrongFilename() { + entry := s.write("devsy.desktop", goodEntry) + s.write("sh.devsy.app.png", "png") + + s.Require(). + ErrorContains(verifyLinuxDesktop("sh.devsy.app", entry, s.root, nil), "expected sh.devsy.app.desktop") +} + +func (s *VerifyLinuxDesktopSuite) TestRejectsMismatchedWindowClass() { + entry := s.write( + "sh.devsy.app.desktop", + "[Desktop Entry]\nIcon=sh.devsy.app\nStartupWMClass=Devsy\n", + ) + s.write("sh.devsy.app.png", "png") + + s.Require(). + ErrorContains(verifyLinuxDesktop("sh.devsy.app", entry, s.root, nil), "StartupWMClass") +} + +func (s *VerifyLinuxDesktopSuite) TestIgnoresKeysOutsideDesktopEntryGroup() { + entry := s.write("sh.devsy.app.desktop", + "[Desktop Entry]\nIcon=sh.devsy.app\n[Desktop Action x]\nStartupWMClass=sh.devsy.app\n") + s.write("sh.devsy.app.png", "png") + + s.Require(). + ErrorContains(verifyLinuxDesktop("sh.devsy.app", entry, s.root, nil), "StartupWMClass") +} + +func (s *VerifyLinuxDesktopSuite) TestRejectsMissingOrEmptyIcon() { + entry := s.write("sh.devsy.app.desktop", goodEntry) + s.Require().ErrorContains(verifyLinuxDesktop("sh.devsy.app", entry, s.root, nil), "not found") + + s.write("icons/sh.devsy.app.png", "") + s.Require().ErrorContains(verifyLinuxDesktop("sh.devsy.app", entry, s.root, nil), "not found") +} + +func (s *VerifyLinuxDesktopSuite) TestRejectsMissingRequiredFile() { + entry := s.write("sh.devsy.app.desktop", goodEntry) + s.write("sh.devsy.app.png", "png") + + s.Require(). + Error(verifyLinuxDesktop("sh.devsy.app", entry, s.root, []string{filepath.Join(s.root, "absent.png")})) +} + +func (s *VerifyLinuxDesktopSuite) write(rel, content string) string { + path := filepath.Join(s.root, rel) + s.Require().NoError(os.MkdirAll(filepath.Dir(path), 0o750)) + s.Require().NoError(os.WriteFile(path, []byte(content), 0o600)) + return path +} diff --git a/sites/docs-devsy-sh/content/docs/troubleshooting/linux-troubleshooting.mdx b/sites/docs-devsy-sh/content/docs/troubleshooting/linux-troubleshooting.mdx index c20f7f31be..f3400dfa5e 100644 --- a/sites/docs-devsy-sh/content/docs/troubleshooting/linux-troubleshooting.mdx +++ b/sites/docs-devsy-sh/content/docs/troubleshooting/linux-troubleshooting.mdx @@ -52,6 +52,14 @@ With SELinux, a mounted volume can fail with "Permission denied" even when owner } ``` +### Generic application icon on GNOME + +GNOME on Wayland takes the icon of a running window from an installed desktop entry named `sh.devsy.app.desktop`. The Flatpak, DEB and RPM packages install that entry and the matching `sh.devsy.app` icon. + +An AppImage you run directly is not installed anywhere, so GNOME has no desktop entry to match the window to and can show a generic icon. Integrate the AppImage with a tool such as [AppImageLauncher](https://github.com/TheAssassin/AppImageLauncher), or create your own desktop entry with `StartupWMClass=sh.devsy.app` and `Icon=sh.devsy.app`. + +If a launcher you pinned before updating still shows the wrong icon, unpin it and pin Devsy again. + ### `ENAMETOOLONG` when opening a workspace in VS Code Some distributions have a very long `PATH`, which makes the SSH connection string too long. Set the SSH binary explicitly in VS Code. See [issue #1045](https://github.com/devsy-org/devsy/issues/1045). From adbd53f18c58a679a48e080c5ab0fe4bf0a7cb4c Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 26 Oct 2026 01:17:06 -0600 Subject: [PATCH 2/4] fix(desktop): complete ownership-safe AppImage desktop integration Offer a persisted one-time prompt and Settings install, update and removal. Respect existing launchers, icon ownership and URI handlers. Follow moved AppImages and escape desktop Exec paths. Decode package PNGs at required theme paths and fix Flatpak bundle verification prerequisites. Refs #1652 --- .github/workflows/release.yml | 13 +- .../src/main/__tests__/app-settings.test.ts | 33 ++ .../linux-desktop-integration.test.ts | 475 +++++++++++++++++ .../main/__tests__/settings-service.test.ts | 15 + desktop/src/main/app-settings.ts | 9 + desktop/src/main/index.ts | 21 +- desktop/src/main/ipc.ts | 23 + desktop/src/main/linux-desktop-integration.ts | 499 ++++++++++++++++++ desktop/src/renderer/src/App.svelte | 2 + .../layout/AppImageIntegrationPrompt.svelte | 65 +++ .../layout/AppImageIntegrationPrompt.test.ts | 79 +++ desktop/src/renderer/src/lib/ipc/commands.ts | 20 + desktop/src/renderer/src/lib/ipc/mock.ts | 25 + .../src/lib/stores/desktop-settings.test.ts | 1 + .../stores/linux-desktop-integration.test.ts | 96 ++++ .../lib/stores/linux-desktop-integration.ts | 65 +++ .../src/renderer/src/lib/stores/settings.ts | 4 + .../renderer/src/pages/SettingsPage.svelte | 48 ++ desktop/src/shared/app-settings.ts | 2 + .../src/shared/linux-desktop-integration.ts | 18 + .../release_artifacts/verify_linux_desktop.go | 166 +++--- .../verify_linux_desktop_test.go | 108 ++-- .../content/docs/getting-started/install.mdx | 6 + .../troubleshooting/linux-troubleshooting.mdx | 12 +- 24 files changed, 1688 insertions(+), 117 deletions(-) create mode 100644 desktop/src/main/__tests__/linux-desktop-integration.test.ts create mode 100644 desktop/src/main/linux-desktop-integration.ts create mode 100644 desktop/src/renderer/src/lib/components/layout/AppImageIntegrationPrompt.svelte create mode 100644 desktop/src/renderer/src/lib/components/layout/AppImageIntegrationPrompt.test.ts create mode 100644 desktop/src/renderer/src/lib/stores/linux-desktop-integration.test.ts create mode 100644 desktop/src/renderer/src/lib/stores/linux-desktop-integration.ts create mode 100644 desktop/src/shared/linux-desktop-integration.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e96222115..be0e7df98a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -254,14 +254,15 @@ jobs: go run ./hack/release_artifacts verify-linux-desktop \ --app-id "$app_id" \ --desktop-file "$appimage_root/$app_id.desktop" \ - --icon-root "$appimage_root" \ - --require-file "$(find "$appimage_root" -path '*/resources/app-icon.png' -print -quit)" + --icon-root "$appimage_root/usr/share/icons/hicolor" \ + --require-file "$appimage_root/resources/app-icon.png" \ + --require-file "$appimage_root/devsy-desktop.png" for root in "$work/deb" "$work/rpm"; do go run ./hack/release_artifacts verify-linux-desktop \ --app-id "$app_id" \ --desktop-file "$root/usr/share/applications/$app_id.desktop" \ - --icon-root "$root/usr/share/icons" \ - --require-file "$(find "$root" -path '*/resources/app-icon.png' -print -quit)" + --icon-root "$root/usr/share/icons/hicolor" \ + --require-file "$root/opt/Devsy/resources/app-icon.png" done desktop-file-validate \ @@ -433,10 +434,12 @@ jobs: - name: verify Flatpak desktop identity and exported icons run: | set -euo pipefail - dnf install -y --setopt=install_weak_deps=False desktop-file-utils ostree + # The pinned image is Freedesktop SDK based, without a distro package manager. + command -v flatpak ostree desktop-file-validate app_id=sh.devsy.app work="$RUNNER_TEMP/flatpak-bundle-check" rm -rf "$work" + mkdir -p "$work" ostree init --repo="$work/repo" --mode=archive-z2 flatpak build-import-bundle "$work/repo" Devsy.flatpak ref=$(ostree refs --repo="$work/repo" | grep "^app/$app_id/" | head -n 1) diff --git a/desktop/src/main/__tests__/app-settings.test.ts b/desktop/src/main/__tests__/app-settings.test.ts index a58efd6e09..bc12710b38 100644 --- a/desktop/src/main/__tests__/app-settings.test.ts +++ b/desktop/src/main/__tests__/app-settings.test.ts @@ -31,6 +31,7 @@ describe("normalizeAppSettings", () => { trayNotifications: "all", desktopLogLevel: "info", cliCaptureLogLevel: "info", + appImageIntegrationPromptDismissed: false, }) }) @@ -43,6 +44,7 @@ describe("normalizeAppSettings", () => { trayNotifications: "failures", desktopLogLevel: "info", cliCaptureLogLevel: "info", + appImageIntegrationPromptDismissed: false, }) }) @@ -79,6 +81,7 @@ describe("patchAppSettings", () => { trayNotifications: "all" as const, desktopLogLevel: "info" as const, cliCaptureLogLevel: "info" as const, + appImageIntegrationPromptDismissed: false, } expect(patchAppSettings(current, { runAtStartup: false })).toEqual({ runAtStartup: false, @@ -86,6 +89,7 @@ describe("patchAppSettings", () => { trayNotifications: "all", desktopLogLevel: "info", cliCaptureLogLevel: "info", + appImageIntegrationPromptDismissed: false, }) }) @@ -96,6 +100,7 @@ describe("patchAppSettings", () => { trayNotifications: "off" as const, desktopLogLevel: "info" as const, cliCaptureLogLevel: "info" as const, + appImageIntegrationPromptDismissed: false, } expect(patchAppSettings(current, { openToTrayOnStartup: true })).toEqual({ runAtStartup: true, @@ -103,6 +108,7 @@ describe("patchAppSettings", () => { trayNotifications: "off", desktopLogLevel: "info", cliCaptureLogLevel: "info", + appImageIntegrationPromptDismissed: false, }) }) }) @@ -169,6 +175,7 @@ describe("AppSettingsStore", () => { trayNotifications: "off", desktopLogLevel: "info", cliCaptureLogLevel: "info", + appImageIntegrationPromptDismissed: false, }) }) @@ -186,6 +193,7 @@ describe("AppSettingsStore", () => { trayNotifications: "failures", desktopLogLevel: "info", cliCaptureLogLevel: "info", + appImageIntegrationPromptDismissed: false, }) }) @@ -215,3 +223,28 @@ describe("AppSettingsStore", () => { expect(calls).toBe(1) }) }) + +describe("appImageIntegrationPromptDismissed", () => { + it("persists a dismissed prompt and defaults to not dismissed", () => { + expect(normalizeAppSettings({}).appImageIntegrationPromptDismissed).toBe( + false, + ) + expect( + normalizeAppSettings({ appImageIntegrationPromptDismissed: true }) + .appImageIntegrationPromptDismissed, + ).toBe(true) + expect( + normalizeAppSettings({ appImageIntegrationPromptDismissed: "yes" }) + .appImageIntegrationPromptDismissed, + ).toBe(false) + }) + + it("accepts only a boolean from the renderer", () => { + expect( + sanitizeAppSettingsPatch({ appImageIntegrationPromptDismissed: true }), + ).toEqual({ appImageIntegrationPromptDismissed: true }) + expect(() => + sanitizeAppSettingsPatch({ appImageIntegrationPromptDismissed: 1 }), + ).toThrow("appImageIntegrationPromptDismissed must be boolean") + }) +}) diff --git a/desktop/src/main/__tests__/linux-desktop-integration.test.ts b/desktop/src/main/__tests__/linux-desktop-integration.test.ts new file mode 100644 index 0000000000..de269bdbd5 --- /dev/null +++ b/desktop/src/main/__tests__/linux-desktop-integration.test.ts @@ -0,0 +1,475 @@ +// @vitest-environment node +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { join, relative } from "node:path" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { + LinuxDesktopIntegration, + type LinuxDesktopIntegrationOptions, + parseExec, + quoteExecArgument, +} from "../linux-desktop-integration.js" + +const ICON_BYTES = Buffer.from("devsy-icon") +const OWNED_ENTRY = "sh.devsy.app.desktop" + +let root: string + +function write(path: string, content: string | Buffer): string { + mkdirSync(join(path, ".."), { recursive: true }) + writeFileSync(path, content) + return path +} + +function appImage(name = "Devsy.AppImage"): string { + return write(join(root, "apps", name), "appimage") +} + +function userApplications(): string { + return join(root, "data", "applications") +} + +function userIcon(): string { + return join( + root, + "data", + "icons", + "hicolor", + "128x128", + "apps", + "sh.devsy.app.png", + ) +} + +function integration( + overrides: { + env?: Record + platform?: NodeJS.Platform + refresh?: LinuxDesktopIntegrationOptions["refreshDesktopDatabase"] + } = {}, +): LinuxDesktopIntegration { + return new LinuxDesktopIntegration({ + platform: overrides.platform ?? "linux", + env: { + XDG_DATA_HOME: join(root, "data"), + XDG_DATA_DIRS: `${join(root, "sys-a")}:${join(root, "sys-b")}`, + ...overrides.env, + }, + homeDir: join(root, "home"), + iconSource: write(join(root, "icon-source.png"), ICON_BYTES), + refreshDesktopDatabase: overrides.refresh ?? (async () => {}), + }) +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "devsy-integration-")) +}) + +afterEach(() => { + chmodSync(root, 0o700) + rmSync(root, { recursive: true, force: true }) +}) + +describe("Exec quoting", () => { + it.each([ + "/home/u/Devsy.AppImage", + "/home/u/My Apps/Devsy.AppImage", + '/home/u/say "hi"/Devsy.AppImage', + "/home/u/it's/Devsy.AppImage", + "/home/u/back\\slash/Devsy.AppImage", + "/home/u/$HOME/`id`/Devsy.AppImage", + "/home/u/100%/Devsy.AppImage", + "/home/u/%U/Devsy.AppImage", + "/home/u/caf\u00e9 (1)/Devsy.AppImage", + ])("round-trips %s through the desktop file", (path) => { + expect(parseExec(`${quoteExecArgument(path)} %U`)).toEqual([path, "%U"]) + }) + + it("keeps a literal percent distinct from a field code", () => { + expect(quoteExecArgument("/a/100%/x")).toBe("/a/100%%/x") + }) + + it("quotes only arguments that contain reserved characters", () => { + expect(quoteExecArgument("/a/b")).toBe("/a/b") + expect(quoteExecArgument("/a b/c")).toBe('"/a b/c"') + }) + + it.each([ + ["/a/$cash", String.raw`"/a/\\$cash"`], + ['/a/a"b', String.raw`"/a/a\\"b"`], + [String.raw`/a/a\b`, String.raw`"/a/a\\\\b"`], + ])("applies both Desktop Entry escaping layers to %s", (path, expected) => { + expect(quoteExecArgument(path)).toBe(expected) + }) +}) + +describe("status", () => { + it("is unsupported outside an AppImage on Linux", () => { + expect( + integration({ env: { APPIMAGE: undefined } }).status().supported, + ).toBe(false) + expect( + integration({ + platform: "darwin", + env: { APPIMAGE: appImage() }, + }).status().supported, + ).toBe(false) + }) + + it.each([ + ["a relative path", () => "Devsy.AppImage", "absolute"], + ["a path containing '='", () => appImage("a=b.AppImage"), "'='"], + ["a missing file", () => join(root, "apps", "gone.AppImage"), "not a file"], + ["a directory", () => join(root, "apps"), "not a file"], + ["a path with a newline", () => appImage("a\nb.AppImage"), "absolute"], + ])( + "reports %s as a problem and refuses to install", + async (_name, path, message) => { + const target = integration({ env: { APPIMAGE: path() } }) + expect(target.status().problem).toContain(message) + const result = await target.install() + expect(result.error).toContain(message) + expect(existsSync(userApplications())).toBe(false) + }, + ) + + it("ignores relative XDG_DATA_HOME and XDG_DATA_DIRS entries", () => { + const relativeDir = relative(process.cwd(), join(root, "relative-share")) + write( + join(root, "relative-share", "applications", OWNED_ENTRY), + "[Desktop Entry]\nName=x\n", + ) + const target = integration({ + env: { + APPIMAGE: appImage(), + XDG_DATA_HOME: relative(process.cwd(), join(root, "relative-home")), + XDG_DATA_DIRS: relativeDir, + }, + }) + expect(target.status().conflict).toBeUndefined() + }) +}) + +describe("install", () => { + it("writes a launcher and icon that identify and reopen this AppImage", async () => { + const path = appImage("My Apps Devsy.AppImage") + const refresh = vi.fn(async () => {}) + const result = await integration({ + env: { APPIMAGE: path }, + refresh, + }).install() + + expect(result.error).toBeUndefined() + expect(result.status).toMatchObject({ installed: true, needsUpdate: false }) + const entry = readFileSync(join(userApplications(), OWNED_ENTRY), "utf-8") + const exec = entry.split("\n").find((line) => line.startsWith("Exec=")) + expect(parseExec(exec?.slice("Exec=".length) ?? "")).toEqual([path, "%U"]) + expect(entry).toContain("Icon=sh.devsy.app\n") + expect(entry).toContain("StartupWMClass=sh.devsy.app\n") + expect(entry).toContain("MimeType=x-scheme-handler/devsy;\n") + expect(entry).toContain("Terminal=false\n") + expect(readFileSync(userIcon())).toEqual(ICON_BYTES) + expect(refresh).toHaveBeenCalledWith(userApplications()) + }) + + it("never changes the default URL handler", async () => { + await integration({ env: { APPIMAGE: appImage() } }).install() + expect( + existsSync(join(root, "data", "applications", "mimeapps.list")), + ).toBe(false) + expect(existsSync(join(root, "home", ".config", "mimeapps.list"))).toBe( + false, + ) + }) + + it.each([ + [ + "a system package", + () => join(root, "sys-b", "applications", OWNED_ENTRY), + ], + [ + "a user Flatpak export", + () => + join( + root, + "home", + ".local", + "share", + "flatpak", + "exports", + "share", + "applications", + OWNED_ENTRY, + ), + ], + ])( + "refuses when %s already provides the launcher", + async (_name, entryPath) => { + const existing = write(entryPath(), "[Desktop Entry]\nName=Devsy\n") + const result = await integration({ + env: { APPIMAGE: appImage() }, + }).install() + + expect(result.error).toContain(existing) + expect(result.status.conflict).toContain(existing) + expect(existsSync(join(userApplications(), OWNED_ENTRY))).toBe(false) + expect(existsSync(userIcon())).toBe(false) + }, + ) + + it("never overwrites a launcher the user manages", async () => { + const mine = write( + join(userApplications(), OWNED_ENTRY), + "[Desktop Entry]\nName=Mine\n", + ) + const result = await integration({ + env: { APPIMAGE: appImage() }, + }).install() + + expect(result.error).toContain(mine) + expect(readFileSync(mine, "utf-8")).toBe("[Desktop Entry]\nName=Mine\n") + expect(result.status.installed).toBe(false) + }) + + it("detects this AppImage integrated under another file name", async () => { + const path = appImage("My Devsy.AppImage") + const foreign = write( + join(root, "sys-a", "applications", "appimagekit_abc-Devsy.desktop"), + `[Desktop Entry]\nName=Devsy\nExec="${path}" %U\n`, + ) + const result = await integration({ env: { APPIMAGE: path } }).install() + + expect(result.error).toContain(foreign) + expect(existsSync(join(userApplications(), OWNED_ENTRY))).toBe(false) + }) + + it("ignores launchers for a different AppImage", async () => { + write( + join(root, "sys-a", "applications", "other.desktop"), + `[Desktop Entry]\nName=Other\nExec=${appImage("Other.AppImage")} %U\n`, + ) + const result = await integration({ + env: { APPIMAGE: appImage() }, + }).install() + expect(result.error).toBeUndefined() + expect(result.status.installed).toBe(true) + }) + + it("keeps an icon it did not write", async () => { + write(userIcon(), "someone else's icon") + await integration({ env: { APPIMAGE: appImage() } }).install() + expect(readFileSync(userIcon(), "utf-8")).toBe("someone else's icon") + }) + + it.skipIf(process.getuid?.() === 0)( + "reports an unwritable data directory and stays usable", + async () => { + mkdirSync(join(root, "data"), { recursive: true }) + chmodSync(join(root, "data"), 0o500) + const target = integration({ env: { APPIMAGE: appImage() } }) + const result = await target.install() + + expect(result.error).toContain("Could not write") + expect(result.status).toMatchObject({ supported: true, installed: false }) + chmodSync(join(root, "data"), 0o700) + expect((await target.install()).status.installed).toBe(true) + }, + ) +}) + +describe("moved AppImage", () => { + it("follows the AppImage to its new path without creating anything new", async () => { + const before = appImage("Old.AppImage") + await integration({ env: { APPIMAGE: before } }).install() + + const after = appImage("New.AppImage") + const moved = integration({ env: { APPIMAGE: after } }) + expect(moved.status()).toMatchObject({ installed: true, needsUpdate: true }) + + const result = await moved.refreshOwned() + expect(result.status).toMatchObject({ installed: true, needsUpdate: false }) + const entry = readFileSync(join(userApplications(), OWNED_ENTRY), "utf-8") + expect(entry).toContain(`Exec=${after} %U`) + expect(entry).not.toContain("Old.AppImage") + }) + + it("leaves a declined launcher absent", async () => { + const result = await integration({ + env: { APPIMAGE: appImage() }, + }).refreshOwned() + expect(result.status.installed).toBe(false) + expect(existsSync(userApplications())).toBe(false) + }) + + it("restores an icon that was deleted", async () => { + const target = integration({ env: { APPIMAGE: appImage() } }) + await target.install() + rmSync(userIcon()) + expect(target.status().needsUpdate).toBe(true) + + await target.refreshOwned() + expect(readFileSync(userIcon())).toEqual(ICON_BYTES) + }) + + it("does not rewrite a launcher while another one shadows it", async () => { + const target = integration({ env: { APPIMAGE: appImage("Old.AppImage") } }) + await target.install() + write( + join(root, "sys-a", "applications", OWNED_ENTRY), + "[Desktop Entry]\nName=Devsy\n", + ) + const moved = integration({ env: { APPIMAGE: appImage("New.AppImage") } }) + + const result = await moved.refreshOwned() + expect(result.error).toBeUndefined() + expect( + readFileSync(join(userApplications(), OWNED_ENTRY), "utf-8"), + ).toContain("Old.AppImage") + }) +}) + +describe("remove", () => { + it("deletes the launcher and icon it wrote", async () => { + const target = integration({ env: { APPIMAGE: appImage() } }) + await target.install() + const result = await target.remove() + + expect(result.error).toBeUndefined() + expect(result.status.installed).toBe(false) + expect(existsSync(join(userApplications(), OWNED_ENTRY))).toBe(false) + expect(existsSync(userIcon())).toBe(false) + }) + + it("refuses to delete a launcher it does not own", async () => { + const mine = write( + join(userApplications(), OWNED_ENTRY), + "[Desktop Entry]\nName=Mine\n", + ) + const result = await integration({ env: { APPIMAGE: appImage() } }).remove() + + expect(result.error).toBeDefined() + expect(existsSync(mine)).toBe(true) + }) + + it("keeps an icon that another Devsy launcher uses", async () => { + const target = integration({ env: { APPIMAGE: appImage() } }) + await target.install() + write( + join(root, "sys-b", "applications", "devsy-desktop.desktop"), + "[Desktop Entry]\nName=Devsy\nIcon=sh.devsy.app\n", + ) + await target.remove() + + expect(existsSync(join(userApplications(), OWNED_ENTRY))).toBe(false) + expect(readFileSync(userIcon())).toEqual(ICON_BYTES) + }) + + it("keeps an icon modified after integration", async () => { + const target = integration({ env: { APPIMAGE: appImage() } }) + await target.install() + write(userIcon(), "someone else's icon") + await target.remove() + expect(readFileSync(userIcon(), "utf-8")).toBe("someone else's icon") + }) +}) + +describe("ownership boundaries", () => { + it("does not run in Flatpak even with an inherited APPIMAGE", async () => { + const target = integration({ + env: { APPIMAGE: appImage(), FLATPAK_ID: "sh.devsy.app" }, + }) + expect(target.status().supported).toBe(false) + expect((await target.install()).error).toBeDefined() + expect(existsSync(userApplications())).toBe(false) + }) + + it("does not take over another package's deep-link handler", async () => { + const entry = write( + join(root, "sys-a", "applications", "devsy-desktop.desktop"), + "[Desktop Entry]\nName=Devsy\nMimeType=x-scheme-handler/devsy;\nExec=/opt/Devsy/devsy-desktop %U\n", + ) + const result = await integration({ + env: { APPIMAGE: appImage() }, + }).install() + expect(result.status.conflict).toContain(entry) + expect(existsSync(userApplications())).toBe(false) + }) + + it("never deletes an arbitrary path recorded as the owned icon", async () => { + const target = integration({ env: { APPIMAGE: appImage() } }) + await target.install() + const victim = write(join(root, "keep.png"), "keep") + const path = join(userApplications(), OWNED_ENTRY) + writeFileSync( + path, + readFileSync(path, "utf8").replace( + /^X-Devsy-AppImage-Icon=.*$/m, + `X-Devsy-AppImage-Icon=${victim}`, + ), + ) + await target.remove() + expect(readFileSync(victim, "utf8")).toBe("keep") + }) + + it("fails closed on an unreadable launcher", async () => { + const directory = join(userApplications(), OWNED_ENTRY) + mkdirSync(directory, { recursive: true }) + const result = await integration({ + env: { APPIMAGE: appImage() }, + }).install() + expect(result.error).toContain("Could not inspect launcher") + expect(existsSync(userIcon())).toBe(false) + }) + + it("does not remove an owned-looking symlink", async () => { + const target = integration({ env: { APPIMAGE: appImage() } }) + await target.install() + const userEntry = join(userApplications(), OWNED_ENTRY) + const original = join(root, "keep.desktop") + write(original, readFileSync(userEntry)) + rmSync(userEntry) + symlinkSync(original, userEntry) + const result = await target.remove() + expect(result.error).toBeDefined() + expect(readFileSync(original, "utf8")).toContain("Name=Devsy") + expect(existsSync(userEntry)).toBe(true) + }) + + it("installs under the home fallback for a relative XDG data home", async () => { + const target = integration({ + env: { + APPIMAGE: appImage("trailing space "), + XDG_DATA_HOME: "relative-data", + }, + }) + const result = await target.install() + expect(result.error).toBeUndefined() + expect(result.status.needsUpdate).toBe(false) + expect( + existsSync(join(root, "home", ".local/share/applications", OWNED_ENTRY)), + ).toBe(true) + await target.remove() + }) + + it("detects a third-party integration that uses a symlink to the AppImage", async () => { + const path = appImage() + const alias = join(root, "alias.AppImage") + symlinkSync(path, alias) + const entry = write( + join(root, "sys-a/applications/integrator.desktop"), + `[Desktop Entry]\nExec=${quoteExecArgument(alias)} %U\n`, + ) + const result = await integration({ env: { APPIMAGE: path } }).install() + expect(result.status.conflict).toContain(entry) + expect(existsSync(userIcon())).toBe(false) + }) +}) diff --git a/desktop/src/main/__tests__/settings-service.test.ts b/desktop/src/main/__tests__/settings-service.test.ts index c1c8eaf4f9..9f64bd444c 100644 --- a/desktop/src/main/__tests__/settings-service.test.ts +++ b/desktop/src/main/__tests__/settings-service.test.ts @@ -46,6 +46,16 @@ describe("SettingsService", () => { return { svc, onChanged } } + it("persists AppImage prompt dismissal without changing login startup", async () => { + const apply = vi.fn(async () => disabled) + const { svc } = service(apply) + await svc.update({ appImageIntegrationPromptDismissed: true }) + const reloaded = new AppSettingsStore(join(dir, "app-settings.json")) + expect(reloaded.load().appImageIntegrationPromptDismissed).toBe(true) + expect(reloaded.get().runAtStartup).toBe(false) + expect(apply).not.toHaveBeenCalled() + }) + it("applies autostart and persists when enabling succeeds", async () => { const { svc, onChanged } = service(async () => enabled) const result = await svc.update({ runAtStartup: true }) @@ -174,6 +184,7 @@ describe("SettingsService", () => { trayNotifications: "failures", desktopLogLevel: "info", cliCaptureLogLevel: "info", + appImageIntegrationPromptDismissed: false, }) expect(onChanged).not.toHaveBeenCalled() }) @@ -208,6 +219,7 @@ describe("SettingsService", () => { trayNotifications: "failures", desktopLogLevel: "info", cliCaptureLogLevel: "info", + appImageIntegrationPromptDismissed: false, }) expect(result.startup.status).toBe("disabled") }) @@ -233,6 +245,7 @@ describe("SettingsService", () => { trayNotifications: "failures", desktopLogLevel: "info", cliCaptureLogLevel: "info", + appImageIntegrationPromptDismissed: false, }) expect(result.startup.status).toBe("error") expect(store.get()).toEqual({ @@ -241,6 +254,7 @@ describe("SettingsService", () => { trayNotifications: "failures", desktopLogLevel: "info", cliCaptureLogLevel: "info", + appImageIntegrationPromptDismissed: false, }) expect(onChanged).toHaveBeenCalledTimes(1) expect(onChanged).toHaveBeenCalledWith({ @@ -273,6 +287,7 @@ describe("SettingsService", () => { trayNotifications: "failures", desktopLogLevel: "info", cliCaptureLogLevel: "info", + appImageIntegrationPromptDismissed: false, }) expect(result.startup).toMatchObject({ applied: false, enabled: true }) expect(store.get().runAtStartup).toBe(true) diff --git a/desktop/src/main/app-settings.ts b/desktop/src/main/app-settings.ts index 15ff532051..dee8ea03b2 100644 --- a/desktop/src/main/app-settings.ts +++ b/desktop/src/main/app-settings.ts @@ -13,6 +13,7 @@ export const DEFAULT_APP_SETTINGS: AppSettings = { trayNotifications: "failures", desktopLogLevel: "info", cliCaptureLogLevel: "info", + appImageIntegrationPromptDismissed: false, } const LEVELS: readonly TrayNotificationLevel[] = ["off", "failures", "all"] @@ -53,6 +54,8 @@ export function normalizeAppSettings(raw: unknown): AppSettings { trayNotifications, desktopLogLevel, cliCaptureLogLevel, + appImageIntegrationPromptDismissed: + input.appImageIntegrationPromptDismissed === true, } } @@ -79,6 +82,12 @@ export function sanitizeAppSettingsPatch(raw: unknown): Partial { throw new Error("openToTrayOnStartup must be boolean") patch.openToTrayOnStartup = input.openToTrayOnStartup } + if ("appImageIntegrationPromptDismissed" in input) { + if (typeof input.appImageIntegrationPromptDismissed !== "boolean") + throw new Error("appImageIntegrationPromptDismissed must be boolean") + patch.appImageIntegrationPromptDismissed = + input.appImageIntegrationPromptDismissed + } if ("trayNotifications" in input) { if (!LEVELS.includes(input.trayNotifications as TrayNotificationLevel)) throw new Error("trayNotifications must be off, failures, or all") diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 95b124ac42..fe32573de8 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -19,6 +19,10 @@ import { shouldSuppressInitialWindow, } from "./launch-context.js" import { linuxWindowIcon } from "./linux-desktop-identity.js" +import { + LinuxDesktopIntegration, + updateDesktopDatabase, +} from "./linux-desktop-integration.js" import { LogStore } from "./log-store.js" import { mainLog, setMainLogLevel } from "./logging.js" import { MachineDiagnosticsManager } from "./machine-diagnostics-manager.js" @@ -152,8 +156,9 @@ app.whenReady().then(async () => { initAnalytics() trackEvent("app_open") - // Register devsy:// as the default protocol handler for this app. - app.setAsDefaultProtocolClient(PROTOCOL) + // Linux packages and the AppImage integration advertise their handler in + // desktop entries; registering at launch would replace another package's default. + if (process.platform !== "linux") app.setAsDefaultProtocolClient(PROTOCOL) // Capture deep link from argv on Windows/Linux when launched via protocol URL. const startupUrl = process.argv.find((arg) => @@ -233,6 +238,13 @@ app.whenReady().then(async () => { const workspaceJobs = new WorkspaceJobs() const autostartEnv = detectAutostartEnvironment() + const linuxDesktopIntegration = new LinuxDesktopIntegration({ + platform: process.platform, + env: app.isPackaged ? process.env : {}, + homeDir: homedir(), + iconSource: join(app.getAppPath(), "resources", "icons", "128x128.png"), + refreshDesktopDatabase: updateDesktopDatabase, + }) const settingsService = new SettingsService({ store: appSettingsStore, applyAutostart: (settings) => applyAutostart(settings, autostartEnv), @@ -302,6 +314,7 @@ app.whenReady().then(async () => { workspaceSnapshot: () => watcher?.workspaceSnapshot(), appNavigation, settingsService, + linuxDesktopIntegration, }) // Start state watcher @@ -381,6 +394,10 @@ app.whenReady().then(async () => { createWindow() } + void linuxDesktopIntegration.refreshOwned().then(({ error }) => { + if (error) mainLog.warn("Could not update the Devsy launcher:", error) + }) + if (app.isPackaged) { initAutoUpdater(() => mainWindow) } diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 0f41d0f617..663054dec1 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -19,6 +19,7 @@ import type { AppNavigationController } from "./app-navigation.js" import { sanitizeAppSettingsPatch } from "./app-settings.js" import type { CliInvocationOptions, CliRunner, StreamLine } from "./cli.js" import { loadCatalog } from "./image-catalog.js" +import type { LinuxDesktopIntegration } from "./linux-desktop-integration.js" import type { LogStore } from "./log-store.js" import { mainLog } from "./logging.js" import type { MachineDiagnosticsManager } from "./machine-diagnostics-manager.js" @@ -170,6 +171,7 @@ interface IpcDependencies { onRendererReady?: (sender: Electron.WebContents) => void appNavigation?: AppNavigationController settingsService?: SettingsService + linuxDesktopIntegration?: LinuxDesktopIntegration secretSessionTimeoutMs?: number } @@ -2761,6 +2763,27 @@ export function registerIpcHandlers(deps: IpcDependencies): { }, ) + ipcMain.handle("get_linux_desktop_integration", () => { + if (!deps.linuxDesktopIntegration) { + throw new Error("Linux desktop integration unavailable") + } + return deps.linuxDesktopIntegration.status() + }) + + ipcMain.handle("install_linux_desktop_integration", () => { + if (!deps.linuxDesktopIntegration) { + throw new Error("Linux desktop integration unavailable") + } + return deps.linuxDesktopIntegration.install() + }) + + ipcMain.handle("remove_linux_desktop_integration", () => { + if (!deps.linuxDesktopIntegration) { + throw new Error("Linux desktop integration unavailable") + } + return deps.linuxDesktopIntegration.remove() + }) + // ── Analytics ── ipcMain.handle( "analytics_track", diff --git a/desktop/src/main/linux-desktop-integration.ts b/desktop/src/main/linux-desktop-integration.ts new file mode 100644 index 0000000000..caa1efb115 --- /dev/null +++ b/desktop/src/main/linux-desktop-integration.ts @@ -0,0 +1,499 @@ +import { execFile } from "node:child_process" +import { createHash, randomUUID } from "node:crypto" +import { + linkSync, + lstatSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs" +import { dirname, isAbsolute, join } from "node:path" +import { promisify } from "node:util" +import type { + LinuxDesktopIntegrationResult, + LinuxDesktopIntegrationStatus, +} from "../shared/linux-desktop-integration.js" + +export const APP_ID = "sh.devsy.app" +const ENTRY_FILE = `${APP_ID}.desktop` +const OWNER_KEY = "X-Devsy-AppImage-Integration" +const PATH_KEY = "X-Devsy-AppImage-Path" +const ICON_KEY = "X-Devsy-AppImage-Icon" +const ICON_HASH_KEY = "X-Devsy-AppImage-Icon-SHA256" +const DEFAULT_DATA_DIRS = "/usr/local/share:/usr/share" + +// The Desktop Entry Exec grammar reserves these characters, so an argument +// containing any of them must be double-quoted. +const EXEC_RESERVED = /[\s"'\\<>~|&;$*?#()`]/ +// Inside double quotes these four need a backslash. +const EXEC_QUOTED_ESCAPES = /["`$\\]/g +const CONTROL_CHARS = /\p{Cc}/u + +export function escapeDesktopString(value: string): string { + return value.replace(/\\/g, "\\\\") +} + +export function unescapeDesktopString(value: string): string { + return value.replace(/\\([\\nrts])/g, (_match, code: string) => { + switch (code) { + case "n": + return "\n" + case "r": + return "\r" + case "t": + return "\t" + case "s": + return " " + default: + return "\\" + } + }) +} + +/** Quotes one Exec argument and applies the desktop-file string escaping. */ +export function quoteExecArgument(argument: string): string { + const percentEscaped = argument.replace(/%/g, "%%") + const execValue = EXEC_RESERVED.test(argument) + ? `"${percentEscaped.replace(EXEC_QUOTED_ESCAPES, "\\$&")}"` + : percentEscaped + return escapeDesktopString(execValue) +} + +/** Splits an Exec value (as stored in the file) into its arguments. */ +export function parseExec(raw: string): string[] { + const value = unescapeDesktopString(raw) + const args: string[] = [] + let current = "" + let inQuotes = false + let started = false + for (let i = 0; i < value.length; i++) { + const char = value[i] + if (inQuotes) { + if (char === "\\" && i + 1 < value.length) current += value[++i] + else if (char === '"') inQuotes = false + else current += char + } else if (char === '"') { + inQuotes = true + started = true + } else if (char === " " || char === "\t") { + if (started) args.push(current) + current = "" + started = false + } else { + current += char + started = true + } + } + if (started) args.push(current) + return args.map((arg) => arg.replace(/%%/g, "%")) +} + +interface DesktopEntry { + path: string + values: Map +} + +function parseDesktopEntry(path: string, text: string): DesktopEntry { + const values = new Map() + let inGroup = false + for (const rawLine of text.split("\n")) { + const line = rawLine.trim() + if (line.startsWith("[")) { + inGroup = line === "[Desktop Entry]" + } else if (inGroup && line && !line.startsWith("#")) { + const separator = line.indexOf("=") + if (separator > 0) { + values.set( + line.slice(0, separator).trim(), + line.slice(separator + 1).trim(), + ) + } + } + } + return { path, values } +} + +function readDesktopEntries(directory: string): DesktopEntry[] { + let names: string[] + try { + names = readdirSync(directory) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return [] + throw new Error(`Could not inspect ${directory}: ${describeError(err)}`) + } + const entries: DesktopEntry[] = [] + for (const name of names) { + const path = join(directory, name) + if (name.endsWith(".desktop")) { + try { + entries.push(parseDesktopEntry(path, readFileSync(path, "utf-8"))) + } catch (err) { + throw new Error( + `Could not inspect launcher ${path}: ${describeError(err)}`, + ) + } + } else if (lstatSync(path).isDirectory()) { + entries.push(...readDesktopEntries(path)) + } + } + return entries +} + +export interface LinuxDesktopIntegrationOptions { + platform: NodeJS.Platform + env: Record + homeDir: string + /** PNG installed as the launcher icon. */ + iconSource: string + /** Best-effort cache refresh after the applications directory changes. */ + refreshDesktopDatabase: (applicationsDir: string) => Promise +} + +function describeError(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +export class LinuxDesktopIntegration { + constructor(private readonly options: LinuxDesktopIntegrationOptions) {} + + status(): LinuxDesktopIntegrationStatus { + const rawPath = this.options.env.APPIMAGE + if ( + this.options.platform !== "linux" || + this.options.env.FLATPAK_ID || + !rawPath + ) { + return { supported: false, installed: false, needsUpdate: false } + } + const problem = appImagePathProblem(rawPath) + if (problem) { + return { + supported: true, + installed: false, + needsUpdate: false, + problem, + } + } + try { + return { ...this.scan(rawPath), supported: true, appImagePath: rawPath } + } catch (err) { + return { + supported: true, + appImagePath: rawPath, + installed: false, + needsUpdate: false, + problem: describeError(err), + } + } + } + + /** Creates the launcher, or updates it when this integration owns it. */ + async install(): Promise { + return this.run(() => this.installLauncher()) + } + + /** Removes only the launcher and icon this integration wrote. */ + async remove(): Promise { + return this.run(() => this.removeLauncher()) + } + + /** Follows a moved AppImage; never creates a launcher the user declined. */ + async refreshOwned(): Promise { + const status = this.status() + if ( + !status.installed || + !status.needsUpdate || + status.conflict || + status.problem + ) { + return { status } + } + return this.install() + } + + private async run( + operation: () => Promise, + ): Promise { + try { + await operation() + return { status: this.status() } + } catch (err) { + return { status: this.status(), error: describeError(err) } + } + } + + private dataHome(): string { + const configured = this.options.env.XDG_DATA_HOME + return configured?.startsWith("/") + ? configured + : join(this.options.homeDir, ".local", "share") + } + + private applicationDirs(): string[] { + const dataDirs = (this.options.env.XDG_DATA_DIRS || DEFAULT_DATA_DIRS) + .split(":") + .filter((dir) => dir.startsWith("/")) + const flatpakExports = [ + join( + this.options.homeDir, + ".local", + "share", + "flatpak", + "exports", + "share", + ), + "/var/lib/flatpak/exports/share", + ] + return [...new Set([this.dataHome(), ...dataDirs, ...flatpakExports])].map( + (dir) => join(dir, "applications"), + ) + } + + private userEntryPath(): string { + return join(this.dataHome(), "applications", ENTRY_FILE) + } + + private iconPath(): string { + return join( + this.dataHome(), + "icons", + "hicolor", + "128x128", + "apps", + `${APP_ID}.png`, + ) + } + + private scan(appImagePath: string): { + installed: boolean + needsUpdate: boolean + conflict?: string + } { + const userEntry = this.userEntryPath() + const canonicalImage = realpathSync(appImagePath) + let owned: DesktopEntry | undefined + let conflict: string | undefined + for (const directory of this.applicationDirs()) { + for (const entry of readDesktopEntries(directory)) { + if ( + entry.path === userEntry && + entry.values.get(OWNER_KEY) === "true" && + entry.values.get(PATH_KEY) && + !lstatSync(userEntry).isSymbolicLink() + ) { + owned = entry + } else if (entry.path.endsWith(`/${ENTRY_FILE}`)) { + conflict ??= `Devsy already has a launcher at ${entry.path}.` + } else if (targetsAppImage(entry, canonicalImage)) { + conflict ??= `This AppImage is already integrated by ${entry.path}.` + } else if ( + entry.values + .get("MimeType") + ?.split(";") + .includes("x-scheme-handler/devsy") + ) { + conflict ??= `Another devsy:// handler already exists at ${entry.path}.` + } + } + } + if (!owned) return { installed: false, needsUpdate: false, conflict } + const recordedPath = unescapeDesktopString(owned.values.get(PATH_KEY) ?? "") + return { + installed: true, + needsUpdate: recordedPath !== appImagePath || !isFile(this.iconPath()), + conflict, + } + } + + private async installLauncher(): Promise { + const status = this.status() + if (status.problem) throw new Error(status.problem) + if (!status.supported || !status.appImagePath) { + throw new Error( + "Adding Devsy to applications only works from an AppImage.", + ) + } + if (status.conflict) throw new Error(status.conflict) + + const userEntry = this.userEntryPath() + const iconPath = this.iconPath() + const iconExists = pathExists(iconPath) + if (iconExists && !this.ownsIcon(userEntry)) { + throw new Error( + `An icon Devsy does not own already exists at ${iconPath}.`, + ) + } + const icon = readFileSync(this.options.iconSource) + const iconHash = createHash("sha256").update(icon).digest("hex") + writeAtomically(iconPath, icon, iconExists) + try { + writeAtomically( + userEntry, + renderDesktopEntry(status.appImagePath, iconPath, iconHash), + status.installed, + ) + } catch (err) { + if (!iconExists) removeIfPresent(iconPath) + throw err + } + await this.options.refreshDesktopDatabase(dirname(userEntry)) + } + + private async removeLauncher(): Promise { + const status = this.status() + if (status.problem) throw new Error(status.problem) + if (!status.supported || !status.installed) { + throw new Error("Devsy did not add this launcher, so it was left alone.") + } + const userEntry = this.userEntryPath() + // Check references before mutation; unreadable application directories + // must not turn uncertainty about shared ownership into deletion. + const deleteIcon = + this.ownsIcon(userEntry) && !this.iconUsedElsewhere(userEntry) + unlinkSync(userEntry) + if (deleteIcon) removeIfPresent(this.iconPath()) + await this.options.refreshDesktopDatabase(dirname(userEntry)) + } + + private ownsIcon(userEntry: string): boolean { + try { + const entry = parseDesktopEntry( + userEntry, + readFileSync(userEntry, "utf-8"), + ) + if ( + entry.values.get(OWNER_KEY) !== "true" || + unescapeDesktopString(entry.values.get(ICON_KEY) ?? "") !== + this.iconPath() || + !lstatSync(this.iconPath()).isFile() + ) + return false + const hash = createHash("sha256") + .update(readFileSync(this.iconPath())) + .digest("hex") + return entry.values.get(ICON_HASH_KEY) === hash + } catch { + return false + } + } + + private iconUsedElsewhere(userEntry: string): boolean { + return this.applicationDirs().some((directory) => + readDesktopEntries(directory).some( + (entry) => + entry.path !== userEntry && + [APP_ID, this.iconPath()].includes( + unescapeDesktopString(entry.values.get("Icon") ?? ""), + ), + ), + ) + } +} + +function appImagePathProblem(path: string): string | undefined { + if (!isAbsolute(path) || CONTROL_CHARS.test(path)) { + return "The AppImage path is not a usable absolute path." + } + if (path.includes("=")) { + return "The AppImage path contains '=', which launchers cannot represent. Move the AppImage to a path without '=' and start it again." + } + if (!isFile(path)) return `The AppImage ${path} is not a file.` + return undefined +} + +function isFile(path: string): boolean { + try { + return statSync(path).isFile() + } catch { + return false + } +} + +function renderDesktopEntry( + appImagePath: string, + ownedIcon: string, + iconHash: string, +): string { + const lines = [ + "[Desktop Entry]", + "Type=Application", + "Name=Devsy", + "Comment=Dev environments in any infrastructure", + `Exec=${quoteExecArgument(appImagePath)} %U`, + `Icon=${APP_ID}`, + "Terminal=false", + "Categories=Development;", + "MimeType=x-scheme-handler/devsy;", + `StartupWMClass=${APP_ID}`, + `${OWNER_KEY}=true`, + `${PATH_KEY}=${escapeDesktopString(appImagePath).replace(/ /g, "\\s")}`, + ] + lines.push( + `${ICON_KEY}=${escapeDesktopString(ownedIcon).replace(/ /g, "\\s")}`, + ) + lines.push(`${ICON_HASH_KEY}=${iconHash}`) + return `${lines.join("\n")}\n` +} + +function writeAtomically( + path: string, + content: string | Buffer, + replace: boolean, +): void { + const temporary = `${path}.${randomUUID()}.tmp` + try { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(temporary, content, { flag: "wx", mode: 0o644 }) + if (replace) renameSync(temporary, path) + else linkSync(temporary, path) + } catch (err) { + throw new Error(`Could not write ${path}: ${describeError(err)}`) + } finally { + removeIfPresent(temporary) + } +} + +function pathExists(path: string): boolean { + try { + lstatSync(path) + return true + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return false + throw err + } +} + +function removeIfPresent(path: string): void { + try { + unlinkSync(path) + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err + } +} + +/** Refreshes the MIME cache; the launcher works without the utility. */ +export async function updateDesktopDatabase( + applicationsDir: string, +): Promise { + await promisify(execFile)("update-desktop-database", [applicationsDir]).catch( + () => {}, + ) +} + +function targetsAppImage(entry: DesktopEntry, canonicalImage: string): boolean { + const args = parseExec(entry.values.get("Exec") ?? "") + args.push(unescapeDesktopString(entry.values.get("TryExec") ?? "")) + return args.some((argument) => { + if (!isAbsolute(argument)) return false + try { + return realpathSync(argument) === canonicalImage + } catch { + return false + } + }) +} diff --git a/desktop/src/renderer/src/App.svelte b/desktop/src/renderer/src/App.svelte index 3285d6ba9a..7e7e267fa0 100644 --- a/desktop/src/renderer/src/App.svelte +++ b/desktop/src/renderer/src/App.svelte @@ -2,6 +2,7 @@ import "./app.css" import Router, { push } from "svelte-spa-router" import { onMount, onDestroy } from "svelte" +import AppImageIntegrationPrompt from "$lib/components/layout/AppImageIntegrationPrompt.svelte" import SecretUnlockDialog from "$lib/components/layout/SecretUnlockDialog.svelte" import Sidebar from "$lib/components/layout/Sidebar.svelte" import ThemeSwitcher from "$lib/components/layout/ThemeSwitcher.svelte" @@ -237,4 +238,5 @@ onDestroy(() => { + diff --git a/desktop/src/renderer/src/lib/components/layout/AppImageIntegrationPrompt.svelte b/desktop/src/renderer/src/lib/components/layout/AppImageIntegrationPrompt.svelte new file mode 100644 index 0000000000..b8aa2aaf61 --- /dev/null +++ b/desktop/src/renderer/src/lib/components/layout/AppImageIntegrationPrompt.svelte @@ -0,0 +1,65 @@ + + + + + + Add Devsy to your applications? + You started Devsy directly from an AppImage, so your desktop has no launcher or icon for it. Adding Devsy creates a launcher in your applications menu and lets devsy:// links open this AppImage. You can remove it later in Settings. + + {#if $integrationError}{/if} + + + + + + diff --git a/desktop/src/renderer/src/lib/components/layout/AppImageIntegrationPrompt.test.ts b/desktop/src/renderer/src/lib/components/layout/AppImageIntegrationPrompt.test.ts new file mode 100644 index 0000000000..0e57042b53 --- /dev/null +++ b/desktop/src/renderer/src/lib/components/layout/AppImageIntegrationPrompt.test.ts @@ -0,0 +1,79 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/svelte" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { integrationError } from "$lib/stores/linux-desktop-integration.js" + +const ipc = vi.hoisted(() => ({ + dismissed: false, + supported: true, + error: undefined as string | undefined, +})) +vi.mock("$lib/ipc/commands.js", async (original) => ({ + ...(await original()), + getAppSettings: async () => ({ + settings: { appImageIntegrationPromptDismissed: ipc.dismissed }, + }), + setAppSettings: async () => { + ipc.dismissed = true + }, + getLinuxDesktopIntegration: async () => ({ + supported: ipc.supported, + installed: false, + needsUpdate: false, + }), + installLinuxDesktopIntegration: async () => ({ + status: { supported: true, installed: !ipc.error, needsUpdate: false }, + error: ipc.error, + }), +})) +vi.mock("$lib/stores/settings.js", () => ({ + syncDesktopSettingsFromMain: async () => {}, +})) + +import AppImageIntegrationPrompt from "./AppImageIntegrationPrompt.svelte" + +beforeEach(() => { + ipc.dismissed = false + ipc.supported = true + ipc.error = undefined + integrationError.set(undefined) +}) +afterEach(cleanup) + +describe("AppImage integration prompt", () => { + it("persists its first appearance and does not prompt again after declining", async () => { + render(AppImageIntegrationPrompt) + const decline = await screen.findByRole("button", { name: "Not now" }) + await fireEvent.click(decline) + cleanup() + render(AppImageIntegrationPrompt) + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()) + expect(ipc.dismissed).toBe(true) + }) + + it("reports integration failures and allows retry in the open dialog", async () => { + ipc.error = "Could not write the applications directory" + render(AppImageIntegrationPrompt) + await fireEvent.click( + await screen.findByRole("button", { name: "Add to applications" }), + ) + expect((await screen.findByRole("alert")).textContent).toBe(ipc.error) + ipc.error = undefined + await fireEvent.click( + screen.getByRole("button", { name: "Add to applications" }), + ) + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()) + }) + + it("does not offer integration outside an AppImage", async () => { + ipc.supported = false + render(AppImageIntegrationPrompt) + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()) + expect(ipc.dismissed).toBe(false) + }) +}) diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index a931b1cc07..d3e6cc3419 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -18,6 +18,10 @@ import type { Workspace, } from "$lib/types/index.js" import type { AppNavigationAcknowledgment } from "$shared/app-route.js" +import type { + LinuxDesktopIntegrationResult, + LinuxDesktopIntegrationStatus, +} from "$shared/linux-desktop-integration.js" import type { MachineDiagnosticsCache } from "$shared/machine-diagnostics-types.js" import { invoke } from "./bridge.js" @@ -592,3 +596,19 @@ export async function setAppSettings( { patch }, ) } + +export async function getLinuxDesktopIntegration(): Promise { + return invoke("get_linux_desktop_integration") +} + +export async function installLinuxDesktopIntegration(): Promise { + return invoke( + "install_linux_desktop_integration", + ) +} + +export async function removeLinuxDesktopIntegration(): Promise { + return invoke( + "remove_linux_desktop_integration", + ) +} diff --git a/desktop/src/renderer/src/lib/ipc/mock.ts b/desktop/src/renderer/src/lib/ipc/mock.ts index cf26f632cc..c355314a9d 100644 --- a/desktop/src/renderer/src/lib/ipc/mock.ts +++ b/desktop/src/renderer/src/lib/ipc/mock.ts @@ -15,6 +15,7 @@ import type { SshKeyInfo, Workspace, } from "$lib/types/index.js" +import type { LinuxDesktopIntegrationStatus } from "$shared/linux-desktop-integration.js" // ── Sample data ────────────────────────────────────────────────────── @@ -116,6 +117,7 @@ let MOCK_APP_SETTINGS = { trayNotifications: "failures", desktopLogLevel: "info", cliCaptureLogLevel: "info", + appImageIntegrationPromptDismissed: false, }, startup: { applied: true, enabled: false, status: "disabled" }, } as { @@ -125,6 +127,7 @@ let MOCK_APP_SETTINGS = { trayNotifications: "off" | "failures" | "all" desktopLogLevel: "error" | "warn" | "info" | "debug" | "trace" cliCaptureLogLevel: "error" | "warn" | "info" | "debug" | "trace" + appImageIntegrationPromptDismissed: boolean } startup: { applied: boolean @@ -134,6 +137,14 @@ let MOCK_APP_SETTINGS = { } } +// The browser mock behaves like a Devsy AppImage that was started directly. +let MOCK_LINUX_INTEGRATION: LinuxDesktopIntegrationStatus = { + supported: true, + appImagePath: "/home/user/Applications/Devsy.AppImage", + installed: false, + needsUpdate: false, +} + const CONTEXTS: Context[] = [{ name: "default" }, { name: "staging" }] const SSH_KEYS: SshKeyInfo[] = [ @@ -347,6 +358,20 @@ const COMMANDS: Record = { return MOCK_APP_SETTINGS }, + get_linux_desktop_integration: () => MOCK_LINUX_INTEGRATION, + install_linux_desktop_integration: () => { + MOCK_LINUX_INTEGRATION = { + ...MOCK_LINUX_INTEGRATION, + installed: true, + needsUpdate: false, + } + return { status: MOCK_LINUX_INTEGRATION } + }, + remove_linux_desktop_integration: () => { + MOCK_LINUX_INTEGRATION = { ...MOCK_LINUX_INTEGRATION, installed: false } + return { status: MOCK_LINUX_INTEGRATION } + }, + // Release channel get_release_channel: () => "stable", set_release_channel: () => undefined, diff --git a/desktop/src/renderer/src/lib/stores/desktop-settings.test.ts b/desktop/src/renderer/src/lib/stores/desktop-settings.test.ts index 31881c318c..fa5c04b809 100644 --- a/desktop/src/renderer/src/lib/stores/desktop-settings.test.ts +++ b/desktop/src/renderer/src/lib/stores/desktop-settings.test.ts @@ -35,6 +35,7 @@ function appState(enabled: boolean): AppSettingsState { trayNotifications: "failures", desktopLogLevel: "info", cliCaptureLogLevel: "info", + appImageIntegrationPromptDismissed: false, }, startup: { applied: true, diff --git a/desktop/src/renderer/src/lib/stores/linux-desktop-integration.test.ts b/desktop/src/renderer/src/lib/stores/linux-desktop-integration.test.ts new file mode 100644 index 0000000000..2a512b6f67 --- /dev/null +++ b/desktop/src/renderer/src/lib/stores/linux-desktop-integration.test.ts @@ -0,0 +1,96 @@ +import { get } from "svelte/store" +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { LinuxDesktopIntegrationStatus } from "$shared/linux-desktop-integration.js" + +const ipc = vi.hoisted(() => ({ + get: vi.fn(), + install: vi.fn(), + remove: vi.fn(), +})) + +vi.mock("$lib/ipc/commands.js", async (importOriginal) => ({ + ...(await importOriginal()), + getLinuxDesktopIntegration: ipc.get, + installLinuxDesktopIntegration: ipc.install, + removeLinuxDesktopIntegration: ipc.remove, +})) + +import { + addToApplications, + integrationBusy, + integrationError, + integrationStatus, + refreshIntegrationStatus, + removeFromApplications, + shouldPromptForIntegration, +} from "./linux-desktop-integration.js" + +const fresh: LinuxDesktopIntegrationStatus = { + supported: true, + appImagePath: "/a/Devsy.AppImage", + installed: false, + needsUpdate: false, +} + +beforeEach(() => { + vi.clearAllMocks() + integrationStatus.set(null) + integrationError.set(undefined) +}) + +describe("shouldPromptForIntegration", () => { + it("prompts a directly started AppImage once", () => { + expect(shouldPromptForIntegration(fresh, false)).toBe(true) + expect(shouldPromptForIntegration(fresh, true)).toBe(false) + }) + + it.each([ + ["not an AppImage", { supported: false }], + ["already added", { installed: true }], + ["another launcher exists", { conflict: "Devsy already has a launcher." }], + ["the path is unusable", { problem: "contains '='" }], + ])("does not prompt when %s", (_name, change) => { + expect(shouldPromptForIntegration({ ...fresh, ...change }, false)).toBe( + false, + ) + }) +}) + +describe("integration actions", () => { + it("publishes the status returned by a successful install", async () => { + ipc.install.mockResolvedValue({ status: { ...fresh, installed: true } }) + await addToApplications() + + expect(get(integrationStatus)?.installed).toBe(true) + expect(get(integrationError)).toBeUndefined() + expect(get(integrationBusy)).toBe(false) + }) + + it("keeps the action available and shows the failure", async () => { + ipc.install.mockResolvedValue({ + status: fresh, + error: "Could not write /x", + }) + await addToApplications() + + expect(get(integrationStatus)?.installed).toBe(false) + expect(get(integrationError)).toBe("Could not write /x") + }) + + it("reports a rejected IPC call without leaving the page busy", async () => { + ipc.remove.mockRejectedValue(new Error("bridge down")) + await removeFromApplications() + + expect(get(integrationError)).toBe("bridge down") + expect(get(integrationBusy)).toBe(false) + }) + + it("keeps the previous status when a refresh fails", async () => { + integrationStatus.set(fresh) + ipc.get.mockRejectedValue(new Error("offline")) + vi.spyOn(console, "warn").mockImplementation(() => {}) + + expect(await refreshIntegrationStatus()).toBeNull() + expect(get(integrationStatus)).toEqual(fresh) + }) +}) diff --git a/desktop/src/renderer/src/lib/stores/linux-desktop-integration.ts b/desktop/src/renderer/src/lib/stores/linux-desktop-integration.ts new file mode 100644 index 0000000000..96d756bd00 --- /dev/null +++ b/desktop/src/renderer/src/lib/stores/linux-desktop-integration.ts @@ -0,0 +1,65 @@ +import { writable } from "svelte/store" +import { + getLinuxDesktopIntegration, + installLinuxDesktopIntegration, + removeLinuxDesktopIntegration, +} from "$lib/ipc/commands.js" +import type { + LinuxDesktopIntegrationResult, + LinuxDesktopIntegrationStatus, +} from "$shared/linux-desktop-integration.js" + +export const integrationStatus = writable( + null, +) +export const integrationBusy = writable(false) +export const integrationError = writable(undefined) + +/** The one-time prompt appears only where the install action can succeed. */ +export function shouldPromptForIntegration( + status: LinuxDesktopIntegrationStatus, + dismissed: boolean, +): boolean { + return ( + status.supported && + !status.installed && + !status.conflict && + !status.problem && + !dismissed + ) +} + +export async function refreshIntegrationStatus(): Promise { + try { + const status = await getLinuxDesktopIntegration() + integrationStatus.set(status) + return status + } catch (err) { + console.warn("[integration] status failed:", err) + return null + } +} + +async function runIntegrationAction( + action: () => Promise, +): Promise { + integrationBusy.set(true) + integrationError.set(undefined) + try { + const result = await action() + integrationStatus.set(result.status) + integrationError.set(result.error) + } catch (err) { + integrationError.set(err instanceof Error ? err.message : String(err)) + } finally { + integrationBusy.set(false) + } +} + +export function addToApplications(): Promise { + return runIntegrationAction(installLinuxDesktopIntegration) +} + +export function removeFromApplications(): Promise { + return runIntegrationAction(removeLinuxDesktopIntegration) +} diff --git a/desktop/src/renderer/src/lib/stores/settings.ts b/desktop/src/renderer/src/lib/stores/settings.ts index 0c0b234790..fceb86a217 100644 --- a/desktop/src/renderer/src/lib/stores/settings.ts +++ b/desktop/src/renderer/src/lib/stores/settings.ts @@ -372,6 +372,7 @@ export const openToTrayOnStartup = writable(false) export const trayNotifications = writable("failures") export const desktopLogLevel = writable("info") export const cliCaptureLogLevel = writable("info") +export const appImageIntegrationPromptDismissed = writable(false) export const startupStatus = writable(null) let desktopSettingsQueue: Promise = Promise.resolve() @@ -383,6 +384,9 @@ function applyAppSettingsState(state: AppSettingsState): void { trayNotifications.set(state.settings.trayNotifications) desktopLogLevel.set(state.settings.desktopLogLevel) cliCaptureLogLevel.set(state.settings.cliCaptureLogLevel) + appImageIntegrationPromptDismissed.set( + state.settings.appImageIntegrationPromptDismissed, + ) startupStatus.set(state.startup) } diff --git a/desktop/src/renderer/src/pages/SettingsPage.svelte b/desktop/src/renderer/src/pages/SettingsPage.svelte index 8446c2c237..30310bb9a6 100644 --- a/desktop/src/renderer/src/pages/SettingsPage.svelte +++ b/desktop/src/renderer/src/pages/SettingsPage.svelte @@ -18,6 +18,14 @@ import { Separator } from "$lib/components/ui/separator/index.js" import * as Command from "$lib/components/ui/command/index.js" import * as Popover from "$lib/components/ui/popover/index.js" import { Switch } from "$lib/components/ui/switch/index.js" +import { + addToApplications, + integrationBusy, + integrationError, + integrationStatus, + refreshIntegrationStatus, + removeFromApplications, +} from "$lib/stores/linux-desktop-integration.js" import { theme, applyTheme, @@ -162,6 +170,7 @@ onMount(() => { localOptionsStore.set(local) loading = false void syncDesktopSettingsFromMain() + void refreshIntegrationStatus() }) function saveLocal(key: keyof LocalOptions, value: string | boolean) { @@ -361,6 +370,45 @@ function saveLocal(key: keyof LocalOptions, value: string | boolean) { + {#if $integrationStatus?.supported} +
+

Applications menu

+
+
+
+ +

+ {#if $integrationStatus.problem} + {$integrationStatus.problem} + {:else if $integrationStatus.conflict} + {$integrationStatus.conflict} Devsy leaves it unchanged. + {:else if $integrationStatus.installed && $integrationStatus.needsUpdate} + The launcher points to an older AppImage location. + {:else if $integrationStatus.installed} + Devsy appears in your applications menu and opens devsy:// links. + {:else} + This AppImage has no launcher or icon in your applications menu. + {/if} +

+
+
+ {#if $integrationStatus.installed} + {#if $integrationStatus.needsUpdate} + + {/if} + + {:else} + + {/if} +
+
+ {#if $integrationError} + + {/if} +
+
+ {/if} +

Notifications

diff --git a/desktop/src/shared/app-settings.ts b/desktop/src/shared/app-settings.ts index ad8443861c..b8f4257d05 100644 --- a/desktop/src/shared/app-settings.ts +++ b/desktop/src/shared/app-settings.ts @@ -7,6 +7,8 @@ export interface AppSettings { trayNotifications: TrayNotificationLevel desktopLogLevel: LogLevel cliCaptureLogLevel: LogLevel + /** The one-time "Add to applications" prompt was shown for an AppImage. */ + appImageIntegrationPromptDismissed: boolean /** Removed after migration; accepted only when reading older settings files. */ logLevel?: LogLevel } diff --git a/desktop/src/shared/linux-desktop-integration.ts b/desktop/src/shared/linux-desktop-integration.ts new file mode 100644 index 0000000000..bd5de321bb --- /dev/null +++ b/desktop/src/shared/linux-desktop-integration.ts @@ -0,0 +1,18 @@ +export interface LinuxDesktopIntegrationStatus { + /** True only when Devsy runs from an AppImage on Linux. */ + supported: boolean + appImagePath?: string + /** A launcher written by this integration exists. */ + installed: boolean + /** The owned launcher points at a different AppImage path or lost its icon. */ + needsUpdate: boolean + /** Another launcher already covers Devsy or this AppImage; install is refused. */ + conflict?: string + /** The AppImage path cannot be used in a launcher. */ + problem?: string +} + +export interface LinuxDesktopIntegrationResult { + status: LinuxDesktopIntegrationStatus + error?: string +} diff --git a/hack/release_artifacts/verify_linux_desktop.go b/hack/release_artifacts/verify_linux_desktop.go index ac43023daa..f2b9192d6d 100644 --- a/hack/release_artifacts/verify_linux_desktop.go +++ b/hack/release_artifacts/verify_linux_desktop.go @@ -2,11 +2,12 @@ package main import ( "bufio" - "errors" "fmt" - "io/fs" + "image/png" "os" "path/filepath" + "regexp" + "strconv" "strings" "github.com/spf13/cobra" @@ -14,23 +15,23 @@ import ( const desktopEntryGroup = "[Desktop Entry]" +var themeIconName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`) + func verifyLinuxDesktopCmd() *cobra.Command { var appID, desktopFile, iconRoot string var requiredFiles []string cmd := &cobra.Command{ Use: "verify-linux-desktop", - Short: "assert a Linux package's desktop entry, window class and icon agree with the app ID", + Short: "verify desktop identity and decoded icons in an extracted Linux package", RunE: func(_ *cobra.Command, _ []string) error { return verifyLinuxDesktop(appID, desktopFile, iconRoot, requiredFiles) }, } - cmd.Flags().StringVar(&appID, "app-id", "", "application ID, e.g. sh.devsy.app") - cmd.Flags(). - StringVar(&desktopFile, "desktop-file", "", "desktop entry extracted from the package") + cmd.Flags().StringVar(&appID, "app-id", "", "expected application ID") + cmd.Flags().StringVar(&desktopFile, "desktop-file", "", "packaged desktop entry") + cmd.Flags().StringVar(&iconRoot, "icon-root", "", "package's share/icons/hicolor directory") cmd.Flags(). - StringVar(&iconRoot, "icon-root", "", "directory searched for the PNG named by the entry's Icon key") - cmd.Flags(). - StringArrayVar(&requiredFiles, "require-file", nil, "additional file that must exist and be non-empty") + StringArrayVar(&requiredFiles, "require-file", nil, "additional PNG to decode (runtime or AppDir icon)") for _, f := range []string{"app-id", "desktop-file", "icon-root"} { _ = cmd.MarkFlagRequired(f) } @@ -38,29 +39,105 @@ func verifyLinuxDesktopCmd() *cobra.Command { } func verifyLinuxDesktop(appID, desktopFile, iconRoot string, requiredFiles []string) error { + icon, err := desktopIdentity(appID, desktopFile) + if err != nil { + return err + } + if err := verifyThemeIcons(iconRoot, icon); err != nil { + return err + } + for _, path := range requiredFiles { + if err := verifyPNG(path, 0); err != nil { + return err + } + } + fmt.Printf("desktop=%s class=%s icon=%s: decoded package PNGs\n", desktopFile, appID, icon) + return nil +} + +func desktopIdentity(appID, desktopFile string) (string, error) { if got, want := filepath.Base(desktopFile), appID+".desktop"; got != want { - return fmt.Errorf("::error::desktop entry is named %s, expected %s", got, want) + return "", fmt.Errorf("desktop entry is named %s, expected %s", got, want) } entry, err := readDesktopEntry(desktopFile) if err != nil { - return fmt.Errorf("::error::%s: %w", desktopFile, err) + return "", fmt.Errorf("%s: %w", desktopFile, err) } if got := entry["StartupWMClass"]; got != appID { - return fmt.Errorf("::error::%s: StartupWMClass is %q, expected %q", desktopFile, got, appID) + return "", fmt.Errorf("%s: StartupWMClass is %q, expected %q", desktopFile, got, appID) } + // Builder keeps the executable's icon name even with syncDesktopName. icon := entry["Icon"] - if icon == "" { - return fmt.Errorf("::error::%s: no Icon key", desktopFile) + if !themeIconName.MatchString(icon) { + return "", fmt.Errorf("%s: invalid theme Icon %q", desktopFile, icon) } - iconPath, err := findNonEmptyFile(iconRoot, icon+".png") + return icon, nil +} + +func verifyThemeIcons(iconRoot, icon string) error { + paths, err := filepath.Glob(filepath.Join(iconRoot, "*", "apps", icon+".png")) if err != nil { - return fmt.Errorf("::error::icon %q named by %s: %w", icon, desktopFile, err) + return err } - fmt.Printf("desktop=%s class=%s icon=%s\n", desktopFile, appID, iconPath) - for _, rel := range requiredFiles { - if err := requireNonEmptyFile(rel); err != nil { - return fmt.Errorf("::error::%w", err) + found128 := false + for _, path := range paths { + if err := verifyThemePNG(path); err != nil { + return err } + size := filepath.Base(filepath.Dir(filepath.Dir(path))) + found128 = found128 || size == "128x128" + } + if !found128 { + return fmt.Errorf( + "missing 128x128 theme icon at %s", + filepath.Join(iconRoot, "128x128", "apps", icon+".png"), + ) + } + return nil +} + +func verifyThemePNG(path string) error { + size := filepath.Base(filepath.Dir(filepath.Dir(path))) + width, height, ok := strings.Cut(size, "x") + n, parseErr := strconv.Atoi(width) + if !ok || width != height || parseErr != nil || n < 1 || n > 512 { + return fmt.Errorf("invalid hicolor size directory %q", size) + } + return verifyPNG(path, n) +} + +func verifyPNG(path string, expectedSize int) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer func() { _ = file.Close() }() + config, err := png.DecodeConfig(file) + if err != nil { + return fmt.Errorf("%s: invalid PNG: %w", path, err) + } + if err := verifyPNGDimensions(path, config.Width, config.Height, expectedSize); err != nil { + return err + } + if _, err := file.Seek(0, 0); err != nil { + return err + } + if _, err := png.Decode(file); err != nil { + return fmt.Errorf("%s: malformed PNG data: %w", path, err) + } + return nil +} + +func verifyPNGDimensions(path string, width, height, expectedSize int) error { + if width != height || width < 1 || width > 1024 || + (expectedSize != 0 && width != expectedSize) { + return fmt.Errorf( + "%s: PNG dimensions %dx%d do not match expected square size %d (max 1024)", + path, + width, + height, + expectedSize, + ) } return nil } @@ -71,7 +148,6 @@ func readDesktopEntry(path string) (map[string]string, error) { return nil, err } defer func() { _ = f.Close() }() - entry := map[string]string{} inGroup := false scanner := bufio.NewScanner(f) @@ -81,56 +157,12 @@ func readDesktopEntry(path string) (map[string]string, error) { inGroup = line == desktopEntryGroup continue } - if !inGroup { + if !inGroup || strings.HasPrefix(line, "#") { continue } if key, value, ok := strings.Cut(line, "="); ok { entry[key] = value } } - if err := scanner.Err(); err != nil { - return nil, err - } - return entry, nil -} - -var errFileNotFound = errors.New("not found") - -func findNonEmptyFile(root, name string) (string, error) { - var found string - err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if d.IsDir() || d.Name() != name { - return nil - } - info, err := os.Stat(path) - if err != nil { - return err - } - if info.Size() > 0 { - found = path - return fs.SkipAll - } - return nil - }) - if err != nil { - return "", err - } - if found == "" { - return "", fmt.Errorf("no non-empty %s under %s: %w", name, root, errFileNotFound) - } - return found, nil -} - -func requireNonEmptyFile(path string) error { - info, err := os.Stat(path) - if err != nil { - return err - } - if info.IsDir() || info.Size() == 0 { - return fmt.Errorf("%s is not a non-empty file", path) - } - return nil + return entry, scanner.Err() } diff --git a/hack/release_artifacts/verify_linux_desktop_test.go b/hack/release_artifacts/verify_linux_desktop_test.go index b3c61c361d..32d431a97a 100644 --- a/hack/release_artifacts/verify_linux_desktop_test.go +++ b/hack/release_artifacts/verify_linux_desktop_test.go @@ -1,6 +1,9 @@ package main import ( + "bytes" + "image" + "image/png" "os" "path/filepath" "testing" @@ -8,6 +11,8 @@ import ( "github.com/stretchr/testify/suite" ) +const goodEntry = "[Desktop Entry]\nName=Devsy\nIcon=devsy-desktop\nStartupWMClass=sh.devsy.app\n" + type VerifyLinuxDesktopSuite struct { suite.Suite root string @@ -21,63 +26,86 @@ func (s *VerifyLinuxDesktopSuite) SetupTest() { s.root = s.T().TempDir() } -const goodEntry = "[Desktop Entry]\nName=Devsy\nIcon=sh.devsy.app\nStartupWMClass=sh.devsy.app\n" - -func (s *VerifyLinuxDesktopSuite) TestAcceptsConsistentPackage() { - entry := s.write("share/applications/sh.devsy.app.desktop", goodEntry) - s.write("share/icons/hicolor/128x128/apps/sh.devsy.app.png", "png") - runtimeIcon := s.write("resources/app-icon.png", "png") - - s.Require().NoError(verifyLinuxDesktop("sh.devsy.app", entry, s.root, []string{runtimeIcon})) +func (s *VerifyLinuxDesktopSuite) TestAcceptsBuilderIconNameAndRuntimeMaster() { + entry := s.write("applications/sh.devsy.app.desktop", []byte(goodEntry)) + s.icon("hicolor/32x32/apps/devsy-desktop.png", 32) + s.icon("hicolor/128x128/apps/devsy-desktop.png", 128) + runtime := s.icon("resources/app-icon.png", 1024) + s.Require().NoError(s.verify(entry, []string{runtime})) } -func (s *VerifyLinuxDesktopSuite) TestRejectsWrongFilename() { - entry := s.write("devsy.desktop", goodEntry) - s.write("sh.devsy.app.png", "png") - - s.Require(). - ErrorContains(verifyLinuxDesktop("sh.devsy.app", entry, s.root, nil), "expected sh.devsy.app.desktop") +func (s *VerifyLinuxDesktopSuite) TestAcceptsFlatpakExportName() { + entry := s.write("applications/sh.devsy.app.desktop", []byte( + "[Desktop Entry]\nIcon=sh.devsy.app\nStartupWMClass=sh.devsy.app\n")) + s.icon("hicolor/128x128/apps/sh.devsy.app.png", 128) + s.Require().NoError(s.verify(entry, nil)) } -func (s *VerifyLinuxDesktopSuite) TestRejectsMismatchedWindowClass() { - entry := s.write( - "sh.devsy.app.desktop", - "[Desktop Entry]\nIcon=sh.devsy.app\nStartupWMClass=Devsy\n", - ) - s.write("sh.devsy.app.png", "png") +func (s *VerifyLinuxDesktopSuite) TestRejectsWrongFilenameOrClass() { + entry := s.write("applications/devsy.desktop", []byte(goodEntry)) + s.Require().ErrorContains(s.verify(entry, nil), "expected sh.devsy.app.desktop") + entry = s.write("applications/sh.devsy.app.desktop", []byte( + "[Desktop Entry]\nIcon=devsy-desktop\nStartupWMClass=Devsy\n")) + s.Require().ErrorContains(s.verify(entry, nil), "StartupWMClass") +} - s.Require(). - ErrorContains(verifyLinuxDesktop("sh.devsy.app", entry, s.root, nil), "StartupWMClass") +func (s *VerifyLinuxDesktopSuite) TestRejectsIconOutsideThemePath() { + entry := s.write("applications/sh.devsy.app.desktop", []byte(goodEntry)) + s.icon("hicolor/wrong/place/devsy-desktop.png", 128) + s.Require().ErrorContains(s.verify(entry, nil), "missing 128x128 theme icon") } -func (s *VerifyLinuxDesktopSuite) TestIgnoresKeysOutsideDesktopEntryGroup() { - entry := s.write("sh.devsy.app.desktop", - "[Desktop Entry]\nIcon=sh.devsy.app\n[Desktop Action x]\nStartupWMClass=sh.devsy.app\n") - s.write("sh.devsy.app.png", "png") +func (s *VerifyLinuxDesktopSuite) TestRejectsUndecodableImage() { + entry := s.write("applications/sh.devsy.app.desktop", []byte(goodEntry)) + s.write("hicolor/128x128/apps/devsy-desktop.png", []byte("png")) + s.Require().ErrorContains(s.verify(entry, nil), "invalid PNG") +} - s.Require(). - ErrorContains(verifyLinuxDesktop("sh.devsy.app", entry, s.root, nil), "StartupWMClass") +func (s *VerifyLinuxDesktopSuite) TestRejectsTruncatedImageData() { + entry := s.write("applications/sh.devsy.app.desktop", []byte(goodEntry)) + path := s.icon("hicolor/128x128/apps/devsy-desktop.png", 128) + data, err := os.ReadFile(path) + s.Require().NoError(err) + s.Require().NoError(os.WriteFile(path, data[:len(data)-16], 0o600)) + s.Require().ErrorContains(s.verify(entry, nil), "malformed PNG data") } -func (s *VerifyLinuxDesktopSuite) TestRejectsMissingOrEmptyIcon() { - entry := s.write("sh.devsy.app.desktop", goodEntry) - s.Require().ErrorContains(verifyLinuxDesktop("sh.devsy.app", entry, s.root, nil), "not found") +func (s *VerifyLinuxDesktopSuite) TestRejectsWrongDimensionsAndExportSize() { + entry := s.write("applications/sh.devsy.app.desktop", []byte(goodEntry)) + s.icon("hicolor/128x128/apps/devsy-desktop.png", 32) + s.Require().ErrorContains(s.verify(entry, nil), "PNG dimensions") + s.icon("hicolor/128x128/apps/devsy-desktop.png", 128) + s.icon("hicolor/1024x1024/apps/devsy-desktop.png", 1024) + s.Require().ErrorContains(s.verify(entry, nil), "invalid hicolor size") +} - s.write("icons/sh.devsy.app.png", "") - s.Require().ErrorContains(verifyLinuxDesktop("sh.devsy.app", entry, s.root, nil), "not found") +func (s *VerifyLinuxDesktopSuite) TestRequiresDecodedRuntimePNG() { + entry := s.write("applications/sh.devsy.app.desktop", []byte(goodEntry)) + s.icon("hicolor/128x128/apps/devsy-desktop.png", 128) + runtime := s.write("resources/app-icon.png", []byte("not an image")) + s.Require().ErrorContains(s.verify(entry, []string{runtime}), "invalid PNG") + s.Require().Error(s.verify(entry, []string{filepath.Join(s.root, "absent")})) } -func (s *VerifyLinuxDesktopSuite) TestRejectsMissingRequiredFile() { - entry := s.write("sh.devsy.app.desktop", goodEntry) - s.write("sh.devsy.app.png", "png") +func (s *VerifyLinuxDesktopSuite) TestIgnoresKeysOutsideDesktopEntryGroup() { + entry := s.write("applications/sh.devsy.app.desktop", []byte( + "[Desktop Entry]\nIcon=devsy-desktop\n[Desktop Action x]\nStartupWMClass=sh.devsy.app\n")) + s.Require().ErrorContains(s.verify(entry, nil), "StartupWMClass") +} - s.Require(). - Error(verifyLinuxDesktop("sh.devsy.app", entry, s.root, []string{filepath.Join(s.root, "absent.png")})) +func (s *VerifyLinuxDesktopSuite) verify(entry string, required []string) error { + return verifyLinuxDesktop("sh.devsy.app", entry, filepath.Join(s.root, "hicolor"), required) } -func (s *VerifyLinuxDesktopSuite) write(rel, content string) string { +func (s *VerifyLinuxDesktopSuite) write(rel string, content []byte) string { path := filepath.Join(s.root, rel) s.Require().NoError(os.MkdirAll(filepath.Dir(path), 0o750)) - s.Require().NoError(os.WriteFile(path, []byte(content), 0o600)) + s.Require().NoError(os.WriteFile(path, content, 0o600)) return path } + +func (s *VerifyLinuxDesktopSuite) icon(rel string, size int) string { + var data bytes.Buffer + s.Require().NoError(png.Encode(&data, image.NewRGBA(image.Rect(0, 0, size, size)))) + return s.write(rel, data.Bytes()) +} diff --git a/sites/docs-devsy-sh/content/docs/getting-started/install.mdx b/sites/docs-devsy-sh/content/docs/getting-started/install.mdx index 44825e2276..da6cbba1be 100644 --- a/sites/docs-devsy-sh/content/docs/getting-started/install.mdx +++ b/sites/docs-devsy-sh/content/docs/getting-started/install.mdx @@ -27,6 +27,12 @@ For earlier versions, see the [GitHub releases page](https://github.com/devsy-or The DEB and RPM packages declare their dependencies, and the AppImage and Flatpak bundle theirs. If the AppImage does not start, install FUSE 2 (`libfuse2` on Debian and Ubuntu) and try again. +For an AppImage, first move it to a permanent location and make it executable. On its first direct launch, Devsy offers **Add to applications**. This creates a user-local launcher and copies its icon outside the temporary AppImage mount. You can decline and add it later under **Settings > Applications menu**. + +If you move the AppImage later, start it once from its new location; Devsy updates a launcher it owns. **Settings > Applications menu > Remove** removes only that launcher and its owned icon. Devsy does not overwrite an existing system, Flatpak, user-managed or third-party AppImage launcher, or change another package's default `devsy://` handler. + +Flatpak, DEB and RPM packages install their own launchers; the AppImage action is not used for those formats. See [Linux icon troubleshooting](/docs/troubleshooting/linux-troubleshooting#generic-application-icon-on-gnome) if a running window still shows a generic icon. + If the app does not open, install [WebView 2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/?form=MA13LH). Recent versions of Windows include it. diff --git a/sites/docs-devsy-sh/content/docs/troubleshooting/linux-troubleshooting.mdx b/sites/docs-devsy-sh/content/docs/troubleshooting/linux-troubleshooting.mdx index f3400dfa5e..b38ee63808 100644 --- a/sites/docs-devsy-sh/content/docs/troubleshooting/linux-troubleshooting.mdx +++ b/sites/docs-devsy-sh/content/docs/troubleshooting/linux-troubleshooting.mdx @@ -54,11 +54,17 @@ With SELinux, a mounted volume can fail with "Permission denied" even when owner ### Generic application icon on GNOME -GNOME on Wayland takes the icon of a running window from an installed desktop entry named `sh.devsy.app.desktop`. The Flatpak, DEB and RPM packages install that entry and the matching `sh.devsy.app` icon. +GNOME on Wayland associates a running application with an installed desktop entry. Devsy uses the desktop filename `sh.devsy.app.desktop` and window identity `sh.devsy.app`. Flatpak exports a theme icon named `sh.devsy.app`; electron-builder's DEB/RPM packages retain the icon name `devsy-desktop`. The icon name must match the package's desktop entry, not necessarily its application ID. -An AppImage you run directly is not installed anywhere, so GNOME has no desktop entry to match the window to and can show a generic icon. Integrate the AppImage with a tool such as [AppImageLauncher](https://github.com/TheAssassin/AppImageLauncher), or create your own desktop entry with `StartupWMClass=sh.devsy.app` and `Icon=sh.devsy.app`. +Directly executing an AppImage does not install its embedded desktop entry. Choose **Add to applications** in the first-launch prompt, or open **Settings > Applications menu**. Devsy writes `$XDG_DATA_HOME/applications/sh.devsy.app.desktop` and copies the 128-pixel icon to `$XDG_DATA_HOME/icons/hicolor/128x128/apps/sh.devsy.app.png` (default data home: `~/.local/share`). The launcher uses the permanent AppImage path, never its temporary mount. -If a launcher you pinned before updating still shows the wrong icon, unpin it and pin Devsy again. +Declining leaves the AppImage usable, but a generic or ungrouped GNOME Wayland icon can remain until desktop integration is installed. If a third-party integrator already manages this AppImage, keep using that integration; Devsy reports the existing launcher rather than creating a duplicate. An existing system or Flatpak Devsy launcher also blocks installation. Remove the conflicting installation or manage its launcher yourself before retrying. + +If you move the AppImage, launch it once from its new path to update Devsy's owned launcher automatically. Settings also offers **Update launcher** and **Remove**. Paths containing `=` cannot be represented in a desktop launcher; move the AppImage to another path. Read-only data directories and other integration failures are reported without preventing normal Devsy use. + +For manual integration, both the desktop entry and a persistent icon file must be installed. Do not reference an icon inside `/tmp/.mount_*`: it disappears when the AppImage exits. Use the package-specific icon name described above, or copy an icon to the user's icon-theme directory and reference that name. Refreshing the icon cache alone cannot repair a missing desktop entry or asset. + +After upgrading from v1.24.0, an old pinned launcher may still reference a previous desktop filename. Unpin that launcher and pin Devsy again. Check the applications directories for older `devsy-desktop.desktop` entries before creating another launcher; do not delete entries owned by another package. ### `ENAMETOOLONG` when opening a workspace in VS Code From 69dad22e2c3274c1107d0ba03c433e1ce49c3378 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 26 Oct 2026 03:07:33 -0600 Subject: [PATCH 3/4] fix(desktop): preserve customized AppImage launchers and icons Verify complete launcher bytes before updating or removing generated entries. Recheck snapshots at mutation boundaries and leave user edits unchanged. Preserve existing icon bytes while repairing paths or re-adding launchers, without adopting foreign or shared icons for later deletion. Refs #1652 --- .../linux-desktop-integration.test.ts | 168 ++++++++++++++- desktop/src/main/linux-desktop-integration.ts | 199 ++++++++++++++---- .../content/docs/getting-started/install.mdx | 2 +- .../troubleshooting/linux-troubleshooting.mdx | 2 + 4 files changed, 321 insertions(+), 50 deletions(-) diff --git a/desktop/src/main/__tests__/linux-desktop-integration.test.ts b/desktop/src/main/__tests__/linux-desktop-integration.test.ts index de269bdbd5..adb0cd99c3 100644 --- a/desktop/src/main/__tests__/linux-desktop-integration.test.ts +++ b/desktop/src/main/__tests__/linux-desktop-integration.test.ts @@ -75,6 +75,7 @@ beforeEach(() => { }) afterEach(() => { + vi.restoreAllMocks() chmodSync(root, 0o700) rmSync(root, { recursive: true, force: true }) }) @@ -264,10 +265,17 @@ describe("install", () => { expect(result.status.installed).toBe(true) }) - it("keeps an icon it did not write", async () => { - write(userIcon(), "someone else's icon") - await integration({ env: { APPIMAGE: appImage() } }).install() - expect(readFileSync(userIcon(), "utf-8")).toBe("someone else's icon") + it("adds a launcher without claiming an existing foreign icon", async () => { + const foreignIcon = Buffer.from("someone else's icon") + write(userIcon(), foreignIcon) + const target = integration({ env: { APPIMAGE: appImage() } }) + const result = await target.install() + expect(result.error).toBeUndefined() + expect(result.status.installed).toBe(true) + expect(readFileSync(userIcon())).toEqual(foreignIcon) + await target.remove() + expect(existsSync(join(userApplications(), OWNED_ENTRY))).toBe(false) + expect(readFileSync(userIcon())).toEqual(foreignIcon) }) it.skipIf(process.getuid?.() === 0)( @@ -300,6 +308,9 @@ describe("moved AppImage", () => { const entry = readFileSync(join(userApplications(), OWNED_ENTRY), "utf-8") expect(entry).toContain(`Exec=${after} %U`) expect(entry).not.toContain("Old.AppImage") + expect((await moved.remove()).error).toBeUndefined() + expect(existsSync(join(userApplications(), OWNED_ENTRY))).toBe(false) + expect(existsSync(userIcon())).toBe(false) }) it("leaves a declined launcher absent", async () => { @@ -473,3 +484,152 @@ describe("ownership boundaries", () => { expect(existsSync(userIcon())).toBe(false) }) }) + +describe("launcher and icon ownership", () => { + it.each(["Exec", "Name", "custom field"] as const)( + "preserves a user-edited %s launcher through move, refresh and removal", + async (change) => { + const original = integration({ + env: { APPIMAGE: appImage("Old.AppImage") }, + }) + expect((await original.install()).error).toBeUndefined() + const path = join(userApplications(), OWNED_ENTRY) + const generated = readFileSync(path, "utf8") + const edited = + change === "Exec" + ? generated.replace(/^(Exec=.*)$/m, "$1 --custom-argument") + : change === "Name" + ? generated.replace(/^Name=.*$/m, "Name=My development launcher") + : `${generated}X-Local-Preference=keep-me\n` + writeFileSync(path, edited) + const expectedBytes = readFileSync(path) + const moved = integration({ + env: { APPIMAGE: appImage("Moved.AppImage") }, + }) + + const refreshed = await moved.refreshOwned() + expect(refreshed.status.conflict).toBeDefined() + expect(refreshed.status.installed).toBe(false) + expect(readFileSync(path)).toEqual(expectedBytes) + expect((await moved.remove()).error).toBeDefined() + expect(readFileSync(path)).toEqual(expectedBytes) + }, + ) + + it.each(["install", "remove"] as const)( + "rechecks an edit made after the status check before %s", + async (operation) => { + const target = integration({ env: { APPIMAGE: appImage() } }) + expect((await target.install()).error).toBeUndefined() + const path = join(userApplications(), OWNED_ENTRY) + const edited = Buffer.from( + `${readFileSync(path, "utf8")}# keep this edit\n`, + ) + const status = target.status.bind(target) + vi.spyOn(target, "status").mockImplementationOnce(() => { + const checked = status() + writeFileSync(path, edited) + return checked + }) + + const result = + operation === "install" ? await target.install() : await target.remove() + expect(result.error).toBeDefined() + expect(readFileSync(path)).toEqual(edited) + }, + ) + + it("rechecks launcher bytes immediately before replacing the prepared file", async () => { + const path = join(userApplications(), OWNED_ENTRY) + const source = write(join(root, "source-icon.png"), ICON_BYTES) + let editBeforeReplacement = false + let edited = "" + const target = new LinuxDesktopIntegration({ + platform: "linux", + env: { + APPIMAGE: appImage(), + XDG_DATA_HOME: join(root, "data"), + XDG_DATA_DIRS: join(root, "system"), + }, + homeDir: join(root, "home"), + get iconSource() { + if (editBeforeReplacement) writeFileSync(path, edited) + return source + }, + refreshDesktopDatabase: async () => {}, + }) + expect((await target.install()).error).toBeUndefined() + edited = `${readFileSync(path, "utf8")}X-Local-Preference=concurrent-edit\n` + rmSync(userIcon()) + editBeforeReplacement = true + + const result = await target.install() + expect(result.error).toBeDefined() + expect(readFileSync(path, "utf8")).toBe(edited) + }) + + it("repairs a moved AppImage while preserving a customized icon", async () => { + const original = integration({ + env: { APPIMAGE: appImage("Old.AppImage") }, + }) + expect((await original.install()).error).toBeUndefined() + const customized = Buffer.from("custom icon bytes") + write(userIcon(), customized) + const movedPath = appImage("Moved.AppImage") + const moved = integration({ env: { APPIMAGE: movedPath } }) + + const result = await moved.refreshOwned() + expect(result.error).toBeUndefined() + expect(result.status).toMatchObject({ installed: true, needsUpdate: false }) + const entry = readFileSync(join(userApplications(), OWNED_ENTRY), "utf8") + const exec = entry.split("\n").find((line) => line.startsWith("Exec=")) + expect(parseExec(exec?.slice(5) ?? "")).toEqual([movedPath, "%U"]) + expect(readFileSync(userIcon())).toEqual(customized) + expect((await moved.remove()).error).toBeUndefined() + expect(readFileSync(userIcon())).toEqual(customized) + }) + + it("re-adds after preserving a shared icon without adopting its ownership", async () => { + const target = integration({ env: { APPIMAGE: appImage() } }) + expect((await target.install()).error).toBeUndefined() + const otherEntry = write( + join(root, "sys-a", "applications", "other-launcher.desktop"), + "[Desktop Entry]\nName=Other launcher\nIcon=sh.devsy.app\n", + ) + const sharedBytes = readFileSync(userIcon()) + expect((await target.remove()).error).toBeUndefined() + expect(readFileSync(userIcon())).toEqual(sharedBytes) + + const result = await target.install() + expect(result.error).toBeUndefined() + expect(result.status.installed).toBe(true) + expect(readFileSync(userIcon())).toEqual(sharedBytes) + rmSync(otherEntry) + expect((await target.remove()).error).toBeUndefined() + expect(readFileSync(userIcon())).toEqual(sharedBytes) + }) +}) + +describe("shared icon path repair", () => { + it("never overwrites or later adopts an icon shared during a path update", async () => { + const original = integration({ + env: { APPIMAGE: appImage("Old.AppImage") }, + }) + expect((await original.install()).error).toBeUndefined() + const otherEntry = write( + join(root, "sys-a", "applications", "other-launcher.desktop"), + "[Desktop Entry]\nName=Other launcher\nIcon=sh.devsy.app\n", + ) + const sharedBytes = readFileSync(userIcon()) + const moved = integration({ env: { APPIMAGE: appImage("Moved.AppImage") } }) + write(join(root, "icon-source.png"), "new bundled artwork") + + const result = await moved.refreshOwned() + expect(result.error).toBeUndefined() + expect(result.status).toMatchObject({ installed: true, needsUpdate: false }) + expect(readFileSync(userIcon())).toEqual(sharedBytes) + rmSync(otherEntry) + expect((await moved.remove()).error).toBeUndefined() + expect(readFileSync(userIcon())).toEqual(sharedBytes) + }) +}) diff --git a/desktop/src/main/linux-desktop-integration.ts b/desktop/src/main/linux-desktop-integration.ts index caa1efb115..c623009153 100644 --- a/desktop/src/main/linux-desktop-integration.ts +++ b/desktop/src/main/linux-desktop-integration.ts @@ -95,10 +95,12 @@ export function parseExec(raw: string): string[] { interface DesktopEntry { path: string + content: Buffer values: Map } -function parseDesktopEntry(path: string, text: string): DesktopEntry { +function parseDesktopEntry(path: string, content: Buffer): DesktopEntry { + const text = content.toString("utf8") const values = new Map() let inGroup = false for (const rawLine of text.split("\n")) { @@ -115,7 +117,7 @@ function parseDesktopEntry(path: string, text: string): DesktopEntry { } } } - return { path, values } + return { path, content, values } } function readDesktopEntries(directory: string): DesktopEntry[] { @@ -131,7 +133,7 @@ function readDesktopEntries(directory: string): DesktopEntry[] { const path = join(directory, name) if (name.endsWith(".desktop")) { try { - entries.push(parseDesktopEntry(path, readFileSync(path, "utf-8"))) + entries.push(parseDesktopEntry(path, readFileSync(path))) } catch (err) { throw new Error( `Could not inspect launcher ${path}: ${describeError(err)}`, @@ -280,13 +282,13 @@ export class LinuxDesktopIntegration { let conflict: string | undefined for (const directory of this.applicationDirs()) { for (const entry of readDesktopEntries(directory)) { - if ( + if (entry.path === userEntry && this.isOwnedLauncher(entry)) { + owned = entry + } else if ( entry.path === userEntry && - entry.values.get(OWNER_KEY) === "true" && - entry.values.get(PATH_KEY) && - !lstatSync(userEntry).isSymbolicLink() + entry.values.get(OWNER_KEY) === "true" ) { - owned = entry + conflict ??= `The launcher at ${entry.path} was modified. Manage it manually or move it aside before adding Devsy again.` } else if (entry.path.endsWith(`/${ENTRY_FILE}`)) { conflict ??= `Devsy already has a launcher at ${entry.path}.` } else if (targetsAppImage(entry, canonicalImage)) { @@ -321,24 +323,52 @@ export class LinuxDesktopIntegration { if (status.conflict) throw new Error(status.conflict) const userEntry = this.userEntryPath() + const previous = this.launcherForMutation(status.installed) const iconPath = this.iconPath() const iconExists = pathExists(iconPath) - if (iconExists && !this.ownsIcon(userEntry)) { - throw new Error( - `An icon Devsy does not own already exists at ${iconPath}.`, - ) + let ownedIcon: OwnedIcon | undefined + let copiedIcon: Buffer | undefined + if (iconExists) { + if (!isFile(iconPath)) + throw new Error(`The icon at ${iconPath} is not a file.`) + const ownedBytes = this.ownedIconContents(previous) + const recordedIconHash = previous?.values.get(ICON_HASH_KEY) + // Repairing a launcher must not replace a customized or shared icon. + if ( + ownedBytes && + recordedIconHash && + !this.iconUsedElsewhere(userEntry) && + fileMatches(iconPath, ownedBytes) + ) { + ownedIcon = { path: iconPath, hash: recordedIconHash } + } + } else { + copiedIcon = readFileSync(this.options.iconSource) + writeAtomically(iconPath, copiedIcon) + ownedIcon = { + path: iconPath, + hash: createHash("sha256").update(copiedIcon).digest("hex"), + } } - const icon = readFileSync(this.options.iconSource) - const iconHash = createHash("sha256").update(icon).digest("hex") - writeAtomically(iconPath, icon, iconExists) try { writeAtomically( userEntry, - renderDesktopEntry(status.appImagePath, iconPath, iconHash), - status.installed, + renderDesktopEntry(status.appImagePath, ownedIcon), + previous?.content, ) } catch (err) { - if (!iconExists) removeIfPresent(iconPath) + // Keep the icon if any concurrent launcher may now reference it. + if ( + copiedIcon && + !pathExists(userEntry) && + fileMatches(iconPath, copiedIcon) + ) { + try { + if (!this.iconUsedElsewhere(userEntry)) removeIfPresent(iconPath) + } catch { + // Unreadable application directories cannot establish exclusive ownership. + } + } throw err } await this.options.refreshDesktopDatabase(dirname(userEntry)) @@ -347,38 +377,95 @@ export class LinuxDesktopIntegration { private async removeLauncher(): Promise { const status = this.status() if (status.problem) throw new Error(status.problem) + if (!status.installed && status.conflict) throw new Error(status.conflict) if (!status.supported || !status.installed) { throw new Error("Devsy did not add this launcher, so it was left alone.") } const userEntry = this.userEntryPath() + const previous = this.launcherForMutation(true) + if (!previous) + throw new Error("The Devsy launcher disappeared; nothing was removed.") + const ownedIcon = this.ownedIconContents(previous) // Check references before mutation; unreadable application directories // must not turn uncertainty about shared ownership into deletion. - const deleteIcon = - this.ownsIcon(userEntry) && !this.iconUsedElsewhere(userEntry) + const deleteIcon = ownedIcon && !this.iconUsedElsewhere(userEntry) + if (!fileMatches(userEntry, previous.content)) { + throw new Error( + `The launcher at ${userEntry} changed while removing it; it was left unchanged.`, + ) + } unlinkSync(userEntry) - if (deleteIcon) removeIfPresent(this.iconPath()) + if (deleteIcon && ownedIcon && fileMatches(this.iconPath(), ownedIcon)) { + removeIfPresent(this.iconPath()) + } await this.options.refreshDesktopDatabase(dirname(userEntry)) } - private ownsIcon(userEntry: string): boolean { - try { - const entry = parseDesktopEntry( - userEntry, - readFileSync(userEntry, "utf-8"), - ) + private isOwnedLauncher(entry: DesktopEntry): boolean { + const recordedPath = unescapeDesktopString(entry.values.get(PATH_KEY) ?? "") + if ( + entry.values.get(OWNER_KEY) !== "true" || + !isAbsolute(recordedPath) || + CONTROL_CHARS.test(recordedPath) || + recordedPath.includes("=") || + !lstatSync(entry.path).isFile() + ) + return false + const iconPath = entry.values.get(ICON_KEY) + const iconHash = entry.values.get(ICON_HASH_KEY) + let ownedIcon: OwnedIcon | undefined + if (iconPath !== undefined || iconHash !== undefined) { if ( - entry.values.get(OWNER_KEY) !== "true" || - unescapeDesktopString(entry.values.get(ICON_KEY) ?? "") !== - this.iconPath() || - !lstatSync(this.iconPath()).isFile() + !iconPath || + !iconHash || + unescapeDesktopString(iconPath) !== this.iconPath() || + !/^[a-f0-9]{64}$/.test(iconHash) ) return false - const hash = createHash("sha256") - .update(readFileSync(this.iconPath())) - .digest("hex") - return entry.values.get(ICON_HASH_KEY) === hash + ownedIcon = { path: this.iconPath(), hash: iconHash } + } + return entry.content.equals( + Buffer.from(renderDesktopEntry(recordedPath, ownedIcon)), + ) + } + + private launcherForMutation(wasInstalled: boolean): DesktopEntry | undefined { + const path = this.userEntryPath() + if (!pathExists(path)) { + if (wasInstalled) + throw new Error( + `The launcher at ${path} disappeared since it was checked.`, + ) + return undefined + } + const entry = parseDesktopEntry(path, readFileSync(path)) + if (!wasInstalled || !this.isOwnedLauncher(entry)) { + throw new Error( + `The launcher at ${path} changed since it was checked; it was left unchanged.`, + ) + } + return entry + } + + private ownedIconContents( + entry: DesktopEntry | undefined, + ): Buffer | undefined { + if ( + !entry || + unescapeDesktopString(entry.values.get(ICON_KEY) ?? "") !== + this.iconPath() + ) { + return undefined + } + try { + if (!lstatSync(this.iconPath()).isFile()) return undefined + const bytes = readFileSync(this.iconPath()) + return entry.values.get(ICON_HASH_KEY) === + createHash("sha256").update(bytes).digest("hex") + ? bytes + : undefined } catch { - return false + return undefined } } @@ -414,10 +501,14 @@ function isFile(path: string): boolean { } } +interface OwnedIcon { + path: string + hash: string +} + function renderDesktopEntry( appImagePath: string, - ownedIcon: string, - iconHash: string, + ownedIcon?: OwnedIcon, ): string { const lines = [ "[Desktop Entry]", @@ -433,24 +524,33 @@ function renderDesktopEntry( `${OWNER_KEY}=true`, `${PATH_KEY}=${escapeDesktopString(appImagePath).replace(/ /g, "\\s")}`, ] - lines.push( - `${ICON_KEY}=${escapeDesktopString(ownedIcon).replace(/ /g, "\\s")}`, - ) - lines.push(`${ICON_HASH_KEY}=${iconHash}`) + if (ownedIcon) { + lines.push( + `${ICON_KEY}=${escapeDesktopString(ownedIcon.path).replace(/ /g, "\\s")}`, + ) + lines.push(`${ICON_HASH_KEY}=${ownedIcon.hash}`) + } return `${lines.join("\n")}\n` } function writeAtomically( path: string, content: string | Buffer, - replace: boolean, + expected?: Buffer, ): void { const temporary = `${path}.${randomUUID()}.tmp` try { mkdirSync(dirname(path), { recursive: true }) writeFileSync(temporary, content, { flag: "wx", mode: 0o644 }) - if (replace) renameSync(temporary, path) - else linkSync(temporary, path) + if (expected === undefined) linkSync(temporary, path) + else { + if (!fileMatches(path, expected)) { + throw new Error( + "The existing file changed or could not be verified; it was left unchanged.", + ) + } + renameSync(temporary, path) + } } catch (err) { throw new Error(`Could not write ${path}: ${describeError(err)}`) } finally { @@ -458,6 +558,15 @@ function writeAtomically( } } +function fileMatches(path: string, expected: Buffer): boolean { + try { + if (!lstatSync(path).isFile()) return false + return readFileSync(path).equals(expected) + } catch { + return false + } +} + function pathExists(path: string): boolean { try { lstatSync(path) diff --git a/sites/docs-devsy-sh/content/docs/getting-started/install.mdx b/sites/docs-devsy-sh/content/docs/getting-started/install.mdx index da6cbba1be..58c9eb6479 100644 --- a/sites/docs-devsy-sh/content/docs/getting-started/install.mdx +++ b/sites/docs-devsy-sh/content/docs/getting-started/install.mdx @@ -29,7 +29,7 @@ The DEB and RPM packages declare their dependencies, and the AppImage and Flatpa For an AppImage, first move it to a permanent location and make it executable. On its first direct launch, Devsy offers **Add to applications**. This creates a user-local launcher and copies its icon outside the temporary AppImage mount. You can decline and add it later under **Settings > Applications menu**. -If you move the AppImage later, start it once from its new location; Devsy updates a launcher it owns. **Settings > Applications menu > Remove** removes only that launcher and its owned icon. Devsy does not overwrite an existing system, Flatpak, user-managed or third-party AppImage launcher, or change another package's default `devsy://` handler. +If you move the AppImage later, start it once from its new location; Devsy updates an unmodified launcher it owns. **Settings > Applications menu > Remove** removes only that unmodified launcher and its owned icon. User-edited launchers and customized, shared or pre-existing icons are preserved. Devsy does not overwrite an existing system, Flatpak, user-managed or third-party AppImage launcher, or change another package's default `devsy://` handler. Flatpak, DEB and RPM packages install their own launchers; the AppImage action is not used for those formats. See [Linux icon troubleshooting](/docs/troubleshooting/linux-troubleshooting#generic-application-icon-on-gnome) if a running window still shows a generic icon. diff --git a/sites/docs-devsy-sh/content/docs/troubleshooting/linux-troubleshooting.mdx b/sites/docs-devsy-sh/content/docs/troubleshooting/linux-troubleshooting.mdx index b38ee63808..364d2cd905 100644 --- a/sites/docs-devsy-sh/content/docs/troubleshooting/linux-troubleshooting.mdx +++ b/sites/docs-devsy-sh/content/docs/troubleshooting/linux-troubleshooting.mdx @@ -62,6 +62,8 @@ Declining leaves the AppImage usable, but a generic or ungrouped GNOME Wayland i If you move the AppImage, launch it once from its new path to update Devsy's owned launcher automatically. Settings also offers **Update launcher** and **Remove**. Paths containing `=` cannot be represented in a desktop launcher; move the AppImage to another path. Read-only data directories and other integration failures are reported without preventing normal Devsy use. +If you edit a launcher created by Devsy, Devsy leaves the entire edited file unchanged, including during updates or removal. Settings reports the conflict; manage that launcher manually or move it aside before using **Add to applications** again. A customized, shared or pre-existing icon is preserved when repairing or re-adding a launcher and is not claimed for later deletion. + For manual integration, both the desktop entry and a persistent icon file must be installed. Do not reference an icon inside `/tmp/.mount_*`: it disappears when the AppImage exits. Use the package-specific icon name described above, or copy an icon to the user's icon-theme directory and reference that name. Refreshing the icon cache alone cannot repair a missing desktop entry or asset. After upgrading from v1.24.0, an old pinned launcher may still reference a previous desktop filename. Unpin that launcher and pin Devsy again. Check the applications directories for older `devsy-desktop.desktop` entries before creating another launcher; do not delete entries owned by another package. From d485425ea9a6202a0024678511c64fa80cecdc87 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 26 Oct 2026 06:08:21 -0600 Subject: [PATCH 4/4] fix(ci): install cpio for RPM verification --- .github/workflows/release.yml | 2 +- .../layout/AppImageIntegrationPrompt.test.ts | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be0e7df98a..b654711444 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -227,7 +227,7 @@ jobs: - name: install desktop-file-utils if: matrix.name == 'linux-x64' - run: sudo apt-get install -y desktop-file-utils rpm + run: sudo apt-get install -y desktop-file-utils rpm cpio - name: verify Linux desktop identity and icons in packages if: matrix.name == 'linux-x64' diff --git a/desktop/src/renderer/src/lib/components/layout/AppImageIntegrationPrompt.test.ts b/desktop/src/renderer/src/lib/components/layout/AppImageIntegrationPrompt.test.ts index 0e57042b53..18f5ad0db2 100644 --- a/desktop/src/renderer/src/lib/components/layout/AppImageIntegrationPrompt.test.ts +++ b/desktop/src/renderer/src/lib/components/layout/AppImageIntegrationPrompt.test.ts @@ -71,9 +71,15 @@ describe("AppImage integration prompt", () => { }) it("does not offer integration outside an AppImage", async () => { - ipc.supported = false - render(AppImageIntegrationPrompt) - await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()) - expect(ipc.dismissed).toBe(false) + vi.useFakeTimers() + try { + ipc.supported = false + render(AppImageIntegrationPrompt) + await vi.runOnlyPendingTimersAsync() + expect(screen.queryByRole("dialog")).toBeNull() + expect(ipc.dismissed).toBe(false) + } finally { + vi.useRealTimers() + } }) })