diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 17dfcbb3b6..b654711444 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -225,6 +225,55 @@ 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 cpio + + - 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/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/hicolor" \ + --require-file "$root/opt/Devsy/resources/app-icon.png" + 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 +427,31 @@ 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 + # 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) + 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__/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-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/__tests__/linux-desktop-integration.test.ts b/desktop/src/main/__tests__/linux-desktop-integration.test.ts new file mode 100644 index 0000000000..adb0cd99c3 --- /dev/null +++ b/desktop/src/main/__tests__/linux-desktop-integration.test.ts @@ -0,0 +1,635 @@ +// @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(() => { + vi.restoreAllMocks() + 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("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)( + "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") + 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 () => { + 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) + }) +}) + +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/__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 ae3939337b..fe32573de8 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -18,6 +18,11 @@ import { isAutomaticLoginLaunch, 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" @@ -102,6 +107,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, @@ -145,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) => @@ -226,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), @@ -295,6 +314,7 @@ app.whenReady().then(async () => { workspaceSnapshot: () => watcher?.workspaceSnapshot(), appNavigation, settingsService, + linuxDesktopIntegration, }) // Start state watcher @@ -374,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-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/desktop/src/main/linux-desktop-integration.ts b/desktop/src/main/linux-desktop-integration.ts new file mode 100644 index 0000000000..c623009153 --- /dev/null +++ b/desktop/src/main/linux-desktop-integration.ts @@ -0,0 +1,608 @@ +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 + content: Buffer + values: Map +} + +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")) { + 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, content, 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))) + } 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 && this.isOwnedLauncher(entry)) { + owned = entry + } else if ( + entry.path === userEntry && + entry.values.get(OWNER_KEY) === "true" + ) { + 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)) { + 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 previous = this.launcherForMutation(status.installed) + const iconPath = this.iconPath() + const iconExists = pathExists(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"), + } + } + try { + writeAtomically( + userEntry, + renderDesktopEntry(status.appImagePath, ownedIcon), + previous?.content, + ) + } catch (err) { + // 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)) + } + + 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 = 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 && ownedIcon && fileMatches(this.iconPath(), ownedIcon)) { + removeIfPresent(this.iconPath()) + } + await this.options.refreshDesktopDatabase(dirname(userEntry)) + } + + 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 ( + !iconPath || + !iconHash || + unescapeDesktopString(iconPath) !== this.iconPath() || + !/^[a-f0-9]{64}$/.test(iconHash) + ) + return false + 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 undefined + } + } + + 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 + } +} + +interface OwnedIcon { + path: string + hash: string +} + +function renderDesktopEntry( + appImagePath: string, + ownedIcon?: OwnedIcon, +): 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")}`, + ] + 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, + expected?: Buffer, +): void { + const temporary = `${path}.${randomUUID()}.tmp` + try { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(temporary, content, { flag: "wx", mode: 0o644 }) + 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 { + removeIfPresent(temporary) + } +} + +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) + 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..18f5ad0db2 --- /dev/null +++ b/desktop/src/renderer/src/lib/components/layout/AppImageIntegrationPrompt.test.ts @@ -0,0 +1,85 @@ +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 () => { + vi.useFakeTimers() + try { + ipc.supported = false + render(AppImageIntegrationPrompt) + await vi.runOnlyPendingTimersAsync() + expect(screen.queryByRole("dialog")).toBeNull() + expect(ipc.dismissed).toBe(false) + } finally { + vi.useRealTimers() + } + }) +}) 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/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..f2b9192d6d --- /dev/null +++ b/hack/release_artifacts/verify_linux_desktop.go @@ -0,0 +1,168 @@ +package main + +import ( + "bufio" + "fmt" + "image/png" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/spf13/cobra" +) + +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: "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", "", "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(). + 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) + } + return cmd +} + +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("desktop entry is named %s, expected %s", got, want) + } + entry, err := readDesktopEntry(desktopFile) + if err != nil { + return "", fmt.Errorf("%s: %w", desktopFile, err) + } + if got := entry["StartupWMClass"]; 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 !themeIconName.MatchString(icon) { + return "", fmt.Errorf("%s: invalid theme Icon %q", desktopFile, icon) + } + return icon, nil +} + +func verifyThemeIcons(iconRoot, icon string) error { + paths, err := filepath.Glob(filepath.Join(iconRoot, "*", "apps", icon+".png")) + if err != nil { + return 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 +} + +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 || strings.HasPrefix(line, "#") { + continue + } + if key, value, ok := strings.Cut(line, "="); ok { + entry[key] = value + } + } + return entry, scanner.Err() +} 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..32d431a97a --- /dev/null +++ b/hack/release_artifacts/verify_linux_desktop_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "bytes" + "image" + "image/png" + "os" + "path/filepath" + "testing" + + "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 +} + +func TestVerifyLinuxDesktopSuite(t *testing.T) { + suite.Run(t, new(VerifyLinuxDesktopSuite)) +} + +func (s *VerifyLinuxDesktopSuite) SetupTest() { + s.root = s.T().TempDir() +} + +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) 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) 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") +} + +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) 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") +} + +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) 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") +} + +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) 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") +} + +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 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, 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..58c9eb6479 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 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. + 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 c20f7f31be..364d2cd905 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,22 @@ With SELinux, a mounted volume can fail with "Permission denied" even when owner } ``` +### Generic application icon on GNOME + +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. + +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. + +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. + +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. + ### `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).