From a7804492ccfe6b84dfac5917ef92090f51e92b3d Mon Sep 17 00:00:00 2001 From: ali00209 Date: Tue, 22 Sep 2026 12:48:25 +0500 Subject: [PATCH 01/12] feat: add apt-manager extension --- extensions/apt-manager/.gitignore | 2 + extensions/apt-manager/README.md | 41 + .../apt-manager/assets/extension_icon.png | Bin 0 -> 59247 bytes extensions/apt-manager/package-lock.json | 775 ++++++++++++++++++ extensions/apt-manager/package.json | 34 + extensions/apt-manager/src/apt.tsx | 259 ++++++ .../src/components/PackageActions.tsx | 232 ++++++ .../src/components/PackageListItemDetail.tsx | 171 ++++ .../src/components/PackageView.tsx | 167 ++++ .../apt-manager/src/components/RunResult.tsx | 47 ++ extensions/apt-manager/src/lib/apt.ts | 390 +++++++++ extensions/apt-manager/src/lib/confirm.ts | 21 + extensions/apt-manager/src/lib/exec.ts | 64 ++ extensions/apt-manager/src/lib/flathub.ts | 182 ++++ .../apt-manager/src/lib/flatpakRemotes.ts | 151 ++++ extensions/apt-manager/src/lib/sources.ts | 375 +++++++++ .../apt-manager/src/lib/useAppDetail.ts | 49 ++ .../src/views/AddFlatpakRemote.tsx | 136 +++ extensions/apt-manager/src/views/AddRepo.tsx | 160 ++++ .../apt-manager/src/views/FlatpakRepos.tsx | 232 ++++++ extensions/apt-manager/src/views/Repos.tsx | 267 ++++++ extensions/apt-manager/tsconfig.json | 18 + 22 files changed, 3773 insertions(+) create mode 100644 extensions/apt-manager/.gitignore create mode 100644 extensions/apt-manager/README.md create mode 100644 extensions/apt-manager/assets/extension_icon.png create mode 100644 extensions/apt-manager/package-lock.json create mode 100644 extensions/apt-manager/package.json create mode 100644 extensions/apt-manager/src/apt.tsx create mode 100644 extensions/apt-manager/src/components/PackageActions.tsx create mode 100644 extensions/apt-manager/src/components/PackageListItemDetail.tsx create mode 100644 extensions/apt-manager/src/components/PackageView.tsx create mode 100644 extensions/apt-manager/src/components/RunResult.tsx create mode 100644 extensions/apt-manager/src/lib/apt.ts create mode 100644 extensions/apt-manager/src/lib/confirm.ts create mode 100644 extensions/apt-manager/src/lib/exec.ts create mode 100644 extensions/apt-manager/src/lib/flathub.ts create mode 100644 extensions/apt-manager/src/lib/flatpakRemotes.ts create mode 100644 extensions/apt-manager/src/lib/sources.ts create mode 100644 extensions/apt-manager/src/lib/useAppDetail.ts create mode 100644 extensions/apt-manager/src/views/AddFlatpakRemote.tsx create mode 100644 extensions/apt-manager/src/views/AddRepo.tsx create mode 100644 extensions/apt-manager/src/views/FlatpakRepos.tsx create mode 100644 extensions/apt-manager/src/views/Repos.tsx create mode 100644 extensions/apt-manager/tsconfig.json diff --git a/extensions/apt-manager/.gitignore b/extensions/apt-manager/.gitignore new file mode 100644 index 00000000..eb3ae205 --- /dev/null +++ b/extensions/apt-manager/.gitignore @@ -0,0 +1,2 @@ +node_modules +vicinae-env.d.ts diff --git a/extensions/apt-manager/README.md b/extensions/apt-manager/README.md new file mode 100644 index 00000000..9d172dd5 --- /dev/null +++ b/extensions/apt-manager/README.md @@ -0,0 +1,41 @@ +# apt-manager + +Manage apt packages and repositories from [Vicinae](https://docs.vicinae.com). + +## Features + +Launched from the `apt` command: + +- **Installed packages** — list what's installed, remove/reinstall, show info. +- **All packages** — browse the full apt cache (lazy-loaded), install on the fly. +- **Upgradable packages** — see updates, upgrade individual packages. +- **Update all** — `apt update` then `apt-get upgrade --with-new-pkgs`, with confirmation. +- **Clean up system** — `apt-get autoremove --purge` then `apt-get autoclean`. +- **Repositories** — list sources from `sources.list`/`sources.list.d` (legacy one-line + and deb822 `.sources`), add new repos as deb822 `.sources` files, enable/disable/remove. + +## How it works + +- Read-only operations (`apt list`, `apt-cache show`) run without privileges. +- Every privileged operation (`install`, `remove`, `upgrade`, `cleanup`, repo writes) + is run via `pkexec`, which shows the desktop's polkit authentication dialog. + If `pkexec` is missing you'll get a readable error plus a "Retry in Terminal (sudo)" + action instead. +- Command output for long-running operations is shown in a result view after completion. + +## Development + +```bash +npm install +npm run dev # run in development mode (requires vicinae running) +npm run build # production bundle +npm run lint +``` + +## Requirements + +- Debian / Ubuntu (or a resolvable) system with `apt`. +- `pkexec` (package `policykit-1`) for privileged operations — either the + polkit agent of your desktop session or the fallback terminal action. +- Optional: `flatpak` to manage Flatpak applications and repositories. The + Flatpak sections are hidden when the CLI is not installed. diff --git a/extensions/apt-manager/assets/extension_icon.png b/extensions/apt-manager/assets/extension_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cf29a7bae5b88ef03926f31875d7acd1a02c1dd5 GIT binary patch literal 59247 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ξB52GFP`YA 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 z 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%0A9b%^Yd*#;u30ZNSb+Gte_qgk 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 literal 0 HcmV?d00001 diff --git a/extensions/apt-manager/package-lock.json b/extensions/apt-manager/package-lock.json new file mode 100644 index 00000000..e0f485df --- /dev/null +++ b/extensions/apt-manager/package-lock.json @@ -0,0 +1,775 @@ +{ + "name": "debian-packages", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "debian-packages", + "license": "MIT", + "dependencies": { + "@vicinae/api": "^0.29.0" + }, + "devDependencies": { + "@biomejs/biome": "2.3.2", + "typescript": "^5.9.2" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.2.tgz", + "integrity": "sha512-8e9tzamuDycx7fdrcJ/F/GDZ8SYukc5ud6tDicjjFqURKYFSWMl0H0iXNXZEGmcmNUmABgGuHThPykcM41INgg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.3.2", + "@biomejs/cli-darwin-x64": "2.3.2", + "@biomejs/cli-linux-arm64": "2.3.2", + "@biomejs/cli-linux-arm64-musl": "2.3.2", + "@biomejs/cli-linux-x64": "2.3.2", + "@biomejs/cli-linux-x64-musl": "2.3.2", + "@biomejs/cli-win32-arm64": "2.3.2", + "@biomejs/cli-win32-x64": "2.3.2" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.2.tgz", + "integrity": "sha512-4LECm4kc3If0JISai4c3KWQzukoUdpxy4fRzlrPcrdMSRFksR9ZoXK7JBcPuLBmd2SoT4/d7CQS33VnZpgBjew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.2.tgz", + "integrity": "sha512-jNMnfwHT4N3wi+ypRfMTjLGnDmKYGzxVr1EYAPBcauRcDnICFXN81wD6wxJcSUrLynoyyYCdfW6vJHS/IAoTDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.2.tgz", + "integrity": "sha512-amnqvk+gWybbQleRRq8TMe0rIv7GHss8mFJEaGuEZYWg1Tw14YKOkeo8h6pf1c+d3qR+JU4iT9KXnBKGON4klw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.2.tgz", + "integrity": "sha512-2Zz4usDG1GTTPQnliIeNx6eVGGP2ry5vE/v39nT73a3cKN6t5H5XxjcEoZZh62uVZvED7hXXikclvI64vZkYqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.2.tgz", + "integrity": "sha512-8BG/vRAhFz1pmuyd24FQPhNeueLqPtwvZk6yblABY2gzL2H8fLQAF/Z2OPIc+BPIVPld+8cSiKY/KFh6k81xfA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.2.tgz", + "integrity": "sha512-gzB19MpRdTuOuLtPpFBGrV3Lq424gHyq2lFj8wfX9tvLMLdmA/R9C7k/mqBp/spcbWuHeIEKgEs3RviOPcWGBA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.2.tgz", + "integrity": "sha512-lCruqQlfWjhMlOdyf5pDHOxoNm4WoyY2vZ4YN33/nuZBRstVDuqPPjS0yBkbUlLEte11FbpW+wWSlfnZfSIZvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.2.tgz", + "integrity": "sha512-6Ee9P26DTb4D8sN9nXxgbi9Dw5vSOfH98M7UlmkjKB2vtUbrRqCbZiNfryGiwnPIpd6YUoTl7rLVD2/x1CyEHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", + "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", + "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@vicinae/api": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@vicinae/api/-/api-0.29.0.tgz", + "integrity": "sha512-NDXl/oUZkBcO9/YSdR4Q3P9iePmtJDecwYMBiQnL94VsHcOP2+5HayShEjVzcZ3va7+lB5G2bxLeDsMrbwdeiQ==", + "license": "ISC", + "dependencies": { + "@types/react": "19.1.17", + "chokidar": "^4.0.3", + "esbuild": "^0.25.2", + "react": "19.1.1", + "typescript": "5.9.2", + "zod": "^4.0.17" + }, + "bin": { + "vici": "dist/bin.js" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@vicinae/api/node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT", + "peer": true + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/extensions/apt-manager/package.json b/extensions/apt-manager/package.json new file mode 100644 index 00000000..4481e8ff --- /dev/null +++ b/extensions/apt-manager/package.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://raw.githubusercontent.com/vicinaehq/vicinae/refs/heads/main/extra/schemas/extension.json", + "name": "debian-packages", + "title": "Debian Packages", + "description": "Debian Packages Management Tool", + "categories": ["System"], + "license": "MIT", + "author": "ali00209", + "contributors": [], + "pastContributors": [], + "icon": "extension_icon.png", + "commands": [ + { + "name": "apt", + "title": "APT", +"description": "APT package manager with Flatpak support: install, remove, and upgrade Debian packages; manage Flatpak apps and remotes.", + "mode": "view" + } + ], + "preferences": [], + "scripts": { + "build": "vici build", + "dev": "vici develop", + "format": "biome format --write src", + "lint": "vici lint" + }, + "dependencies": { + "@vicinae/api": "^0.29.0" + }, + "devDependencies": { + "typescript": "^5.9.2", + "@biomejs/biome": "2.3.2" + } +} diff --git a/extensions/apt-manager/src/apt.tsx b/extensions/apt-manager/src/apt.tsx new file mode 100644 index 00000000..8fd136e1 --- /dev/null +++ b/extensions/apt-manager/src/apt.tsx @@ -0,0 +1,259 @@ +import { + Action, + ActionPanel, + Icon, + type Keyboard, + List, + showToast, + Toast, + useNavigation, +} from "@vicinae/api"; +import type { ReactNode } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { PackageView } from "./components/PackageView"; +import { RunResult } from "./components/RunResult"; +import { + type OperationResult, + runAptCleanup, + runAptUpgradeAll, +} from "./lib/apt"; +import { askConfirm } from "./lib/confirm"; +import { fetchFlatpakRemotes, hasFlatpakBinary } from "./lib/flatpakRemotes"; +import { loadRepos } from "./lib/sources"; +import { ReposView } from "./views/Repos"; +import { FlatpakReposView } from "./views/FlatpakRepos"; + +export default function Command() { + const repoCount = useMemo(() => loadRepos().sources.length, []); + const flatpakAvailable = useMemo(() => hasFlatpakBinary(), []); + const [flatpakRepoCount, setFlatpakRepoCount] = useState(null); + const runAndShow = usePrivilegedRunner(); + + useEffect(() => { + if (!flatpakAvailable) return; + let cancelled = false; + fetchFlatpakRemotes().then((result) => { + if (!cancelled) setFlatpakRepoCount(result.remotes.length); + }); + return () => { + cancelled = true; + }; + }, [flatpakAvailable]); + + return ( + + + + } + /> + } + /> + + } + /> + } + /> + + } + /> + } + /> + + + + runAndShow( + runAptUpgradeAll, + "Update all packages", + ["upgrade", "-y", "--with-new-pkgs"], + "Run apt update then upgrade all packages?", + ) + } + extraActions={ + + } + /> + + runAndShow( + runAptCleanup, + "Clean up system", + ["autoremove", "-y", "--purge"], + "Run autoremove --purge and autoclean?", + ) + } + /> + + + } /> + } + /> + {flatpakAvailable && ( + } + /> + } + /> + )} + + + ); +} + +type RootItemProps = { + title: string; + subtitle?: string; + icon: Icon; + shortcut: Keyboard.Shortcut; + actions?: ReactNode; + extraActions?: ReactNode; + destructive?: boolean; + onAction?: () => void; +}; + +function RootItem({ + title, + subtitle, + icon, + shortcut, + actions, + extraActions, + destructive, + onAction, +}: RootItemProps) { + return ( + + {actions ?? + (onAction ? ( + + ) : null)} + {extraActions} + + } + /> + ); +} + +function usePrivilegedRunner() { + const { push } = useNavigation(); + return async ( + run: () => Promise, + heading: string, + sudoArgs: string[], + confirmMessage: string | null, + ) => { + if (confirmMessage) { + const confirmed = await askConfirm(heading, confirmMessage); + if (!confirmed) return; + } + const toast = await showToast({ + style: Toast.Style.Animated, + title: heading, + }); + const result = await run(); + if (result.ok) { + toast.style = Toast.Style.Success; + toast.title = `${heading} completed`; + } else { + toast.style = Toast.Style.Failure; + toast.title = `${heading} failed`; + toast.message = + result.stderr.trim().slice(0, 140) || + `exit code ${result.code ?? "unknown"}`; + } + push( + , + ); + }; +} diff --git a/extensions/apt-manager/src/components/PackageActions.tsx b/extensions/apt-manager/src/components/PackageActions.tsx new file mode 100644 index 00000000..b5e6667e --- /dev/null +++ b/extensions/apt-manager/src/components/PackageActions.tsx @@ -0,0 +1,232 @@ +import { + Action, + ActionPanel, + Icon, + Keyboard, + Toast, + showToast, + useNavigation, +} from "@vicinae/api"; +import type { AptPackage, PackageListKind } from "../lib/apt"; +import { runPrivilegedApGet, runPrivilegedCommand } from "../lib/apt"; +import { askConfirm } from "../lib/confirm"; +import { RunResult } from "./RunResult"; + +type ActionsProps = { + kind: PackageListKind; + pkg: AptPackage; + onRefresh: () => void; + onToggleDetail: () => void; +}; + +export function PackageActions({ + kind, + pkg, + onRefresh, + onToggleDetail, +}: ActionsProps) { + const { push } = useNavigation(); + + const runPrivileged = async ( + command: string, + args: string[], + label: string, + confirmMessage: string | null, + ) => { + if (confirmMessage) { + const confirmed = await askConfirm(label, confirmMessage); + if (!confirmed) return; + } + const toast = await showToast({ + style: Toast.Style.Animated, + title: label, + }); + const run = + command === "apt-get" + ? () => runPrivilegedApGet(args, label) + : () => runPrivilegedCommand(command, args, label); + const result = await run(); + if (result.ok) { + toast.style = Toast.Style.Success; + toast.title = label; + toast.message = "Done"; + } else { + toast.style = Toast.Style.Failure; + toast.title = `${label} failed`; + toast.message = + result.stderr.trim().slice(0, 140) || + `exit code ${result.code ?? "unknown"}`; + } + push( + , + ); + onRefresh(); + }; + + const toggleDetail = ( + + ); + + const copyName = ( + + ); + + if (pkg.manager === "flatpak") { + return ( + + {toggleDetail} + {pkg.flags.installed ? ( + + runPrivileged( + "flatpak", + ["uninstall", "-y", pkg.name], + `Remove ${pkg.name}`, + `Remove the Flatpak application ${pkg.name}?`, + ) + } + /> + ) : ( + + runPrivileged( + "flatpak", + ["install", "-y", "flathub", pkg.name], + `Install ${pkg.name}`, + null, + ) + } + /> + )} + {copyName} + + ); + } + + if (kind === "installed") { + return ( + + {toggleDetail} + + runPrivileged( + "apt-get", + ["remove", "-y", pkg.name], + `Remove ${pkg.name}`, + `Remove ${pkg.name}? Its configuration files are kept.`, + ) + } + /> + + runPrivileged( + "apt-get", + ["install", "--reinstall", "-y", pkg.name], + `Reinstall ${pkg.name}`, + null, + ) + } + /> + {copyName} + + ); + } + + if (kind === "upgradable") { + return ( + + {toggleDetail} + + runPrivileged( + "apt-get", + ["install", "--only-upgrade", "-y", pkg.name], + `Upgrade ${pkg.name}`, + `Upgrade ${pkg.name} to ${pkg.version}?`, + ) + } + /> + {copyName} + + ); + } + + return ( + + {toggleDetail} + + runPrivileged( + "apt-get", + ["install", "-y", pkg.name], + `Install ${pkg.name}`, + null, + ) + } + /> + {pkg.flags.installed ? ( + + runPrivileged( + "apt-get", + ["remove", "-y", pkg.name], + `Remove ${pkg.name}`, + `Remove ${pkg.name}? Its configuration files are kept.`, + ) + } + /> + ) : null} + {pkg.flags.upgradable ? ( + + runPrivileged( + "apt-get", + ["install", "--only-upgrade", "-y", pkg.name], + `Upgrade ${pkg.name}`, + `Upgrade ${pkg.name} to ${pkg.version}?`, + ) + } + /> + ) : null} + {copyName} + + ); +} diff --git a/extensions/apt-manager/src/components/PackageListItemDetail.tsx b/extensions/apt-manager/src/components/PackageListItemDetail.tsx new file mode 100644 index 00000000..4f849a67 --- /dev/null +++ b/extensions/apt-manager/src/components/PackageListItemDetail.tsx @@ -0,0 +1,171 @@ +import { Color, List } from "@vicinae/api"; +import { useEffect, useState } from "react"; +import { + type AptPackage, + type PackageInfo, + fetchPackageInfo, +} from "../lib/apt"; +import { + type FlathubApp, + buildDetailMarkdown, + formatInstalls, +} from "../lib/flathub"; +import { useAppDetail } from "../lib/useAppDetail"; + +export function AptAppDetail({ + pkg, + enabled, +}: { + pkg: AptPackage; + enabled: boolean; +}) { + const [info, setInfo] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + setInfo(null); + setLoading(true); + fetchPackageInfo(pkg.name) + .then((value) => { + if (cancelled) return; + setInfo(value); + setLoading(false); + }) + .catch(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [pkg.name, enabled]); + + const first = info?.paragraphs[0] ?? {}; + const field = (key: string): string | undefined => first[key]?.[0]; + + + const statusParts: string[] = []; + if (pkg.flags.installed) statusParts.push("installed"); + if (pkg.flags.upgradable) statusParts.push(`upgradable to ${pkg.version}`); + + let size: string | null = null; + const sizeKb = Number.parseInt(field("Installed-Size") ?? "0", 10); + if (sizeKb > 0) size = `${(sizeKb / 1024).toFixed(1)} MB`; + + return ( + + + {statusParts.length > 0 && ( + + )} + + {field("Priority") && ( + + )} + {field("Section") && ( + + )} + {size && } + {field("Maintainer") && ( + + )} + {field("Depends") && ( + + {field("Depends")?.split(",").map((lib, index) => ( + + ))} + + )} + {field("Homepage") && ( + + )} + + } + + /> + ); +} + +export function FlathubAppDetail({ + app, + enabled, +}: { + app: FlathubApp; + enabled: boolean; +}) { + const { data: fullApp, isLoading } = useAppDetail(app.app_id, enabled); + + const displayApp = fullApp || app; + const screenshots = displayApp.screenshots || []; + const latestRelease = displayApp.releases?.[0]; + + const markdown = isLoading + ? `# ${app.app_id}\n\n_Loading…_` + : buildDetailMarkdown(screenshots, app, displayApp); + + return ( + + {displayApp.summary && ( + + )} + {displayApp.developer_name && ( + + )} + {displayApp.installs_last_month && ( + + )} + {latestRelease && ( + + )} + + } + /> + ); +} diff --git a/extensions/apt-manager/src/components/PackageView.tsx b/extensions/apt-manager/src/components/PackageView.tsx new file mode 100644 index 00000000..fc4b484a --- /dev/null +++ b/extensions/apt-manager/src/components/PackageView.tsx @@ -0,0 +1,167 @@ +import { Color, Icon, List } from "@vicinae/api"; +import { useEffect, useMemo, useState } from "react"; +import type { AptPackage, PackageListKind } from "../lib/apt"; +import { fetchPackageList, flathubAppFromListed } from "../lib/apt"; +import { PackageActions } from "./PackageActions"; +import { AptAppDetail, FlathubAppDetail } from "./PackageListItemDetail"; + +const PAGE_START = 500; +const PAGE_STEP = 1000; + +type Props = { + kind: PackageListKind; + title: string; + emptyTitle: string; +}; + +export function PackageView({ kind, title, emptyTitle }: Props) { + const [packages, setPackages] = useState(null); + const [error, setError] = useState(null); + const [reloadKey, setReloadKey] = useState(0); + const [query, setQuery] = useState(""); + const [visible, setVisible] = useState(PAGE_START); + const [showingDetail, setShowingDetail] = useState(false); + const [selectedId, setSelectedId] = useState(null); + + useEffect(() => { + let cancelled = false; + setPackages(null); + setError(null); + fetchPackageList(kind) + .then((list) => { + if (!cancelled) setPackages(list); + }) + .catch((reason: unknown) => { + if (!cancelled) { + setError(reason instanceof Error ? reason.message : String(reason)); + } + }); + return () => { + cancelled = true; + }; + }, [kind, reloadKey]); + + useEffect(() => { + setVisible(PAGE_START); + }, [query]); + + const filtered = useMemo(() => { + if (!packages) return []; + const q = query.trim().toLowerCase(); + if (q === "") return packages; + return packages.filter( + (pkg) => + pkg.name.toLowerCase().includes(q) || + pkg.arch.includes(q) || + pkg.suite.includes(q), + ); + }, [packages, query]); + + const isLoading = packages === null; + const hasMore = filtered.length > visible; + + const refresh = () => setReloadKey((value) => value + 1); + + return ( + setVisible((value) => value + PAGE_STEP), + }} + > + {error ? ( + + ) : !isLoading && filtered.length === 0 ? ( + + ) : ( + filtered.slice(0, visible).map((pkg) => { + const id = `${pkg.name}/${pkg.arch}`; + return ( + + ) : ( + + ) + } + actions={ + setShowingDetail((value) => !value)} + /> + } + /> + ); + }) + )} + + ); +} + +function accessoriesFor( + kind: PackageListKind, + pkg: AptPackage, +): List.Item.Accessory[] { + if (pkg.manager === "flatpak") { + return [ + { + tag: { + color: pkg.flags.installed ? Color.Green : Color.Blue, + value: "flathub", + }, + }, + ]; + } + if (kind === "upgradable") { + return [ + { + tag: { + color: Color.Orange, + value: `${pkg.current ?? "?"} → ${pkg.version}`, + }, + }, + ]; + } + if (kind === "installed") { + return [{ tag: { color: Color.Green, value: "installed" } }]; + } + if (pkg.flags.installed) + return [{ tag: { color: Color.Green, value: "installed" } }]; + if (pkg.flags.upgradable) + return [{ tag: { color: Color.Orange, value: "upgradable" } }]; + return []; +} diff --git a/extensions/apt-manager/src/components/RunResult.tsx b/extensions/apt-manager/src/components/RunResult.tsx new file mode 100644 index 00000000..587f1f2f --- /dev/null +++ b/extensions/apt-manager/src/components/RunResult.tsx @@ -0,0 +1,47 @@ +import { Action, ActionPanel, Detail, Icon, Keyboard } from "@vicinae/api"; +import type { OperationResult } from "../lib/apt"; +import { resultMarkdown } from "../lib/apt"; + +type Props = { + heading: string; + title: string; + result: OperationResult; + /** Optional arguments to re-run the operation with `sudo` in a terminal. */ + sudoArgs?: string[]; + /** Command used for the `sudo` retry action. Defaults to `apt-get`. */ + sudoCommand?: string; +}; + +export function RunResult({ + heading, + title, + result, + sudoArgs, + sudoCommand, +}: Props) { + const markdown = resultMarkdown(result, heading); + const retryCommand = sudoCommand ?? "apt-get"; + + const actions = ( + + + {sudoArgs && sudoArgs.length > 0 ? ( + + ) : null} + + ); + + return ( + + ); +} diff --git a/extensions/apt-manager/src/lib/apt.ts b/extensions/apt-manager/src/lib/apt.ts new file mode 100644 index 00000000..be07da47 --- /dev/null +++ b/extensions/apt-manager/src/lib/apt.ts @@ -0,0 +1,390 @@ +import { existsSync } from "node:fs"; +import { run } from "./exec"; +import { + type FlathubApp, + type FlathubListedApp, + fetchFlathubInstalled, + fetchFlathubRemote, + findFlatpakIcon, +} from "./flathub"; + +export type PackageFlags = { + installed: boolean; + automatic: boolean; + upgradable: boolean; + local: boolean; +}; + +export type AptPackage = { + name: string; + suite: string; + version: string; + arch: string; + flags: PackageFlags; + /** For upgradable packages: the currently installed version. */ + current: string | null; + description: string; + /** Package manager that owns this entry. */ + manager: "apt" | "flatpak"; + /** Local path to an app icon, if one is available. */ + icon: string | null; +}; + +/** + * Build a minimal `FlathubApp` from a listed package, for UIs that only know + * how to render Flathub apps. + */ +export function flathubAppFromListed(pkg: AptPackage): FlathubApp { + return { app_id: pkg.name, name: pkg.name, summary: "" }; +} + +export function parseAptListLine(raw: string): AptPackage | null { + const line = raw.trim(); + if ( + line === "" || + line.startsWith("Listing") || + line.startsWith("E:") || + line.startsWith("W:") + ) { + return null; + } + const slash = line.indexOf("/"); + if (slash === -1) return null; + + const name = line.slice(0, slash); + let rest = line.slice(slash + 1); + + const flagsMatch = /\[([^\]]*)]\s*$/.exec(rest); + const flagsRaw = flagsMatch ? flagsMatch[1] : ""; + if (flagsMatch) rest = rest.slice(0, flagsMatch.index).trim(); + + const tokens = rest.split(/\s+/).filter(Boolean); + if (tokens.length < 2) return null; + + const suite = tokens[0]; + const arch = tokens.length >= 3 ? tokens[tokens.length - 1] : ""; + const version = arch ? tokens.slice(1, -1).join(" ") : tokens[1]; + + const flags = parseFlags(flagsRaw); + const current = flags.upgradable + ? (currentFromFlags(flagsRaw) ?? version) + : flags.installed + ? version + : null; + + return { + name, + suite, + version, + arch, + flags, + current, + description: "", + manager: "apt", + icon: null, + }; +} + +function parseFlags(raw: string): PackageFlags { + const flags: PackageFlags = { + installed: false, + automatic: false, + upgradable: false, + local: false, + }; + for (const part of raw.split(",")) { + const token = part.trim().toLowerCase(); + if (token.includes("installed")) flags.installed = true; + if (token.includes("automatic")) flags.automatic = true; + if (token.includes("upgradable")) flags.upgradable = true; + if (token.includes("local")) flags.local = true; + } + return flags; +} + +function currentFromFlags(raw: string): string | null { + const match = /upgradable from:\s*([^,\]]*)$/i.exec(raw.trim()); + if (!match) return null; + const value = match[1].trim(); + return value === "" ? null : value; +} + +export function parseAptList(output: string): AptPackage[] { + const packages: AptPackage[] = []; + for (const line of output.split("\n")) { + const pkg = parseAptListLine(line); + if (pkg) packages.push(pkg); + } + return packages; +} + +export type PackageListKind = "installed" | "all" | "upgradable"; + +const LIST_ARGS: Record = { + installed: ["--installed"], + all: [], + upgradable: ["--upgradable"], +}; + +/** + * Fetch a list of packages via `apt list`. Runs without elevated privileges + * since it only reads the local package cache. + * For the `installed` and `all` kinds, Flatpak applications are merged in. + */ +export async function fetchPackageList( + kind: PackageListKind, +): Promise { + const args: string[] = ["list"]; + if (LIST_ARGS[kind].length > 0) args.push(...LIST_ARGS[kind]); + + const result = await run("apt", args, { timeout: 60_000 }); + if (!result.ok) { + throw new Error( + result.stderr.trim() || + result.stdout.trim() || + `apt ${args.join(" ")} failed`, + ); + } + const all = parseAptList(result.stdout); + if (kind === "installed") { + const aptInstalled = all.filter((pkg) => pkg.flags.installed); + const flatpakInstalled = (await fetchFlathubInstalled()).map((pkg) => + flathubToAptPackage(pkg, true), + ); + return [...aptInstalled, ...flatpakInstalled].sort((a, b) => + a.name.localeCompare(b.name), + ); + } + if (kind === "upgradable") { + const filtered = all.filter((pkg) => pkg.flags.upgradable); + return filtered.sort((a, b) => a.name.localeCompare(b.name)); + } + const flatpakAvailable = (await fetchFlathubRemote()).map((pkg) => + flathubToAptPackage(pkg, false), + ); + return [...all, ...flatpakAvailable].sort((a, b) => + a.name.localeCompare(b.name), + ); +} + +function flathubToAptPackage( + pkg: FlathubListedApp, + installed: boolean, +): AptPackage { + return { + name: pkg.name, + suite: pkg.origin || "flathub", + version: pkg.version || pkg.branch || "", + arch: pkg.arch, + flags: { + installed, + automatic: false, + upgradable: false, + local: false, + }, + current: null, + description: "", + manager: "flatpak", + icon: findFlatpakIcon(pkg.name), + }; +} + +export type PackageInfo = { + name: string; + paragraphs: Array>; +}; + +/** + * Fetch the full description and metadata for a package via `apt-cache show`. + */ +export async function fetchPackageInfo( + name: string, +): Promise { + const result = await run("apt-cache", ["show", name], { timeout: 30_000 }); + if (!result.ok) return null; + + const paragraphs: Array> = []; + let current: Record | null = null; + for (const line of result.stdout.split("\n")) { + if (line === "") { + if (current) paragraphs.push(current); + current = null; + continue; + } + if (!/^\s/.test(line)) { + const sep = line.indexOf(":"); + if (sep === -1) continue; + const key = line.slice(0, sep); + const value = line.slice(sep + 1).trim(); + if (!current) current = {}; + current[key] = [value]; + } else if (current) { + const lastKey = Object.keys(current)[Object.keys(current).length - 1]; + if (lastKey) { + current[lastKey].push(line.trim()); + } + } + } + if (current) paragraphs.push(current); + + return { name, paragraphs }; +} + +export type AptOperation = { + args: string[]; + title: string; + successMessage: string; +}; + +export type OperationResult = { + ok: boolean; + code: number | null; + stdout: string; + stderr: string; + command: string; +}; + +const APT_ENV = { DEBIAN_FRONTEND: "noninteractive" }; +const PRIVILEGED_TIMEOUT = 20 * 60 * 1000; + +function pkexecAvailable(): boolean { + return ["/usr/bin/pkexec", "/bin/pkexec"].some((path) => existsSync(path)); +} + +/** + * Run an elevated operation via `pkexec`, capturing the output. + * The polkit authentication dialog is shown by the desktop environment. + */ +export async function runPrivilegedCommand( + command: string, + args: string[], + _description?: string, +): Promise { + const full = `pkexec ${command} ${args.join(" ")}`; + if (!pkexecAvailable()) { + return { + ok: false, + code: null, + stdout: "", + stderr: + "`pkexec` was not found in PATH. Install polkit (e.g. `apt install policykit-1`) or use the 'Retry in Terminal' action with sudo.", + command: full, + }; + } + try { + const result = await run("pkexec", [command, ...args], { + env: APT_ENV, + timeout: PRIVILEGED_TIMEOUT, + }); + return { + ok: result.ok, + code: result.code, + stdout: result.stdout, + stderr: result.stderr, + command: full, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + ok: false, + code: null, + stdout: "", + stderr: message, + command: full, + }; + } +} + +/** + * Run an elevated apt-get operation via `pkexec`, capturing the output. + */ +export async function runPrivilegedApGet( + args: string[], + description?: string, +): Promise { + return runPrivilegedCommand("apt-get", args, description); +} + +/** + * Run `apt-get update` elevated. Returns the result. + */ +export async function runAptUpdate(): Promise { + return runPrivilegedApGet(["update"], "Refresh package lists"); +} + +/** + * Run `apt update` then `apt-get upgrade --with-new-pkgs` elevated. + */ +export async function runAptUpgradeAll(): Promise { + const update = await runPrivilegedApGet(["update"], "Refresh package lists"); + if (!update.ok) return update; + return runPrivilegedApGet( + ["upgrade", "-y", "--with-new-pkgs"], + "Upgrade all packages", + ); +} + +/** + * Install one or more packages. + */ +export function runAptInstall(packages: string[]): Promise { + return runPrivilegedApGet( + ["install", "-y", ...packages], + `Install ${packages.join(", ")}`, + ); +} + +/** + * Remove one or more packages (keeping their configuration files). + */ +export function runAptRemove(packages: string[]): Promise { + return runPrivilegedApGet( + ["remove", "-y", ...packages], + `Remove ${packages.join(", ")}`, + ); +} + +/** + * Fully remove packages including configuration files. + */ +export function runAptPurge(packages: string[]): Promise { + return runPrivilegedApGet( + ["purge", "-y", ...packages], + `Purge ${packages.join(", ")}`, + ); +} + +/** + * Clean up unused dependency packages and stale cache entries. + */ +export async function runAptCleanup(): Promise { + const autoremove = await runPrivilegedApGet( + ["autoremove", "-y", "--purge"], + "Remove unused packages", + ); + if (!autoremove.ok) return autoremove; + return runPrivilegedApGet(["autoclean"], "Clean package cache"); +} + +export function resultMarkdown( + result: OperationResult, + heading: string, +): string { + const lines: string[] = []; + lines.push(`# ${result.ok ? "Succeeded" : "Failed"}: ${heading}`); + lines.push(""); + lines.push(`**Command:** \`${result.command}\``); + if (result.code !== null) lines.push(`**Exit code:** ${result.code}`); + lines.push(""); + const body = [result.stdout.trim(), result.stderr.trim()] + .filter(Boolean) + .join("\n"); + if (body) { + lines.push("```text"); + lines.push(body.slice(0, 30_000)); + lines.push("```"); + } else { + lines.push("No output."); + } + return lines.join("\n"); +} diff --git a/extensions/apt-manager/src/lib/confirm.ts b/extensions/apt-manager/src/lib/confirm.ts new file mode 100644 index 00000000..41d52fe7 --- /dev/null +++ b/extensions/apt-manager/src/lib/confirm.ts @@ -0,0 +1,21 @@ +import { Alert, confirmAlert } from "@vicinae/api"; + +/** + * Ask the user to confirm a potentially destructive operation. + * Returns `true` only when the primary action was chosen. + */ +export async function askConfirm( + title: string, + message: string, + primaryLabel = "Yes", +): Promise { + return confirmAlert({ + title, + message, + primaryAction: { + title: primaryLabel, + style: Alert.ActionStyle.Destructive, + }, + dismissAction: { title: "Cancel", style: Alert.ActionStyle.Cancel }, + }); +} diff --git a/extensions/apt-manager/src/lib/exec.ts b/extensions/apt-manager/src/lib/exec.ts new file mode 100644 index 00000000..77138aba --- /dev/null +++ b/extensions/apt-manager/src/lib/exec.ts @@ -0,0 +1,64 @@ +import { execFile } from "node:child_process"; + +export type ExecResult = { + ok: boolean; + code: number | null; + stdout: string; + stderr: string; +}; + +export type ExecOptions = { + /** + * Data written to the child's stdin. + */ + input?: string; + /** + * Kill the process and fail if it runs longer than this (ms). + */ + timeout?: number; + /** + * Replace (or extend) the environment passed to the child. + */ + env?: Record; + maxBuffer?: number; +}; + +const DEFAULT_MAX_BUFFER = 64 * 1024 * 1024; + +/** + * Run a command with `execFile` (no shell, no injection risk) and resolve with + * its exit status and captured output. Never rejects for non-zero exit codes. + */ +export function run( + command: string, + args: string[] = [], + options: ExecOptions = {}, +): Promise { + return new Promise((resolve) => { + const env: NodeJS.ProcessEnv = { LC_ALL: "C", ...process.env }; + if (options.env) { + Object.assign(env, options.env); + } + execFile( + command, + args, + { + env, + timeout: options.timeout ?? 120_000, + maxBuffer: options.maxBuffer ?? DEFAULT_MAX_BUFFER, + encoding: "utf8", + }, + (error, stdout, stderr) => { + if (!error) { + resolve({ ok: true, code: 0, stdout, stderr }); + return; + } + const code = typeof error.code === "number" ? error.code : null; + if (stderr === "" && typeof error.message === "string") { + stderr = error.message; + } + resolve({ ok: code === 0, code, stdout, stderr }); + }, + )?.stdin?.end(options.input ?? ""); + }); +} diff --git a/extensions/apt-manager/src/lib/flathub.ts b/extensions/apt-manager/src/lib/flathub.ts new file mode 100644 index 00000000..c2d25a57 --- /dev/null +++ b/extensions/apt-manager/src/lib/flathub.ts @@ -0,0 +1,182 @@ +import { existsSync, readdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { run } from "./exec"; + +export type FlathubApp = { + app_id: string; + name: string; + summary: string; + icon?: string; + project_license?: string; + installs_last_month?: number; + trending?: number; + favorites_count?: number; + description?: string; + developer_name?: string; + screenshots?: Array<{ + caption?: string; + default?: boolean; + sizes: Array<{ + src: string; + width: string; + height: string; + scale?: string; + }>; + }>; + releases?: Array<{ + version: string; + timestamp: number; + description?: string; + }>; +}; + +const FLATHUB_APP_DETAIL_URL = "https://flathub.org/api/v2/appstream"; + +export async function fetchAppDetails(appId: string): Promise { + const response = await fetch( + `${FLATHUB_APP_DETAIL_URL}/${encodeURIComponent(appId)}`, + { + signal: AbortSignal.timeout(10_000), + }, + ); + + if (!response.ok) { + throw new Error(`Failed to fetch app details: ${response.status}`); + } + + return (await response.json()) as FlathubApp; +} + +/** A Flathub application as listed by the local `flatpak` CLI. */ +export type FlathubListedApp = { + name: string; + arch: string; + version: string; + branch: string; + origin: string; +}; + +const LIST_COLUMNS = "application,arch,version,branch,origin"; + +let appstreamIconDirs: string[] | null = null; + +function getAppstreamIconDirs(): string[] { + if (appstreamIconDirs) return appstreamIconDirs; + const dirs: string[] = []; + for (const root of [ + "/var/lib/flatpak/appstream/flathub", + join(homedir(), ".local/share/flatpak/appstream/flathub"), + ]) { + try { + for (const arch of readdirSync(root)) { + for (const commit of readdirSync(join(root, arch))) { + for (const size of ["128x128", "64x64"]) { + dirs.push(join(root, arch, commit, "icons", size)); + } + } + } + } catch { + // directory does not exist on this system + } + } + appstreamIconDirs = dirs; + return dirs; +} + +/** + * Find a local icon file for a Flathub application, if one is available in the + * appstream metadata cache or the exported hicolor theme. + */ +export function findFlatpakIcon(appId: string): string | null { + for (const dir of getAppstreamIconDirs()) { + const icon = join(dir, `${appId}.png`); + if (existsSync(icon)) return icon; + } + for (const root of [ + "/var/lib/flatpak/exports/share/icons", + join(homedir(), ".local/share/flatpak/exports/share/icons"), + ]) { + const svg = join(root, "hicolor/scalable/apps", `${appId}.svg`); + if (existsSync(svg)) return svg; + const png = join(root, "hicolor/128x128/apps", `${appId}.png`); + if (existsSync(png)) return png; + } + return null; +} + +function parseFlatpakTabular(output: string): FlathubListedApp[] { + const apps: FlathubListedApp[] = []; + for (const line of output.split("\n")) { + const parts = line.split("\t"); + const name = parts[0]?.trim(); + if (!name) continue; + apps.push({ + name, + arch: parts[1]?.trim() ?? "", + version: parts[2]?.trim() ?? "", + branch: parts[3]?.trim() ?? "", + origin: parts[4]?.trim() ?? "", + }); + } + return apps; +} + +/** + * List installed Flatpak applications (system + user). + */ +export async function fetchFlathubInstalled(): Promise { + const result = await run( + "flatpak", + ["list", "--app", `--columns=${LIST_COLUMNS}`], + { timeout: 60_000 }, + ); + if (!result.ok) return []; + return parseFlatpakTabular(result.stdout); +} + +/** + * List all applications available from the Flathub remote. + */ +export async function fetchFlathubRemote(): Promise { + const result = await run( + "flatpak", + ["remote-ls", "flathub", "--app", `--columns=${LIST_COLUMNS}`], + { timeout: 120_000 }, + ); + if (!result.ok) return []; + return parseFlatpakTabular(result.stdout); +} + +/** + * Build the markdown for a Flathub app's side-panel detail. + */ +export function buildDetailMarkdown( + screenshots: NonNullable, + app: FlathubApp, + displayApp: FlathubApp, +): string { + const name = displayApp.name || app.name || app.app_id; + const lines: string[] = []; + lines.push(`# ${name}`); + if (displayApp.summary) lines.push(`${displayApp.summary}`); + if (displayApp.description) { + lines.push(""); + lines.push(displayApp.description); + } + for (const shot of screenshots ?? []) { + const sizes = shot.sizes ?? []; + const image = sizes.find((size) => Number(size.width) >= 600) ?? sizes[0]; + if (image?.src) { + lines.push(`\n![${shot.caption ?? name}](${image.src})`); + } + } + return lines.join("\n"); +} + +/** Format an install count, e.g. `12300` -> `12.3k`. */ +export function formatInstalls(installs: number): string { + if (installs >= 1_000_000) return `${(installs / 1_000_000).toFixed(1)}M`; + if (installs >= 1_000) return `${(installs / 1_000).toFixed(1)}k`; + return String(installs); +} \ No newline at end of file diff --git a/extensions/apt-manager/src/lib/flatpakRemotes.ts b/extensions/apt-manager/src/lib/flatpakRemotes.ts new file mode 100644 index 00000000..132d72ec --- /dev/null +++ b/extensions/apt-manager/src/lib/flatpakRemotes.ts @@ -0,0 +1,151 @@ +import { existsSync } from "node:fs"; +import { type OperationResult, runPrivilegedCommand } from "./apt"; +import { run } from "./exec"; + +export type FlatpakInstallation = "system" | "user"; + +/** Whether the `flatpak` CLI is available on this system. */ +export function hasFlatpakBinary(): boolean { + return ["/usr/bin/flatpak", "/bin/flatpak"].some((path) => existsSync(path)); +} + +/** A Flatpak remote repository from either installation. */ +export type FlatpakRemote = { + name: string; + title: string | null; + url: string; + homepage: string | null; + priority: number; + enabled: boolean; + installation: FlatpakInstallation; +}; + +const REMOTE_COLUMNS = "name,title,url,options,priority,homepage"; + +/** + * Parse one `flatpak remotes --columns=...` output line. When both system and + * user installations are listed, the options column carries the installation + * token (`system`/`user`) plus flag tokens such as `disabled`. + */ +function parseRemoteLine(line: string): FlatpakRemote | null { + const parts = line.split("\t"); + const name = parts[0]?.trim(); + if (!name) return null; + const options = (parts[3] ?? "") + .split(",") + .map((option) => option.trim().toLowerCase()) + .filter(Boolean); + const homepage = parts[5]?.trim(); + return { + name, + title: parts[1]?.trim() || null, + url: parts[2]?.trim() ?? "", + enabled: !options.includes("disabled"), + priority: Number.parseInt(parts[4] ?? "", 10) || 1, + homepage: homepage && homepage !== "-" ? homepage : null, + installation: options.includes("user") ? "user" : "system", + }; +} + +/** + * List all Flatpak remotes from the system and user installations, including + * disabled ones. Returns an error message when the `flatpak` command fails. + */ +export async function fetchFlatpakRemotes(): Promise<{ + remotes: FlatpakRemote[]; + error: string | null; +}> { + const result = await run( + "flatpak", + ["remotes", "--show-disabled", `--columns=${REMOTE_COLUMNS}`], + { timeout: 30_000 }, + ); + if (!result.ok) { + return { + remotes: [], + error: + result.stderr.trim() || + `flatpak remotes failed (exit code ${result.code ?? "unknown"})`, + }; + } + const remotes: FlatpakRemote[] = []; + for (const line of result.stdout.split("\n")) { + const remote = parseRemoteLine(line); + if (remote) remotes.push(remote); + } + return { remotes, error: null }; +} + +function installationFlag(installation: FlatpakInstallation): string { + return installation === "user" ? "--user" : "--system"; +} + +/** Build the arguments to toggle a remote's enabled state. */ +export function flatpakRemoteModifyArgs( + remote: FlatpakRemote, + enabled: boolean, +): string[] { + return [ + "remote-modify", + installationFlag(remote.installation), + enabled ? "--enable" : "--disable", + remote.name, + ]; +} + +/** Build the arguments to delete a remote. */ +export function flatpakRemoteDeleteArgs(remote: FlatpakRemote): string[] { + return [ + "remote-delete", + installationFlag(remote.installation), + "--force", + remote.name, + ]; +} + +/** Build the arguments to add a remote repository. */ +export function flatpakRemoteAddArgs( + installation: FlatpakInstallation, + name: string, + url: string, + title?: string, +): string[] { + const args = [ + "remote-add", + installationFlag(installation), + "--if-not-exists", + ]; + if (title?.trim()) args.push(`--title=${title.trim()}`); + args.push(name, url); + return args; +} + +/** Build the arguments to refresh appstream metadata for a remote. */ +export function flatpakAppstreamArgs( + installation: FlatpakInstallation, + name: string, +): string[] { + return ["update", "--appstream", installationFlag(installation), name]; +} + +/** + * Run a Flatpak command. System-wide operations go through `pkexec`; user + * operations run directly. + */ +export async function runFlatpakCommand( + installation: FlatpakInstallation, + args: string[], + description?: string, +): Promise { + if (installation === "user") { + const result = await run("flatpak", args, { timeout: 20 * 60 * 1000 }); + return { + ok: result.ok, + code: result.code, + stdout: result.stdout, + stderr: result.stderr, + command: `flatpak ${args.join(" ")}`, + }; + } + return runPrivilegedCommand("flatpak", args, description); +} diff --git a/extensions/apt-manager/src/lib/sources.ts b/extensions/apt-manager/src/lib/sources.ts new file mode 100644 index 00000000..5a277cdc --- /dev/null +++ b/extensions/apt-manager/src/lib/sources.ts @@ -0,0 +1,375 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { run } from "./exec"; + +export const SOURCES_LIST = "/etc/apt/sources.list"; +export const SOURCES_LIST_D = "/etc/apt/sources.list.d"; + +export type RepoSource = { + /** Stable unique id built from file path + block index. */ + id: string; + /** Absolute path of the file that holds this source. */ + file: string; + format: "legacy" | "deb822"; + enabled: boolean; + types: string[]; + uris: string[]; + suites: string[]; + components: string[]; + signedBy: string | null; + /** Exact source of the block within the file (line for legacy, stanza for deb822). */ + raw: string; + /** 0-based start line of the block inside the file. */ + startLine: number; + /** 0-based exclusive end line of the block inside the file. */ + endLine: number; +}; + +export type ParsedFile = { + path: string; + content: string; + blocks: Array<{ start: number; end: number }>; +}; + +export type ReposState = { + sources: RepoSource[]; + files: ParsedFile[]; + error: string | null; +}; + +function readSafely(path: string): string | null { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } +} + +/** + * Detect whether a sources file uses the deb822 or the legacy one-line format + * by looking at the first non-comment, non-blank field line. + */ +function detectFormat(content: string): "legacy" | "deb822" { + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#")) continue; + if (/^deb(?:-src)?(\s|$)/.test(trimmed)) return "legacy"; + if ( + /^(Types|URIs|Suites|Components|Enabled|Architectures|Signed-By|Description):/i.test( + trimmed, + ) + ) { + return "deb822"; + } + } + return "legacy"; +} + +function sourcesFileCandidates(): string[] { + const candidates = [SOURCES_LIST]; + try { + for (const entry of readdirSync(SOURCES_LIST_D)) { + if (entry.endsWith(".list") || entry.endsWith(".sources")) { + candidates.push(join(SOURCES_LIST_D, entry)); + } + } + } catch { + // directory does not exist (unlikely on Debian/Ubuntu systems) + } + return candidates; +} + +export function loadRepos(): ReposState { + const files: ParsedFile[] = []; + const sources: RepoSource[] = []; + let error: string | null = null; + + for (const path of sourcesFileCandidates()) { + const content = readSafely(path); + if (content === null) continue; + + const lines = content.split("\n"); + const file: ParsedFile = { path, content, blocks: [] }; + + // `.sources` files are always deb822; the main sources.list file may be + // either format depending on the distribution, so detect by content. + const isDeb822 = + path.endsWith(".sources") || + (path === SOURCES_LIST && detectFormat(content) === "deb822"); + + if (isDeb822) { + parseDeb822File(file, lines, sources); + } else { + parseLegacyFile(file, lines, sources); + } + + if (file.blocks.length > 0) files.push(file); + } + + if (sources.length === 0) { + error = "No repositories found. Make sure /etc/apt/sources.list exists."; + } + + return { sources, files, error }; +} + +function parseDeb822File( + file: ParsedFile, + lines: string[], + sources: RepoSource[], +) { + const KEYS = new Set([ + "types", + "uris", + "suites", + "components", + "signed-by", + "enabled", + ]); + let stanza: { + start: number; + fields: Map; + commented: boolean; + } | null = null; + + const flush = () => { + if (!stanza) return; + const end = findStanzaEnd(lines, stanza.start); + const raw = lines.slice(stanza.start, end).join("\n"); + sources.push( + buildSource( + file.path, + "deb822", + stanza.start, + end, + stanza.fields, + raw, + stanza.commented ? false : undefined, + ), + ); + file.blocks.push({ start: stanza.start, end }); + stanza = null; + }; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const trimmed = line.trim(); + const isComment = trimmed.startsWith("#"); + // A fully commented stanza (`#Types: deb`, …) is a disabled source. + const content = isComment ? trimmed.replace(/^#+\s*/, "") : trimmed; + if (content.includes(":")) { + const key = content.slice(0, content.indexOf(":")).trim().toLowerCase(); + if (KEYS.has(key)) { + if (!stanza) { + stanza = { start: index, fields: new Map(), commented: isComment }; + } else if (!isComment) { + stanza.commented = false; + } + const existing = stanza.fields.get(key) ?? []; + existing.push(content.slice(content.indexOf(":") + 1).trim()); + stanza.fields.set(key, existing); + continue; + } + } + // A blank line or a new block that begins with a fresh key boundaries a stanza. + if (line.trim() === "" && stanza) flush(); + } + if (stanza) flush(); +} + +function findStanzaEnd(lines: string[], start: number): number { + let end = start; + while (end < lines.length) { + if (lines[end].trim() === "") break; + end += 1; + } + return end; +} + +function parseLegacyFile( + file: ParsedFile, + lines: string[], + sources: RepoSource[], +) { + for (let index = 0; index < lines.length; index += 1) { + const trimmed = lines[index].trim(); + // Match `deb`/`deb-src` lines, including commented-out (disabled) ones. + const match = /^(\#+\s*)?(deb(?:-src)?)(\s|$)/.exec(trimmed); + if (!match) continue; + const disabled = match[1] !== undefined && match[1] !== ""; + const content = disabled ? trimmed.slice(match[1].length) : trimmed; + const fields = parseLegacyLine(content); + if (fields) { + const endLine = index + 1; + sources.push( + buildSource( + file.path, + "legacy", + index, + endLine, + fields, + trimmed, + disabled ? false : undefined, + ), + ); + file.blocks.push({ start: index, end: endLine }); + } + } +} + +function parseLegacyLine(line: string): Map | null { + const headerMatch = /^(deb(?:-src)?)/.exec(line); + if (!headerMatch) return null; + + let rest = line.slice(headerMatch[1].length).trim(); + // Skip global options block, e.g. `[arch=amd64 trusted=yes]` + if (rest.startsWith("[")) { + const close = rest.indexOf("]"); + if (close === -1) return null; + rest = rest.slice(close + 1).trim(); + } + + const tokens = rest.split(/\s+/).filter(Boolean); + if (tokens.length < 2) return null; + + const fields = new Map(); + fields.set("types", headerMatch[1] === "deb-src" ? ["deb-src"] : ["deb"]); + fields.set("uris", [tokens[0]]); + fields.set("suites", [tokens[1]]); + fields.set("components", tokens.slice(2)); + + return fields; +} + +function buildSource( + file: string, + format: "legacy" | "deb822", + startLine: number, + endLine: number, + fields: Map, + raw: string, + enabledOverride?: boolean, +): RepoSource { + const list = (key: string): string[] => fields.get(key) ?? []; + const enabledValue = (list("enabled")[0] ?? "yes").toLowerCase(); + const enabled = enabledOverride ?? enabledValue === "yes"; + const types = list("types") + .flatMap((value) => value.split(",")) + .map((v) => v.trim()) + .filter(Boolean); + const uris = list("uris") + .flatMap((value) => value.split(",")) + .map((v) => v.trim()) + .filter(Boolean); + const suites = list("suites") + .flatMap((value) => value.split(",")) + .map((v) => v.trim()) + .filter(Boolean); + const components = list("components") + .flatMap((value) => value.split(",")) + .map((v) => v.trim()) + .filter(Boolean); + const signedBy = list("signed-by")[0] ?? null; + + return { + id: `${file}:${startLine}`, + file, + format, + enabled, + types: types.length > 0 ? types : ["deb"], + uris, + suites, + components, + signedBy, + raw, + startLine, + endLine, + }; +} + +/** + * Build a deb822 `.sources` stanza for a repository that is about to be added. + */ +export function buildDeb822Source(input: { + types: string[]; + uri: string; + suites: string[]; + components: string[]; + signedBy?: string; + comment?: string; +}): string { + const lines: string[] = []; + if (input.comment) lines.push(`# ${input.comment}`); + lines.push(`Types: ${input.types.join(" ")}`); + lines.push(`URIs: ${input.uri}`); + lines.push(`Suites: ${input.suites.join(" ")}`); + if (input.components.length > 0) + lines.push(`Components: ${input.components.join(" ")}`); + if (input.signedBy) lines.push(`Signed-By: ${input.signedBy}`); + lines.push("Enabled: yes"); + return `${lines.join("\n")}\n`; +} + +/** + * Remove the block spanning [startLine, endLine) from a file's content. + */ +export function removeBlockFromContent( + content: string, + startLine: number, + endLine: number, +): string { + const lines = content.split("\n"); + const next = [...lines.slice(0, startLine), ...lines.slice(endLine)]; + return next.join("\n"); +} + +export async function writeFilePrivileged( + path: string, + content: string, +): Promise { + const result = await run("pkexec", ["tee", path], { + input: content, + timeout: 120_000, + }); + return result.ok + ? null + : result.stderr.trim() || result.stdout.trim() || "Failed to write file"; +} + +export async function deleteFilePrivileged( + path: string, +): Promise { + const result = await run("pkexec", ["rm", "-f", path], { timeout: 120_000 }); + return result.ok ? null : result.stderr.trim() || "Failed to delete file"; +} + +/** + * Apply a set of pending file changes. Empty replacements delete the file, + * everything else overwrites it via `pkexec tee`. + */ +export async function applyRepoChanges( + changes: Array<{ path: string; content: string | null }>, +): Promise> { + const results: Array<{ path: string; ok: boolean; error: string | null }> = + []; + for (const change of changes) { + if (change.content === null || change.content.trim() === "") { + const error = await deleteFilePrivileged(change.path); + results.push({ path: change.path, ok: error === null, error }); + } else { + const error = await writeFilePrivileged(change.path, change.content); + results.push({ path: change.path, ok: error === null, error }); + } + } + return results; +} + +export function slugify(input: string): string { + const slug = input + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + return slug || "custom"; +} diff --git a/extensions/apt-manager/src/lib/useAppDetail.ts b/extensions/apt-manager/src/lib/useAppDetail.ts new file mode 100644 index 00000000..b51160bf --- /dev/null +++ b/extensions/apt-manager/src/lib/useAppDetail.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; +import { type FlathubApp, fetchAppDetails } from "./flathub"; + +const APP_DETAIL_STALE_MS = 10 * 60 * 1000; + +const appDetailCache = new Map< + string, + { data: FlathubApp; fetchedAt: number } +>(); + +/** + * Lightweight stand-in for `useQuery(["flathub","app-detail",appId], …)` that + * fetches Flathub app details and caches them for a short while. No external + * query library is required. + */ +export function useAppDetail( + appId: string, + enabled: boolean, +): { data: FlathubApp | null; isLoading: boolean } { + const [state, setState] = useState<{ + data: FlathubApp | null; + isLoading: boolean; + }>({ data: null, isLoading: enabled }); + + useEffect(() => { + if (!enabled) return; + const cached = appDetailCache.get(appId); + if (cached && Date.now() - cached.fetchedAt < APP_DETAIL_STALE_MS) { + setState({ data: cached.data, isLoading: false }); + return; + } + let cancelled = false; + setState({ data: null, isLoading: true }); + fetchAppDetails(appId) + .then((fullApp) => { + if (cancelled) return; + appDetailCache.set(appId, { data: fullApp, fetchedAt: Date.now() }); + setState({ data: fullApp, isLoading: false }); + }) + .catch(() => { + if (!cancelled) setState({ data: null, isLoading: false }); + }); + return () => { + cancelled = true; + }; + }, [appId, enabled]); + + return state; +} diff --git a/extensions/apt-manager/src/views/AddFlatpakRemote.tsx b/extensions/apt-manager/src/views/AddFlatpakRemote.tsx new file mode 100644 index 00000000..60ff0463 --- /dev/null +++ b/extensions/apt-manager/src/views/AddFlatpakRemote.tsx @@ -0,0 +1,136 @@ +import { + Action, + ActionPanel, + Form, + Icon, + Toast, + showToast, + useNavigation, +} from "@vicinae/api"; +import { useState } from "react"; +import { + flatpakAppstreamArgs, + flatpakRemoteAddArgs, + runFlatpakCommand, + type FlatpakInstallation, +} from "../lib/flatpakRemotes"; + +type Props = { + onAdded?: () => void; +}; + +export function AddFlatpakRemoteForm({ onAdded }: Props) { + const { pop } = useNavigation(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const onSubmit = async (values: Form.Values) => { + const name = String(values.name ?? "").trim(); + const url = String(values.url ?? "").trim(); + const title = String(values.title ?? "").trim(); + const installation = String( + values.installation ?? "system", + ) as FlatpakInstallation; + + if (name === "" || url === "") { + await showToast({ + style: Toast.Style.Failure, + title: "A name and a URL are required", + }); + return; + } + if (!/^[a-z0-9][a-z0-9._-]*$/i.test(name)) { + await showToast({ + style: Toast.Style.Failure, + title: "Name must be alphanumeric with . _ -", + }); + return; + } + + const args = flatpakRemoteAddArgs(installation, name, url, title); + setIsSubmitting(true); + const toast = await showToast({ + style: Toast.Style.Animated, + title: `Adding remote ${name}`, + }); + const result = await runFlatpakCommand(installation, args, `Add ${name}`); + setIsSubmitting(false); + + if (!result.ok) { + toast.style = Toast.Style.Failure; + toast.title = "Failed to add remote"; + toast.message = + result.stderr.trim().slice(0, 140) || + `exit code ${result.code ?? "unknown"}`; + return; + } + + toast.style = Toast.Style.Success; + toast.title = "Remote added"; + onAdded?.(); + + if (Boolean(values.updateNow)) { + const appstream = await runFlatpakCommand( + installation, + flatpakAppstreamArgs(installation, name), + `Update appstream for ${name}`, + ); + if (!appstream.ok) { + await showToast({ + style: Toast.Style.Failure, + title: `Failed to update appstream for ${name}`, + message: appstream.stderr.trim().slice(0, 140) || undefined, + }); + } + } + pop(); + }; + + return ( +
+ + + } + > + + + + + + + + + + ); +} diff --git a/extensions/apt-manager/src/views/AddRepo.tsx b/extensions/apt-manager/src/views/AddRepo.tsx new file mode 100644 index 00000000..1d5d2b87 --- /dev/null +++ b/extensions/apt-manager/src/views/AddRepo.tsx @@ -0,0 +1,160 @@ +import { join } from "node:path"; +import { + Action, + ActionPanel, + Form, + Icon, + showToast, + Toast, + useNavigation, +} from "@vicinae/api"; +import { useState } from "react"; +import { runAptUpdate } from "../lib/apt"; +import { + buildDeb822Source, + SOURCES_LIST_D, + slugify, + writeFilePrivileged, +} from "../lib/sources"; + +type Props = { + onAdded?: () => void; +}; + +export function AddRepoForm({ onAdded }: Props) { + const { pop } = useNavigation(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const onSubmit = async (values: Form.Values) => { + const name = String(values.filename ?? "").trim(); + const uri = String(values.uri ?? "").trim(); + const suites = splitList(values.suites); + const components = splitList(values.components); + const signedBy = String(values.signedBy ?? "").trim(); + const includeSource = Boolean(values.includeSource); + const enabled = Boolean(values.enabled); + + if (name === "" || uri === "" || suites.length === 0) { + await showToast({ + style: Toast.Style.Failure, + title: "URI, suites and a name are required", + }); + return; + } + if (!/^[a-z0-9][a-z0-9._-]*$/i.test(name)) { + await showToast({ + style: Toast.Style.Failure, + title: "Filename must be alphanumeric with . _ -", + }); + return; + } + + const types = includeSource ? ["deb", "deb-src"] : ["deb"]; + const content = buildDeb822Source({ + types, + uri, + suites, + components, + signedBy: signedBy || undefined, + comment: `Added via Vicinae apt-manager (${new Date().toISOString()})`, + }); + const finalContent = enabled + ? content + : content.replace("Enabled: yes", "Enabled: no"); + + const path = join(SOURCES_LIST_D, `${slugify(name)}.sources`); + setIsSubmitting(true); + const toast = await showToast({ + style: Toast.Style.Animated, + title: `Adding repository ${uri}`, + }); + const error = await writeFilePrivileged(path, finalContent); + setIsSubmitting(false); + + if (error) { + toast.style = Toast.Style.Failure; + toast.title = "Failed to add repository"; + toast.message = error; + return; + } + + toast.style = Toast.Style.Success; + toast.title = "Repository added"; + onAdded?.(); + + const shouldUpdate = Boolean(values.updateNow); + if (shouldUpdate) { + await runAptUpdate(); + pop(); + return; + } + pop(); + }; + + return ( +
+ + + } + > + + + + + + + + + + ); +} + +function splitList(value: Form.Value): string[] { + const text = typeof value === "string" ? value : String(value ?? ""); + return text + .split(",") + .map((part) => part.trim()) + .filter(Boolean); +} diff --git a/extensions/apt-manager/src/views/FlatpakRepos.tsx b/extensions/apt-manager/src/views/FlatpakRepos.tsx new file mode 100644 index 00000000..18d266ec --- /dev/null +++ b/extensions/apt-manager/src/views/FlatpakRepos.tsx @@ -0,0 +1,232 @@ +import { + Action, + ActionPanel, + Color, + Icon, + List, + Keyboard, + Toast, + showToast, + useNavigation, +} from "@vicinae/api"; +import { useEffect, type ReactNode, useState } from "react"; +import { RunResult } from "../components/RunResult"; +import { askConfirm } from "../lib/confirm"; +import { + fetchFlatpakRemotes, + flatpakRemoteDeleteArgs, + flatpakRemoteModifyArgs, + runFlatpakCommand, + type FlatpakRemote, +} from "../lib/flatpakRemotes"; +import { AddFlatpakRemoteForm } from "./AddFlatpakRemote"; + +export function FlatpakReposView() { + const [reloadKey, setReloadKey] = useState(0); + const [remotes, setRemotes] = useState( + undefined, + ); + const [error, setError] = useState(null); + const { push } = useNavigation(); + + useEffect(() => { + let cancelled = false; + fetchFlatpakRemotes().then((result) => { + if (cancelled) return; + setRemotes(result.remotes); + setError(result.error); + }); + return () => { + cancelled = true; + }; + }, [reloadKey]); + + const refresh = () => setReloadKey((value) => value + 1); + + const systemItems = remotes?.filter( + (remote) => remote.installation === "system", + ); + const userItems = remotes?.filter((remote) => remote.installation === "user"); + + return ( + + } + /> + + } + > + {error ? ( + + ) : ( + <> + + + + + + + + )} + + ); +} + +function RemoteItems({ + items, + push, + onChanged, +}: { + items?: FlatpakRemote[]; + push: (element: ReactNode) => void; + onChanged: () => void; +}) { + if (!items) { + return ; + } + if (items.length === 0) { + return ( + + ); + } + return items.map((remote) => ( + + + runRemoteAction( + remote, + flatpakRemoteModifyArgs(remote, !remote.enabled), + `${remote.enabled ? "Disable" : "Enable"} ${remote.name}`, + push, + onChanged, + ) + } + /> + } + /> + + + runRemoteAction( + remote, + flatpakRemoteDeleteArgs(remote), + `Remove ${remote.name}`, + push, + onChanged, + { + title: "Remove repository", + message: `Remove ${remote.name} from the ${remote.installation} installation?`, + }, + ) + } + /> + + } + /> + )); +} + +function formatSubtitle(remote: FlatpakRemote): string { + const parts = [ + remote.url, + remote.installation === "system" ? "system" : "user", + `priority ${remote.priority}`, + ]; + if (remote.homepage) parts.push(remote.homepage); + return parts.join(" · "); +} + +async function runRemoteAction( + remote: FlatpakRemote, + args: string[], + label: string, + push: (element: ReactNode) => void, + onChanged: () => void, + confirm?: { title: string; message: string }, +) { + if (confirm) { + const confirmed = await askConfirm( + confirm.title, + confirm.message, + "Continue", + ); + if (!confirmed) return; + } + const toast = await showToast({ style: Toast.Style.Animated, title: label }); + const result = await runFlatpakCommand(remote.installation, args, label); + if (result.ok) { + toast.style = Toast.Style.Success; + toast.title = label; + toast.message = "Done"; + } else { + toast.style = Toast.Style.Failure; + toast.title = `${label} failed`; + toast.message = + result.stderr.trim().slice(0, 140) || + `exit code ${result.code ?? "unknown"}`; + } + push( + , + ); + onChanged(); +} diff --git a/extensions/apt-manager/src/views/Repos.tsx b/extensions/apt-manager/src/views/Repos.tsx new file mode 100644 index 00000000..d298ed1c --- /dev/null +++ b/extensions/apt-manager/src/views/Repos.tsx @@ -0,0 +1,267 @@ +import { + Action, + ActionPanel, + Color, + Icon, + List, + showToast, + Toast, +} from "@vicinae/api"; +import { useMemo, useState } from "react"; +import { type OperationResult, runAptUpdate } from "../lib/apt"; +import { askConfirm } from "../lib/confirm"; +import { + applyRepoChanges, + loadRepos, + type ParsedFile, + type RepoSource, + removeBlockFromContent, +} from "../lib/sources"; +import { AddRepoForm } from "./AddRepo"; + +type Props = { + onChanged?: () => void; +}; + +export function ReposView({ onChanged }: Props) { + const [reloadKey, setReloadKey] = useState(0); + const state = useMemo(() => loadRepos(), [reloadKey]); + + const refresh = () => setReloadKey((value) => value + 1); + const changed = () => { + refresh(); + onChanged?.(); + }; + + return ( + + } + /> + + + } + > + + {state.error ? ( + + ) : ( + state.sources.map((repo) => ( + + + setEnabled(repo, state.files, !repo.enabled, changed) + } + /> + } + /> + + removeRepo(repo, state.files, changed)} + /> + + + } + /> + )) + )} + + + ); +} + +function enabledAccessory(repo: RepoSource): List.Item.Accessory[] { + return [ + { + tag: { + color: repo.enabled ? Color.Green : Color.SecondaryText, + value: repo.enabled ? "enabled" : "disabled", + }, + }, + ]; +} + +function hostOf(repo: RepoSource): string { + const uri = repo.uris[0]; + if (!uri) return ""; + try { + return new URL(uri).host; + } catch { + return uri; + } +} + +const formatTitle = (repo: RepoSource): string => { + const host = hostOf(repo); + const suites = repo.suites.join("/"); + return host ? `${host} · ${suites}` : suites || repo.file; +}; + +const formatSubtitle = (repo: RepoSource): string => + `${repo.format === "deb822" ? ".sources" : "one-line"} · ${repo.types.join(", ")} · ${repo.components.join(" ")} · ${repo.enabled ? "enabled" : "disabled"}`; + +async function removeRepo( + repo: RepoSource, + files: ParsedFile[], + changed: () => void, +) { + const confirmed = await askConfirm( + "Remove repository", + `Remove repo ${hostOf(repo) || repo.file}?`, + "Remove", + ); + if (!confirmed) return; + + const toast = await showToast({ + style: Toast.Style.Animated, + title: "Removing repository", + }); + const file = files.find((entry) => entry.path === repo.file); + const content = file + ? removeBlockFromContent(file.content, repo.startLine, repo.endLine) + : null; + const results = await applyRepoChanges([{ path: repo.file, content }]); + + const failure = results.find((result) => !result.ok); + if (failure) { + toast.style = Toast.Style.Failure; + toast.title = "Failed to remove repository"; + toast.message = failure.error ?? undefined; + return; + } + toast.style = Toast.Style.Success; + toast.title = "Repository removed"; + changed(); +} + +/** + * Rewrite a source file toggling the enabled status of the given repo block. + */ +function rewriteEnabled( + base: string, + repo: RepoSource, + enabled: boolean, +): string { + const lines = base.split("\n"); + if (repo.format === "deb822") { + const commented = (lines[repo.startLine] ?? "").trimStart().startsWith("#"); + if (commented && enabled) { + for ( + let index = repo.startLine; + index < Math.min(repo.endLine, lines.length); + index += 1 + ) { + lines[index] = lines[index].replace(/^#+\s*/, ""); + } + } + for ( + let index = repo.startLine; + index < Math.min(repo.endLine, lines.length); + index += 1 + ) { + const stripped = lines[index].trim(); + if (stripped.startsWith("Enabled:")) { + lines[index] = `Enabled: ${enabled ? "yes" : "no"}`; + return lines.join("\n"); + } + } + return lines.join("\n"); + } + if (repo.startLine >= 0 && repo.startLine < lines.length) { + const line = lines[repo.startLine]; + if (enabled) { + lines[repo.startLine] = line.replace(/^#+\s*/, ""); + } else if (!line.trimStart().startsWith("#")) { + lines[repo.startLine] = `# ${line}`; + } + } + return lines.join("\n"); +} + +async function setEnabled( + repo: RepoSource, + files: ParsedFile[], + enabled: boolean, + changed: () => void, +) { + const file = files.find((entry) => entry.path === repo.file); + const base = file?.content ?? repo.raw; + const content = rewriteEnabled(base, repo, enabled); + const results = await applyRepoChanges([{ path: repo.file, content }]); + const failure = results.find((result) => !result.ok); + if (failure) { + await showToast({ + style: Toast.Style.Failure, + title: `Failed to ${enabled ? "enable" : "disable"} repository`, + message: failure.error ?? undefined, + }); + return; + } + await showToast({ + style: Toast.Style.Success, + title: `Repository ${enabled ? "enabled" : "disabled"}`, + }); + changed(); +} + +async function runUpdateAndShow(): Promise { + const toast = await showToast({ + style: Toast.Style.Animated, + title: "Refreshing package lists", + }); + const result: OperationResult = await runAptUpdate(); + if (result.ok) { + toast.style = Toast.Style.Success; + toast.title = "Package lists updated"; + return; + } + toast.style = Toast.Style.Failure; + toast.title = "Failed to update package lists"; + toast.message = result.stderr.trim().slice(0, 140) || undefined; +} diff --git a/extensions/apt-manager/tsconfig.json b/extensions/apt-manager/tsconfig.json new file mode 100644 index 00000000..8d9b6bbc --- /dev/null +++ b/extensions/apt-manager/tsconfig.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Node 16", + "include": ["src/**/*"], + "compilerOptions": { + //"lib": ["es2020"], + "module": "commonjs", + "target": "es2020", + "strict": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "jsx": "react-jsx", + "types": ["node"] + } +} From 0ca7c2352a10dde0b489b96ac64f57b855647e24 Mon Sep 17 00:00:00 2001 From: Ali Date: Tue, 22 Sep 2026 13:35:50 +0500 Subject: [PATCH 02/12] Update extensions/apt-manager/src/lib/exec.ts Co-authored-by: Clankus Aurelius --- extensions/apt-manager/src/lib/exec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/apt-manager/src/lib/exec.ts b/extensions/apt-manager/src/lib/exec.ts index 77138aba..bc4b4c64 100644 --- a/extensions/apt-manager/src/lib/exec.ts +++ b/extensions/apt-manager/src/lib/exec.ts @@ -35,7 +35,7 @@ export function run( options: ExecOptions = {}, ): Promise { return new Promise((resolve) => { - const env: NodeJS.ProcessEnv = { LC_ALL: "C", ...process.env }; + const env: NodeJS.ProcessEnv = { ...process.env, LC_ALL: "C" }; if (options.env) { Object.assign(env, options.env); } From 4a6931886964a7639f8d118d623b0a26b0d1fa6c Mon Sep 17 00:00:00 2001 From: ali00209 Date: Tue, 22 Sep 2026 13:49:06 +0500 Subject: [PATCH 03/12] fix: prevent deb822 disable no-op, add pkexec checks, guard repo overwrite - Insert Enabled: no when disabling deb822 stanza without the field (CORRECTNESS-001) - Add pkexec availability checks to writeFilePrivileged/deleteFilePrivileged - Add existsSync check before writing new repo files in AddRepo - Narrow README terminal retry documentation to match actual behavior --- extensions/apt-manager/README.md | 8 ++++---- extensions/apt-manager/src/lib/apt.ts | 2 +- extensions/apt-manager/src/lib/sources.ts | 7 +++++++ extensions/apt-manager/src/views/AddRepo.tsx | 9 +++++++++ extensions/apt-manager/src/views/Repos.tsx | 3 +++ 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/extensions/apt-manager/README.md b/extensions/apt-manager/README.md index 9d172dd5..73bb8fa3 100644 --- a/extensions/apt-manager/README.md +++ b/extensions/apt-manager/README.md @@ -17,10 +17,10 @@ Launched from the `apt` command: ## How it works - Read-only operations (`apt list`, `apt-cache show`) run without privileges. -- Every privileged operation (`install`, `remove`, `upgrade`, `cleanup`, repo writes) - is run via `pkexec`, which shows the desktop's polkit authentication dialog. - If `pkexec` is missing you'll get a readable error plus a "Retry in Terminal (sudo)" - action instead. +- Every privileged operation (`install`, `remove`, `upgrade`, `cleanup`) + is run via `pkexec`, which shows the desktop's polkit authentication dialog. + If `pkexec` is missing you'll get a readable error plus a "Retry in Terminal (sudo)" + action instead. Repository writes are also privileged but do not provide a terminal retry fallback; use `sudo tee` or your editor instead. - Command output for long-running operations is shown in a result view after completion. ## Development diff --git a/extensions/apt-manager/src/lib/apt.ts b/extensions/apt-manager/src/lib/apt.ts index be07da47..4a50b9ee 100644 --- a/extensions/apt-manager/src/lib/apt.ts +++ b/extensions/apt-manager/src/lib/apt.ts @@ -247,7 +247,7 @@ export type OperationResult = { const APT_ENV = { DEBIAN_FRONTEND: "noninteractive" }; const PRIVILEGED_TIMEOUT = 20 * 60 * 1000; -function pkexecAvailable(): boolean { +export function pkexecAvailable(): boolean { return ["/usr/bin/pkexec", "/bin/pkexec"].some((path) => existsSync(path)); } diff --git a/extensions/apt-manager/src/lib/sources.ts b/extensions/apt-manager/src/lib/sources.ts index 5a277cdc..a1987d69 100644 --- a/extensions/apt-manager/src/lib/sources.ts +++ b/extensions/apt-manager/src/lib/sources.ts @@ -1,6 +1,7 @@ import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { run } from "./exec"; +import { pkexecAvailable } from "./apt"; export const SOURCES_LIST = "/etc/apt/sources.list"; export const SOURCES_LIST_D = "/etc/apt/sources.list.d"; @@ -327,6 +328,9 @@ export async function writeFilePrivileged( path: string, content: string, ): Promise { + if (!pkexecAvailable()) { + return "`pkexec` was not found in PATH. Install polkit (e.g. `apt install policykit-1`) or use the 'Retry in Terminal' action with sudo."; + } const result = await run("pkexec", ["tee", path], { input: content, timeout: 120_000, @@ -339,6 +343,9 @@ export async function writeFilePrivileged( export async function deleteFilePrivileged( path: string, ): Promise { + if (!pkexecAvailable()) { + return "`pkexec` was not found in PATH. Install polkit (e.g. `apt install policykit-1`) or use the 'Retry in Terminal' action with sudo."; + } const result = await run("pkexec", ["rm", "-f", path], { timeout: 120_000 }); return result.ok ? null : result.stderr.trim() || "Failed to delete file"; } diff --git a/extensions/apt-manager/src/views/AddRepo.tsx b/extensions/apt-manager/src/views/AddRepo.tsx index 1d5d2b87..d2358325 100644 --- a/extensions/apt-manager/src/views/AddRepo.tsx +++ b/extensions/apt-manager/src/views/AddRepo.tsx @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import { join } from "node:path"; import { Action, @@ -63,6 +64,14 @@ export function AddRepoForm({ onAdded }: Props) { : content.replace("Enabled: yes", "Enabled: no"); const path = join(SOURCES_LIST_D, `${slugify(name)}.sources`); + if (existsSync(path)) { + await showToast({ + style: Toast.Style.Failure, + title: "Repository already exists", + message: `A file already exists at ${path}. Choose a different name.`, + }); + return; + } setIsSubmitting(true); const toast = await showToast({ style: Toast.Style.Animated, diff --git a/extensions/apt-manager/src/views/Repos.tsx b/extensions/apt-manager/src/views/Repos.tsx index d298ed1c..e308c259 100644 --- a/extensions/apt-manager/src/views/Repos.tsx +++ b/extensions/apt-manager/src/views/Repos.tsx @@ -211,6 +211,9 @@ function rewriteEnabled( return lines.join("\n"); } } + if (!enabled) { + lines.splice(repo.endLine, 0, "Enabled: no"); + } return lines.join("\n"); } if (repo.startLine >= 0 && repo.startLine < lines.length) { From ac5a6cb5546b65f4ac7b5a19f483e8718fbfb003 Mon Sep 17 00:00:00 2001 From: ali00209 Date: Tue, 22 Sep 2026 13:59:12 +0500 Subject: [PATCH 04/12] fix: preserve Flatpak installation scope for user apps - Add installation field to FlathubListedApp and AptPackage - Split fetchFlathubInstalled into --system and --user queries - Use runFlatpakCommand with installation flag instead of runPrivileged - User Flatpak operations run directly (no pkexec); system still uses pkexec - Terminal retry via sudo only for system Flatpak operations --- .../src/components/PackageActions.tsx | 17 +++++++++++-- extensions/apt-manager/src/lib/apt.ts | 3 +++ extensions/apt-manager/src/lib/flathub.ts | 24 +++++++++++++++---- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/extensions/apt-manager/src/components/PackageActions.tsx b/extensions/apt-manager/src/components/PackageActions.tsx index b5e6667e..7cc45457 100644 --- a/extensions/apt-manager/src/components/PackageActions.tsx +++ b/extensions/apt-manager/src/components/PackageActions.tsx @@ -8,7 +8,9 @@ import { useNavigation, } from "@vicinae/api"; import type { AptPackage, PackageListKind } from "../lib/apt"; +import { runFlatpakCommand } from "../lib/flatpakRemotes"; import { runPrivilegedApGet, runPrivilegedCommand } from "../lib/apt"; +import type { FlatpakInstallation } from "../lib/flatpakRemotes"; import { askConfirm } from "../lib/confirm"; import { RunResult } from "./RunResult"; @@ -32,6 +34,7 @@ export function PackageActions({ args: string[], label: string, confirmMessage: string | null, + installation?: FlatpakInstallation, ) => { if (confirmMessage) { const confirmed = await askConfirm(label, confirmMessage); @@ -44,7 +47,9 @@ export function PackageActions({ const run = command === "apt-get" ? () => runPrivilegedApGet(args, label) - : () => runPrivilegedCommand(command, args, label); + : command === "flatpak" + ? () => runFlatpakCommand(installation ?? "system", args, label) + : () => runPrivilegedCommand(command, args, label); const result = await run(); if (result.ok) { toast.style = Toast.Style.Success; @@ -62,7 +67,13 @@ export function PackageActions({ heading={label} title={label} result={result} - sudoArgs={args} + sudoArgs={ + command === "flatpak" && installation === "system" + ? args + : command === "apt-get" + ? args + : undefined + } sudoCommand={command} />, ); @@ -103,6 +114,7 @@ export function PackageActions({ ["uninstall", "-y", pkg.name], `Remove ${pkg.name}`, `Remove the Flatpak application ${pkg.name}?`, + pkg.installation, ) } /> @@ -116,6 +128,7 @@ export function PackageActions({ ["install", "-y", "flathub", pkg.name], `Install ${pkg.name}`, null, + pkg.installation, ) } /> diff --git a/extensions/apt-manager/src/lib/apt.ts b/extensions/apt-manager/src/lib/apt.ts index 4a50b9ee..1093978b 100644 --- a/extensions/apt-manager/src/lib/apt.ts +++ b/extensions/apt-manager/src/lib/apt.ts @@ -28,6 +28,8 @@ export type AptPackage = { manager: "apt" | "flatpak"; /** Local path to an app icon, if one is available. */ icon: string | null; + /** Installation scope for Flatpak apps. */ + installation?: "system" | "user"; }; /** @@ -186,6 +188,7 @@ function flathubToAptPackage( description: "", manager: "flatpak", icon: findFlatpakIcon(pkg.name), + installation: pkg.installation, }; } diff --git a/extensions/apt-manager/src/lib/flathub.ts b/extensions/apt-manager/src/lib/flathub.ts index c2d25a57..a1049ed7 100644 --- a/extensions/apt-manager/src/lib/flathub.ts +++ b/extensions/apt-manager/src/lib/flathub.ts @@ -55,6 +55,7 @@ export type FlathubListedApp = { version: string; branch: string; origin: string; + installation?: "system" | "user"; }; const LIST_COLUMNS = "application,arch,version,branch,origin"; @@ -126,13 +127,28 @@ function parseFlatpakTabular(output: string): FlathubListedApp[] { * List installed Flatpak applications (system + user). */ export async function fetchFlathubInstalled(): Promise { - const result = await run( + const system = await run( "flatpak", - ["list", "--app", `--columns=${LIST_COLUMNS}`], + ["list", "--app", "--system", `--columns=${LIST_COLUMNS}`], { timeout: 60_000 }, ); - if (!result.ok) return []; - return parseFlatpakTabular(result.stdout); + const user = await run( + "flatpak", + ["list", "--app", "--user", `--columns=${LIST_COLUMNS}`], + { timeout: 60_000 }, + ); + const apps: FlathubListedApp[] = []; + if (system.ok) { + for (const app of parseFlatpakTabular(system.stdout)) { + apps.push({ ...app, installation: "system" }); + } + } + if (user.ok) { + for (const app of parseFlatpakTabular(user.stdout)) { + apps.push({ ...app, installation: "user" }); + } + } + return apps; } /** From abb8422131e3878e3b626df47a4d49736b22b061 Mon Sep 17 00:00:00 2001 From: ali00209 Date: Tue, 22 Sep 2026 14:00:33 +0500 Subject: [PATCH 05/12] chore: add AGENT.md and .agent to gitignore, enable commonjs module --- extensions/apt-manager/.gitignore | 2 ++ extensions/apt-manager/tsconfig.json | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/apt-manager/.gitignore b/extensions/apt-manager/.gitignore index eb3ae205..a0b7ce6a 100644 --- a/extensions/apt-manager/.gitignore +++ b/extensions/apt-manager/.gitignore @@ -1,2 +1,4 @@ node_modules vicinae-env.d.ts +AGENT.md +.agent diff --git a/extensions/apt-manager/tsconfig.json b/extensions/apt-manager/tsconfig.json index 8d9b6bbc..4451aafa 100644 --- a/extensions/apt-manager/tsconfig.json +++ b/extensions/apt-manager/tsconfig.json @@ -3,7 +3,6 @@ "display": "Node 16", "include": ["src/**/*"], "compilerOptions": { - //"lib": ["es2020"], "module": "commonjs", "target": "es2020", "strict": true, From 79760caf72e3dda1dd581dcb58aef8c9ed841cc6 Mon Sep 17 00:00:00 2001 From: ali00209 Date: Wed, 23 Sep 2026 14:35:26 +0500 Subject: [PATCH 06/12] feat: add AppImage support to apt manager - AppImages integrated into existing apt command, mixed in package list - Discovery from ~/Applications with .desktop/.appdata.xml metadata parsing - Install from URL or local file (user choice via form) - Three-depth removal: file only, with desktop entries, with config - Manual check for updates per AppImage - AppImage detail view with name, version, description --- extensions/apt-manager/README.md | 2 + extensions/apt-manager/package.json | 4 +- extensions/apt-manager/src/apt.tsx | 14 + .../src/components/PackageActions.tsx | 98 +++++ .../src/components/PackageListItemDetail.tsx | 35 ++ .../src/components/PackageView.tsx | 16 +- extensions/apt-manager/src/lib/appimage.ts | 362 ++++++++++++++++++ extensions/apt-manager/src/lib/apt.ts | 9 +- .../src/views/InstallAppImageView.tsx | 92 +++++ 9 files changed, 625 insertions(+), 7 deletions(-) create mode 100644 extensions/apt-manager/src/lib/appimage.ts create mode 100644 extensions/apt-manager/src/views/InstallAppImageView.tsx diff --git a/extensions/apt-manager/README.md b/extensions/apt-manager/README.md index 73bb8fa3..854f8eea 100644 --- a/extensions/apt-manager/README.md +++ b/extensions/apt-manager/README.md @@ -13,6 +13,8 @@ Launched from the `apt` command: - **Clean up system** — `apt-get autoremove --purge` then `apt-get autoclean`. - **Repositories** — list sources from `sources.list`/`sources.list.d` (legacy one-line and deb822 `.sources`), add new repos as deb822 `.sources` files, enable/disable/remove. +- **AppImages** — install, remove, and check for updates of AppImage applications + installed in `~/Applications`. ## How it works diff --git a/extensions/apt-manager/package.json b/extensions/apt-manager/package.json index 4481e8ff..a716705f 100644 --- a/extensions/apt-manager/package.json +++ b/extensions/apt-manager/package.json @@ -2,7 +2,7 @@ "$schema": "https://raw.githubusercontent.com/vicinaehq/vicinae/refs/heads/main/extra/schemas/extension.json", "name": "debian-packages", "title": "Debian Packages", - "description": "Debian Packages Management Tool", + "description": "Debian Packages Management Tool with Flatpak and AppImage support", "categories": ["System"], "license": "MIT", "author": "ali00209", @@ -13,7 +13,7 @@ { "name": "apt", "title": "APT", -"description": "APT package manager with Flatpak support: install, remove, and upgrade Debian packages; manage Flatpak apps and remotes.", + "description": "APT package manager with Flatpak and AppImage support: install, remove, and upgrade Debian packages; manage Flatpak apps and remotes and AppImage applications.", "mode": "view" } ], diff --git a/extensions/apt-manager/src/apt.tsx b/extensions/apt-manager/src/apt.tsx index 8fd136e1..157fb658 100644 --- a/extensions/apt-manager/src/apt.tsx +++ b/extensions/apt-manager/src/apt.tsx @@ -20,8 +20,10 @@ import { import { askConfirm } from "./lib/confirm"; import { fetchFlatpakRemotes, hasFlatpakBinary } from "./lib/flatpakRemotes"; import { loadRepos } from "./lib/sources"; +import { installAppImage } from "./lib/appimage"; import { ReposView } from "./views/Repos"; import { FlatpakReposView } from "./views/FlatpakRepos"; +import { InstallAppImageView } from "./views/InstallAppImageView"; export default function Command() { const repoCount = useMemo(() => loadRepos().sources.length, []); @@ -100,6 +102,18 @@ export default function Command() { /> } /> + } + /> + } + /> + {toggleDetail} + { + const toast = await showToast({ + style: Toast.Style.Animated, + title: `Removing ${pkg.name}`, + }); + const result = await removeAppImage(pkg.name, "file"); + if (result.ok) { + toast.style = Toast.Style.Success; + toast.title = `${pkg.name} removed`; + toast.message = "File removed"; + } else { + toast.style = Toast.Style.Failure; + toast.title = `Failed to remove ${pkg.name}`; + toast.message = result.error ?? undefined; + } + onRefresh(); + }} + /> + { + const toast = await showToast({ + style: Toast.Style.Animated, + title: `Removing ${pkg.name}`, + }); + const result = await removeAppImage(pkg.name, "desktop"); + if (result.ok) { + toast.style = Toast.Style.Success; + toast.title = `${pkg.name} removed`; + toast.message = "File and desktop entry removed"; + } else { + toast.style = Toast.Style.Failure; + toast.title = `Failed to remove ${pkg.name}`; + toast.message = result.error ?? undefined; + } + onRefresh(); + }} + /> + { + const toast = await showToast({ + style: Toast.Style.Animated, + title: `Removing ${pkg.name}`, + }); + const result = await removeAppImage(pkg.name, "config"); + if (result.ok) { + toast.style = Toast.Style.Success; + toast.title = `${pkg.name} removed`; + toast.message = "File, desktop entry, and config removed"; + } else { + toast.style = Toast.Style.Failure; + toast.title = `Failed to remove ${pkg.name}`; + toast.message = result.error ?? undefined; + } + onRefresh(); + }} + /> + { + const toast = await showToast({ + style: Toast.Style.Animated, + title: `Checking for updates`, + }); + const result = await checkAppImageUpdate(pkg.name); + if (!result.ok) { + toast.style = Toast.Style.Failure; + toast.title = `Failed to check for updates`; + toast.message = result.error ?? undefined; + } else if (result.updateAvailable) { + toast.style = Toast.Style.Success; + toast.title = `Update available for ${pkg.name}`; + } else { + toast.style = Toast.Style.Success; + toast.title = `${pkg.name} is up to date`; + } + }} + /> + {copyName} + + ); + } + return ( {toggleDetail} diff --git a/extensions/apt-manager/src/components/PackageListItemDetail.tsx b/extensions/apt-manager/src/components/PackageListItemDetail.tsx index 4f849a67..895b30a2 100644 --- a/extensions/apt-manager/src/components/PackageListItemDetail.tsx +++ b/extensions/apt-manager/src/components/PackageListItemDetail.tsx @@ -169,3 +169,38 @@ export function FlathubAppDetail({ /> ); } + +export function AppImageDetail({ + pkg, + enabled, +}: { + pkg: AptPackage; + enabled: boolean; +}) { + return ( + + + + {pkg.description && ( + + )} + + + } + /> + ); +} diff --git a/extensions/apt-manager/src/components/PackageView.tsx b/extensions/apt-manager/src/components/PackageView.tsx index fc4b484a..473214fb 100644 --- a/extensions/apt-manager/src/components/PackageView.tsx +++ b/extensions/apt-manager/src/components/PackageView.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import type { AptPackage, PackageListKind } from "../lib/apt"; import { fetchPackageList, flathubAppFromListed } from "../lib/apt"; import { PackageActions } from "./PackageActions"; -import { AptAppDetail, FlathubAppDetail } from "./PackageListItemDetail"; +import { AptAppDetail, AppImageDetail, FlathubAppDetail } from "./PackageListItemDetail"; const PAGE_START = 500; const PAGE_STEP = 1000; @@ -100,7 +100,11 @@ export function PackageView({ kind, title, emptyTitle }: Props) { keywords={[pkg.suite]} icon={ pkg.icon ?? - (pkg.manager === "flatpak" ? Icon.AppWindow : undefined) + (pkg.manager === "flatpak" + ? Icon.AppWindow + : pkg.manager === "appimage" + ? Icon.Box + : undefined) } accessories={accessoriesFor(kind, pkg)} detail={ @@ -109,6 +113,11 @@ export function PackageView({ kind, title, emptyTitle }: Props) { app={flathubAppFromListed(pkg)} enabled={showingDetail && selectedId === id} /> + ) : pkg.manager === "appimage" ? ( + ) : ( ]*>([\s\S]*?)<\/summary>/i); + if (summaryMatch) summary = summaryMatch[1].trim(); + const descMatch = content.match(/]*>([\s\S]*?)<\/description>/i); + if (descMatch) { + const inner = descMatch[1].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); + description = inner || null; + } + return { summary, description }; +} + +export async function discoverAppImageFile( + dir: string, +): Promise { + const lowerName = basename(dir).toLowerCase(); + if (!lowerName.endsWith(".appimage")) return null; + if (!existsSync(dir)) return null; + + const baseName = basename(dir, ".AppImage"); + const desktopPath = [ + join(dirname(dir), `${baseName}.desktop`), + join(dirname(dir), `${baseName.toLowerCase()}.desktop`), + ].find((p) => existsSync(p)) ?? null; + + let name = baseName; + let description = ""; + let icon: string | null = null; + let url: string | null = null; + + if (desktopPath && existsSync(desktopPath)) { + try { + const parsed = parseDesktopFile(readFileSync(desktopPath, "utf8")); + if (parsed.name) name = parsed.name; + if (parsed.comment) description = parsed.comment; + if (parsed.icon) icon = parsed.icon; + if (parsed.exec) { + const urlMatch = parsed.exec.match(/https?:\/\/[^\s"']+/); + if (urlMatch) url = urlMatch[0]; + } + } catch { + // ignore parse errors + } + } + + let version = ""; + try { + const appDataPath = join(dirname(desktopPath ?? ""), "appdata", `${baseName}.appdata.xml`); + if (appDataPath && existsSync(appDataPath)) { + const parsed = parseAppDataXml(readFileSync(appDataPath, "utf8")); + if (parsed.summary) description = parsed.summary; + if (parsed.description) { + const verMatch = parsed.description.match(/(\d+\.(\d+\.)*\d+)/); + if (verMatch) version = verMatch[1]; + } + } + } catch { + // ignore + } + + return { + name, + path: dir, + version, + description, + icon, + url, + desktopPath, + configDir: null, + }; +} + +/** + * Discover installed AppImages in ~/Applications and return them as AptPackage entries. + */ +export async function fetchAppImages(): Promise { + const appsDir = getApplicationsDir(); + if (!existsSync(appsDir)) return []; + let entries: string[]; + try { + entries = readdirSync(appsDir); + } catch { + return []; + } + const packages: AptPackage[] = []; + for (const entry of entries) { + const fullPath = join(appsDir, entry); + if (!existsSync(fullPath)) continue; + const info = await discoverAppImageFile(fullPath); + if (!info) continue; + packages.push({ + name: info.name, + suite: "appimage", + version: info.version || "unknown", + arch: "", + flags: { + installed: true, + automatic: false, + upgradable: false, + local: true, + }, + current: null, + description: info.description, + manager: "appimage", + icon: info.icon, + }); + } + return packages.sort((a, b) => a.name.localeCompare(b.name)); +} + +export async function installAppImage( + source: { type: "url"; url: string } | { type: "file"; path: string }, +): Promise<{ ok: boolean; error: string | null }> { + const appsDir = getApplicationsDir(); + if (!existsSync(appsDir)) { + try { + mkdirSync(appsDir, { recursive: true }); + } catch { + return { ok: false, error: `Failed to create ${appsDir}` }; + } + } + let targetPath: string; + if (source.type === "url") { + const url = source.url; + const urlName = basename(url).split("?")[0] || "app.AppImage"; + targetPath = join(appsDir, urlName); + try { + const response = await fetch(url); + if (!response.ok) { + return { ok: false, error: `Download failed: ${response.status}` }; + } + const buffer = await response.arrayBuffer(); + const bytes = Buffer.from(buffer); + try { + const { writeFileSync } = await import("node:fs"); + writeFileSync(targetPath, bytes); + } catch { + const fs = await import("node:fs"); + await new Promise((resolve, reject) => { + const stream = fs.createWriteStream(targetPath); + stream.on("finish", () => resolve()); + stream.on("error", (e) => reject(e)); + stream.write(bytes); + stream.end(); + }); + } + try { + chmodSync(targetPath, 0o755); + } catch { + // chmod may fail silently + } + } catch (err) { + return { + ok: false, + error: `Download failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } + } else { + const srcPath = source.path; + if (!existsSync(srcPath)) { + return { ok: false, error: `File not found: ${srcPath}` }; + } + targetPath = join(appsDir, basename(srcPath)); + try { + copyFileSync(srcPath, targetPath); + try { + chmodSync(targetPath, 0o755); + } catch { + // chmod may fail silently + } + } catch (err) { + return { + ok: false, + error: `Copy failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } + } + return { ok: true, error: null }; +} + +export async function removeAppImage( + name: string, + depth: AppImageRemoveDepth, +): Promise<{ ok: boolean; error: string | null }> { + const appsDir = getApplicationsDir(); + if (!existsSync(appsDir)) { + return { ok: false, error: "Applications directory not found" }; + } + let targetPath: string | null = null; + let targetName: string | null = null; + for (const entry of readdirSync(appsDir)) { + const fullPath = join(appsDir, entry); + if (!existsSync(fullPath)) continue; + try { + const info = await discoverAppImageFile(fullPath); + if (info && info.name === name) { + targetPath = info.path; + targetName = info.name; + break; + } + } catch { + continue; + } + } + if (!targetPath || !targetName) { + return { ok: false, error: `AppImage "${name}" not found` }; + } + try { + if (depth === "file" || depth === "desktop" || depth === "config") { + rmSync(targetPath, { recursive: true, force: true }); + } + if (depth === "desktop") { + const desktopFile = targetPath.replace(/\.AppImage$/i, ".desktop"); + if (existsSync(desktopFile)) { + rmSync(desktopFile, { force: true }); + } + } + if (depth === "config") { + const configDir = join(homedir(), ".config", targetName); + if (existsSync(configDir)) { + rmSync(configDir, { recursive: true, force: true }); + } + } + } catch (err) { + return { + ok: false, + error: `Remove failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } + return { ok: true, error: null }; +} + +export async function checkAppImageUpdate( + name: string, +): Promise<{ ok: boolean; error: string | null; updateAvailable: boolean }> { + const appsDir = getApplicationsDir(); + if (!existsSync(appsDir)) { + return { ok: false, error: "Applications directory not found", updateAvailable: false }; + } + let targetPath: string | null = null; + let appUrl: string | null = null; + for (const entry of readdirSync(appsDir)) { + const fullPath = join(appsDir, entry); + if (!existsSync(fullPath)) continue; + try { + const info = await discoverAppImageFile(fullPath); + if (info && info.name === name) { + targetPath = info.path; + appUrl = info.url; + break; + } + } catch { + continue; + } + } + if (!targetPath) { + return { ok: false, error: `AppImage "${name}" not found`, updateAvailable: false }; + } + let localMtime = 0; + try { + localMtime = statSync(targetPath).mtimeMs; + } catch { + // ignore + } + if (!appUrl) { + return { ok: true, error: null, updateAvailable: false }; + } + try { + const response = await fetch(appUrl, { method: "HEAD" }); + if (!response.ok) { + return { ok: true, error: null, updateAvailable: false }; + } + const lastModified = response.headers.get("last-modified"); + if (lastModified) { + const remoteTime = new Date(lastModified).getTime(); + return { + ok: true, + error: null, + updateAvailable: remoteTime > localMtime, + }; + } + } catch { + // ignore network errors + } + return { ok: true, error: null, updateAvailable: false }; +} diff --git a/extensions/apt-manager/src/lib/apt.ts b/extensions/apt-manager/src/lib/apt.ts index 1093978b..433f6d65 100644 --- a/extensions/apt-manager/src/lib/apt.ts +++ b/extensions/apt-manager/src/lib/apt.ts @@ -7,6 +7,7 @@ import { fetchFlathubRemote, findFlatpakIcon, } from "./flathub"; +import { fetchAppImages } from "./appimage"; export type PackageFlags = { installed: boolean; @@ -25,7 +26,7 @@ export type AptPackage = { current: string | null; description: string; /** Package manager that owns this entry. */ - manager: "apt" | "flatpak"; + manager: "apt" | "flatpak" | "appimage"; /** Local path to an app icon, if one is available. */ icon: string | null; /** Installation scope for Flatpak apps. */ @@ -153,7 +154,8 @@ export async function fetchPackageList( const flatpakInstalled = (await fetchFlathubInstalled()).map((pkg) => flathubToAptPackage(pkg, true), ); - return [...aptInstalled, ...flatpakInstalled].sort((a, b) => + const appImages = await fetchAppImages(); + return [...aptInstalled, ...flatpakInstalled, ...appImages].sort((a, b) => a.name.localeCompare(b.name), ); } @@ -164,7 +166,8 @@ export async function fetchPackageList( const flatpakAvailable = (await fetchFlathubRemote()).map((pkg) => flathubToAptPackage(pkg, false), ); - return [...all, ...flatpakAvailable].sort((a, b) => + const appImages = await fetchAppImages(); + return [...all, ...flatpakAvailable, ...appImages].sort((a, b) => a.name.localeCompare(b.name), ); } diff --git a/extensions/apt-manager/src/views/InstallAppImageView.tsx b/extensions/apt-manager/src/views/InstallAppImageView.tsx new file mode 100644 index 00000000..5d02a978 --- /dev/null +++ b/extensions/apt-manager/src/views/InstallAppImageView.tsx @@ -0,0 +1,92 @@ +import { + Action, + ActionPanel, + Form, + Icon, + Toast, + showToast, + useNavigation, +} from "@vicinae/api"; +import { useState } from "react"; +import { installAppImage } from "../lib/appimage"; + +export function InstallAppImageView() { + const { pop } = useNavigation(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [sourceType, setSourceType] = useState<"url" | "file">("url"); + + const onSubmit = async (values: Form.Values) => { + const source = String(values.sourceType ?? "url") as "url" | "file"; + const input = String(values.input ?? "").trim(); + if (!input) { + await showToast({ + style: Toast.Style.Failure, + title: "A URL or file path is required", + }); + return; + } + setIsSubmitting(true); + const toast = await showToast({ + style: Toast.Style.Animated, + title: source === "url" ? "Downloading AppImage" : "Installing AppImage", + }); + const result = source === "url" + ? await installAppImage({ type: "url", url: input }) + : await installAppImage({ type: "file", path: input }); + setIsSubmitting(false); + + if (!result.ok) { + toast.style = Toast.Style.Failure; + toast.title = "Installation failed"; + toast.message = result.error ?? undefined; + return; + } + + toast.style = Toast.Style.Success; + toast.title = "AppImage installed"; + pop(); + }; + + return ( +
+ + + } + > + + + + + + + ); +} From 2b4b427a566a0a7c16813f068c663faf785df7b2 Mon Sep 17 00:00:00 2001 From: ali00209 Date: Wed, 23 Sep 2026 14:45:21 +0500 Subject: [PATCH 07/12] fix: auto-generate .desktop file after AppImage install --- extensions/apt-manager/src/lib/appimage.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/extensions/apt-manager/src/lib/appimage.ts b/extensions/apt-manager/src/lib/appimage.ts index 1f552f92..2b2b54d8 100644 --- a/extensions/apt-manager/src/lib/appimage.ts +++ b/extensions/apt-manager/src/lib/appimage.ts @@ -251,6 +251,18 @@ export async function installAppImage( }; } } + const desktopPath = targetPath.replace(/\.AppImage$/i, ".desktop"); + if (!existsSync(desktopPath)) { + const appName = basename(targetPath).replace(/\.AppImage$/i, ""); + const desktopContent = `[Desktop Entry]\nType=Application\nName=${appName}\nExec=${targetPath}\nIcon=${appName}\n`; + try { + const { writeFileSync } = await import("node:fs"); + writeFileSync(desktopPath, desktopContent); + } catch { + const fs = await import("node:fs"); + fs.writeFileSync(desktopPath, desktopContent); + } + } return { ok: true, error: null }; } From 0453453e65697e91ea8cd36e1a6b7eb448fd1afb Mon Sep 17 00:00:00 2001 From: ali00209 Date: Wed, 23 Sep 2026 14:57:12 +0500 Subject: [PATCH 08/12] fix: place .desktop file in user or system applications dir - User: ~/.local/share/applications/ (no privileges needed) - System: /usr/share/applications/ (via pkexec tee) - User prompted to choose location before installing - installAppImage returns targetPath for desktop file creation - installDesktopFile creates .desktop entry separately from binary --- extensions/apt-manager/src/lib/appimage.ts | 97 ++++++++++++------- .../src/views/InstallAppImageView.tsx | 41 ++++++-- 2 files changed, 97 insertions(+), 41 deletions(-) diff --git a/extensions/apt-manager/src/lib/appimage.ts b/extensions/apt-manager/src/lib/appimage.ts index 2b2b54d8..aa2a7452 100644 --- a/extensions/apt-manager/src/lib/appimage.ts +++ b/extensions/apt-manager/src/lib/appimage.ts @@ -7,9 +7,11 @@ import { readFileSync, rmSync, statSync, + writeFileSync, } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; +import { run } from "./exec"; import type { AptPackage } from "./apt"; export type AppImageRemoveDepth = "file" | "desktop" | "config"; @@ -29,6 +31,10 @@ function getApplicationsDir(): string { return join(homedir(), "Applications"); } +function getUserDesktopDir(): string { + return join(homedir(), ".local", "share", "applications"); +} + function parseDesktopFile(content: string): { name: string | null; comment: string | null; @@ -121,7 +127,11 @@ export async function discoverAppImageFile( let version = ""; try { - const appDataPath = join(dirname(desktopPath ?? ""), "appdata", `${baseName}.appdata.xml`); + const appDataPath = join( + dirname(desktopPath ?? ""), + "appdata", + `${baseName}.appdata.xml`, + ); if (appDataPath && existsSync(appDataPath)) { const parsed = parseAppDataXml(readFileSync(appDataPath, "utf8")); if (parsed.summary) description = parsed.summary; @@ -186,7 +196,7 @@ export async function fetchAppImages(): Promise { export async function installAppImage( source: { type: "url"; url: string } | { type: "file"; path: string }, -): Promise<{ ok: boolean; error: string | null }> { +): Promise<{ ok: boolean; error: string | null; targetPath?: string }> { const appsDir = getApplicationsDir(); if (!existsSync(appsDir)) { try { @@ -207,24 +217,8 @@ export async function installAppImage( } const buffer = await response.arrayBuffer(); const bytes = Buffer.from(buffer); - try { - const { writeFileSync } = await import("node:fs"); - writeFileSync(targetPath, bytes); - } catch { - const fs = await import("node:fs"); - await new Promise((resolve, reject) => { - const stream = fs.createWriteStream(targetPath); - stream.on("finish", () => resolve()); - stream.on("error", (e) => reject(e)); - stream.write(bytes); - stream.end(); - }); - } - try { - chmodSync(targetPath, 0o755); - } catch { - // chmod may fail silently - } + writeFileSync(targetPath, bytes); + chmodSync(targetPath, 0o755); } catch (err) { return { ok: false, @@ -239,11 +233,7 @@ export async function installAppImage( targetPath = join(appsDir, basename(srcPath)); try { copyFileSync(srcPath, targetPath); - try { - chmodSync(targetPath, 0o755); - } catch { - // chmod may fail silently - } + chmodSync(targetPath, 0o755); } catch (err) { return { ok: false, @@ -251,16 +241,42 @@ export async function installAppImage( }; } } - const desktopPath = targetPath.replace(/\.AppImage$/i, ".desktop"); - if (!existsSync(desktopPath)) { - const appName = basename(targetPath).replace(/\.AppImage$/i, ""); - const desktopContent = `[Desktop Entry]\nType=Application\nName=${appName}\nExec=${targetPath}\nIcon=${appName}\n`; - try { - const { writeFileSync } = await import("node:fs"); - writeFileSync(desktopPath, desktopContent); - } catch { - const fs = await import("node:fs"); - fs.writeFileSync(desktopPath, desktopContent); + return { ok: true, error: null, targetPath }; +} + +/** + * Create a .desktop file for an AppImage in the specified location. + */ +export async function installDesktopFile( + appPath: string, + location: "user" | "system", +): Promise<{ ok: boolean; error: string | null }> { + const appName = basename(appPath).replace(/\.AppImage$/i, ""); + const desktopContent = `[Desktop Entry]\nType=Application\nName=${appName}\nExec=${appPath}\nIcon=${appName}\n`; + let desktopPath: string; + + if (location === "user") { + const desktopDir = getUserDesktopDir(); + if (!existsSync(desktopDir)) { + try { + mkdirSync(desktopDir, { recursive: true }); + } catch { + return { ok: false, error: `Failed to create ${desktopDir}` }; + } + } + desktopPath = join(desktopDir, `${appName}.desktop`); + writeFileSync(desktopPath, desktopContent); + } else { + desktopPath = join("/usr/share/applications", `${appName}.desktop`); + const result = await run("pkexec", ["tee", desktopPath], { + input: desktopContent, + timeout: 30_000, + }); + if (!result.ok) { + return { + ok: false, + error: result.stderr.trim() || result.stdout.trim() || "Failed to write desktop file", + }; } } return { ok: true, error: null }; @@ -302,6 +318,17 @@ export async function removeAppImage( if (existsSync(desktopFile)) { rmSync(desktopFile, { force: true }); } + const userDesktop = join(getUserDesktopDir(), `${targetName}.desktop`); + if (existsSync(userDesktop)) { + rmSync(userDesktop, { force: true }); + } + const sysDesktop = join( + "/usr/share/applications", + `${targetName}.desktop`, + ); + if (existsSync(sysDesktop)) { + rmSync(sysDesktop, { force: true }); + } } if (depth === "config") { const configDir = join(homedir(), ".config", targetName); diff --git a/extensions/apt-manager/src/views/InstallAppImageView.tsx b/extensions/apt-manager/src/views/InstallAppImageView.tsx index 5d02a978..7016a715 100644 --- a/extensions/apt-manager/src/views/InstallAppImageView.tsx +++ b/extensions/apt-manager/src/views/InstallAppImageView.tsx @@ -8,15 +8,17 @@ import { useNavigation, } from "@vicinae/api"; import { useState } from "react"; -import { installAppImage } from "../lib/appimage"; +import { installAppImage, installDesktopFile } from "../lib/appimage"; export function InstallAppImageView() { const { pop } = useNavigation(); const [isSubmitting, setIsSubmitting] = useState(false); const [sourceType, setSourceType] = useState<"url" | "file">("url"); + const [desktopLocation, setDesktopLocation] = useState<"user" | "system">("user"); const onSubmit = async (values: Form.Values) => { const source = String(values.sourceType ?? "url") as "url" | "file"; + const location = String(values.location ?? "user") as "user" | "system"; const input = String(values.input ?? "").trim(); if (!input) { await showToast({ @@ -33,17 +35,30 @@ export function InstallAppImageView() { const result = source === "url" ? await installAppImage({ type: "url", url: input }) : await installAppImage({ type: "file", path: input }); - setIsSubmitting(false); - if (!result.ok) { toast.style = Toast.Style.Failure; toast.title = "Installation failed"; toast.message = result.error ?? undefined; + setIsSubmitting(false); return; } - - toast.style = Toast.Style.Success; - toast.title = "AppImage installed"; + const desktopToast = await showToast({ + style: Toast.Style.Animated, + title: "Creating desktop entry", + }); + const desktopResult = result.targetPath + ? await installDesktopFile(result.targetPath, location) + : { ok: false, error: "No target path" }; + if (!desktopResult.ok) { + toast.style = Toast.Style.Failure; + toast.title = "AppImage installed but desktop entry failed"; + toast.message = desktopResult.error ?? undefined; + } else { + toast.style = Toast.Style.Success; + toast.title = "AppImage installed"; + toast.message = `Installed in ${location} desktop entry`; + } + setIsSubmitting(false); pop(); }; @@ -72,6 +87,20 @@ export function InstallAppImageView() { /> + + + + Date: Fri, 25 Sep 2026 13:40:35 +0500 Subject: [PATCH 09/12] fix(apt-manager): install .deb/.flatpak/.AppImage files, address review findings - Install Package dialog detects extension and dispatches: .AppImage copy with metadata extraction, .deb via apt-get, .flatpak via flatpak (with explicit installation scope flag) - Only install AppImages from user-provided local files (no arbitrary URL downloads); drop URL-dependent update check - Harden removeAppImage: remove system desktop entries via pkexec before deleting the binary, cover both desktop/config depths - Surface failed metadata extraction as a warning instead of silent success - Handle user desktop-entry write failures; make repo pkexec errors describe an available recovery path instead of a nonexistent retry action - Complete terminal retries for multi-stage update/cleanup operations - Inspect runAptUpdate() result in AddRepo and show failure feedback - Order package actions so AppImages/Flatpaks never reach apt actions --- extensions/apt-manager/README.md | 9 +- extensions/apt-manager/src/apt.tsx | 367 ++++++----- .../src/components/PackageActions.tsx | 156 ++--- .../src/components/PackageView.tsx | 2 +- .../apt-manager/src/components/RunResult.tsx | 12 +- extensions/apt-manager/src/lib/appimage.ts | 607 ++++++++++++------ extensions/apt-manager/src/lib/apt.ts | 2 + extensions/apt-manager/src/lib/exec.ts | 5 + .../apt-manager/src/lib/flatpakRemotes.ts | 12 +- extensions/apt-manager/src/lib/sources.ts | 4 +- extensions/apt-manager/src/views/AddRepo.tsx | 20 +- .../src/views/InstallAppImageView.tsx | 121 ---- .../src/views/InstallPackageView.tsx | 243 +++++++ 13 files changed, 946 insertions(+), 614 deletions(-) delete mode 100644 extensions/apt-manager/src/views/InstallAppImageView.tsx create mode 100644 extensions/apt-manager/src/views/InstallPackageView.tsx diff --git a/extensions/apt-manager/README.md b/extensions/apt-manager/README.md index 854f8eea..0e02e833 100644 --- a/extensions/apt-manager/README.md +++ b/extensions/apt-manager/README.md @@ -13,8 +13,13 @@ Launched from the `apt` command: - **Clean up system** — `apt-get autoremove --purge` then `apt-get autoclean`. - **Repositories** — list sources from `sources.list`/`sources.list.d` (legacy one-line and deb822 `.sources`), add new repos as deb822 `.sources` files, enable/disable/remove. -- **AppImages** — install, remove, and check for updates of AppImage applications - installed in `~/Applications`. +- **Install package files** — install a local `.deb` (via `apt`), `.flatpak` + (via `flatpak`), or `.AppImage` file from one dialog. The extension detects + the extension and runs the matching method. +- **AppImages** — install (from a local file) and remove AppImage applications + installed in `~/Applications`. Download an AppImage yourself, then point the + extension at the file; it copies it into `~/Applications` and extracts its + icon and metadata for the launcher entry. ## How it works diff --git a/extensions/apt-manager/src/apt.tsx b/extensions/apt-manager/src/apt.tsx index 157fb658..a7ddd64f 100644 --- a/extensions/apt-manager/src/apt.tsx +++ b/extensions/apt-manager/src/apt.tsx @@ -1,122 +1,122 @@ import { - Action, - ActionPanel, - Icon, - type Keyboard, - List, - showToast, - Toast, - useNavigation, + Action, + ActionPanel, + Icon, + type Keyboard, + List, + showToast, + Toast, + useNavigation, } from "@vicinae/api"; import type { ReactNode } from "react"; import { useEffect, useMemo, useState } from "react"; import { PackageView } from "./components/PackageView"; import { RunResult } from "./components/RunResult"; import { - type OperationResult, - runAptCleanup, - runAptUpgradeAll, + type OperationResult, + runAptCleanup, + runAptUpgradeAll, } from "./lib/apt"; import { askConfirm } from "./lib/confirm"; import { fetchFlatpakRemotes, hasFlatpakBinary } from "./lib/flatpakRemotes"; import { loadRepos } from "./lib/sources"; -import { installAppImage } from "./lib/appimage"; import { ReposView } from "./views/Repos"; import { FlatpakReposView } from "./views/FlatpakRepos"; -import { InstallAppImageView } from "./views/InstallAppImageView"; +import { InstallPackageView } from "./views/InstallPackageView"; export default function Command() { - const repoCount = useMemo(() => loadRepos().sources.length, []); - const flatpakAvailable = useMemo(() => hasFlatpakBinary(), []); - const [flatpakRepoCount, setFlatpakRepoCount] = useState(null); - const runAndShow = usePrivilegedRunner(); + const repoCount = useMemo(() => loadRepos().sources.length, []); + const flatpakAvailable = useMemo(() => hasFlatpakBinary(), []); + const [flatpakRepoCount, setFlatpakRepoCount] = useState(null); + const runAndShow = usePrivilegedRunner(); - useEffect(() => { - if (!flatpakAvailable) return; - let cancelled = false; - fetchFlatpakRemotes().then((result) => { - if (!cancelled) setFlatpakRepoCount(result.remotes.length); - }); - return () => { - cancelled = true; - }; - }, [flatpakAvailable]); + useEffect(() => { + if (!flatpakAvailable) return; + let cancelled = false; + fetchFlatpakRemotes().then((result) => { + if (!cancelled) setFlatpakRepoCount(result.remotes.length); + }); + return () => { + cancelled = true; + }; + }, [flatpakAvailable]); - return ( - - - - } - /> - } - /> - - } - /> - } - /> - - } - /> - } - /> - + + + } + /> + } + /> + + } + /> + } + /> + + + } + /> + } + /> +} + title="Install Package" + target={} /> } /> - - - + + } @@ -148,90 +162,99 @@ export default function Command() { runAndShow( runAptCleanup, "Clean up system", - ["autoremove", "-y", "--purge"], + undefined, "Run autoremove --purge and autoclean?", + { + label: "Retry Cleanup in Terminal (sudo)", + args: [ + "sudo", + "bash", + "-c", + "apt-get autoremove -y --purge && apt-get autoclean", + ], + }, ) } /> - - - } /> - } - /> - {flatpakAvailable && ( - } - /> - } - /> - )} - - - ); +
+ + } /> + } + /> + {flatpakAvailable && ( + } + /> + } + /> + )} + + + ); } type RootItemProps = { - title: string; - subtitle?: string; - icon: Icon; - shortcut: Keyboard.Shortcut; - actions?: ReactNode; - extraActions?: ReactNode; - destructive?: boolean; - onAction?: () => void; + title: string; + subtitle?: string; + icon: Icon; + shortcut: Keyboard.Shortcut; + actions?: ReactNode; + extraActions?: ReactNode; + destructive?: boolean; + onAction?: () => void; }; function RootItem({ - title, - subtitle, - icon, - shortcut, - actions, - extraActions, - destructive, - onAction, + title, + subtitle, + icon, + shortcut, + actions, + extraActions, + destructive, + onAction, }: RootItemProps) { - return ( - - {actions ?? - (onAction ? ( - - ) : null)} - {extraActions} - - } - /> - ); + return ( + + {actions ?? + (onAction ? ( + + ) : null)} + {extraActions} + + } + /> + ); } function usePrivilegedRunner() { @@ -239,8 +262,9 @@ function usePrivilegedRunner() { return async ( run: () => Promise, heading: string, - sudoArgs: string[], + sudoArgs: string[] | undefined, confirmMessage: string | null, + terminalRetry?: { label: string; args: string[] }, ) => { if (confirmMessage) { const confirmed = await askConfirm(heading, confirmMessage); @@ -267,6 +291,7 @@ function usePrivilegedRunner() { title={heading} result={result} sudoArgs={sudoArgs} + terminalRetry={terminalRetry} />, ); }; diff --git a/extensions/apt-manager/src/components/PackageActions.tsx b/extensions/apt-manager/src/components/PackageActions.tsx index fd844785..bc2fb68e 100644 --- a/extensions/apt-manager/src/components/PackageActions.tsx +++ b/extensions/apt-manager/src/components/PackageActions.tsx @@ -8,9 +8,12 @@ import { useNavigation, } from "@vicinae/api"; import type { AptPackage, PackageListKind } from "../lib/apt"; -import { runFlatpakCommand } from "../lib/flatpakRemotes"; +import { + flatpakInstallationFlag, + runFlatpakCommand, +} from "../lib/flatpakRemotes"; import { runPrivilegedApGet, runPrivilegedCommand } from "../lib/apt"; -import { checkAppImageUpdate, removeAppImage } from "../lib/appimage"; +import { removeAppImage } from "../lib/appimage"; import type { FlatpakInstallation } from "../lib/flatpakRemotes"; import { askConfirm } from "../lib/confirm"; import { RunResult } from "./RunResult"; @@ -112,7 +115,12 @@ export function PackageActions({ onAction={() => runPrivileged( "flatpak", - ["uninstall", "-y", pkg.name], + [ + "uninstall", + flatpakInstallationFlag(pkg.installation ?? "system"), + "-y", + pkg.name, + ], `Remove ${pkg.name}`, `Remove the Flatpak application ${pkg.name}?`, pkg.installation, @@ -126,7 +134,13 @@ export function PackageActions({ onAction={() => runPrivileged( "flatpak", - ["install", "-y", "flathub", pkg.name], + [ + "install", + flatpakInstallationFlag(pkg.installation ?? "system"), + "-y", + "flathub", + pkg.name, + ], `Install ${pkg.name}`, null, pkg.installation, @@ -139,7 +153,40 @@ export function PackageActions({ ); } - if (kind === "installed") { + if (pkg.manager === "appimage") { + return ( + + {toggleDetail} + { + const toast = await showToast({ + style: Toast.Style.Animated, + title: `Removing ${pkg.name}`, + }); + const appImageId = pkg.fileName ?? pkg.name; + const result = await removeAppImage(appImageId, "desktop"); + if (result.ok) { + toast.style = Toast.Style.Success; + toast.title = `${pkg.name} removed`; + toast.message = "Application and desktop entry removed"; + } else { + toast.style = Toast.Style.Failure; + toast.title = `Failed to remove ${pkg.name}`; + toast.message = result.error ?? undefined; + } + onRefresh(); + }} + /> + {copyName} + + ); + } + + if (kind === "installed" && pkg.manager === "apt") { return ( {toggleDetail} @@ -174,7 +221,7 @@ export function PackageActions({ ); } - if (kind === "upgradable") { + if (kind === "upgradable" && pkg.manager === "apt") { return ( {toggleDetail} @@ -195,103 +242,6 @@ export function PackageActions({ ); } - if (pkg.manager === "appimage") { - return ( - - {toggleDetail} - { - const toast = await showToast({ - style: Toast.Style.Animated, - title: `Removing ${pkg.name}`, - }); - const result = await removeAppImage(pkg.name, "file"); - if (result.ok) { - toast.style = Toast.Style.Success; - toast.title = `${pkg.name} removed`; - toast.message = "File removed"; - } else { - toast.style = Toast.Style.Failure; - toast.title = `Failed to remove ${pkg.name}`; - toast.message = result.error ?? undefined; - } - onRefresh(); - }} - /> - { - const toast = await showToast({ - style: Toast.Style.Animated, - title: `Removing ${pkg.name}`, - }); - const result = await removeAppImage(pkg.name, "desktop"); - if (result.ok) { - toast.style = Toast.Style.Success; - toast.title = `${pkg.name} removed`; - toast.message = "File and desktop entry removed"; - } else { - toast.style = Toast.Style.Failure; - toast.title = `Failed to remove ${pkg.name}`; - toast.message = result.error ?? undefined; - } - onRefresh(); - }} - /> - { - const toast = await showToast({ - style: Toast.Style.Animated, - title: `Removing ${pkg.name}`, - }); - const result = await removeAppImage(pkg.name, "config"); - if (result.ok) { - toast.style = Toast.Style.Success; - toast.title = `${pkg.name} removed`; - toast.message = "File, desktop entry, and config removed"; - } else { - toast.style = Toast.Style.Failure; - toast.title = `Failed to remove ${pkg.name}`; - toast.message = result.error ?? undefined; - } - onRefresh(); - }} - /> - { - const toast = await showToast({ - style: Toast.Style.Animated, - title: `Checking for updates`, - }); - const result = await checkAppImageUpdate(pkg.name); - if (!result.ok) { - toast.style = Toast.Style.Failure; - toast.title = `Failed to check for updates`; - toast.message = result.error ?? undefined; - } else if (result.updateAvailable) { - toast.style = Toast.Style.Success; - toast.title = `Update available for ${pkg.name}`; - } else { - toast.style = Toast.Style.Success; - toast.title = `${pkg.name} is up to date`; - } - }} - /> - {copyName} - - ); - } - return ( {toggleDetail} diff --git a/extensions/apt-manager/src/components/PackageView.tsx b/extensions/apt-manager/src/components/PackageView.tsx index 473214fb..d8842659 100644 --- a/extensions/apt-manager/src/components/PackageView.tsx +++ b/extensions/apt-manager/src/components/PackageView.tsx @@ -90,7 +90,7 @@ export function PackageView({ kind, title, emptyTitle }: Props) { /> ) : ( filtered.slice(0, visible).map((pkg) => { - const id = `${pkg.name}/${pkg.arch}`; + const id = pkg.fileName ?? `${pkg.name}/${pkg.arch}`; return ( - {sudoArgs && sudoArgs.length > 0 ? ( + {terminalRetry ? ( + + ) : sudoArgs && sudoArgs.length > 0 ? ( | null { + const manifestPath = getManifestPath(appPath); + if (!existsSync(manifestPath)) return null; + try { + const raw = JSON.parse(readFileSync(manifestPath, "utf8")) as Partial; + return typeof raw === "object" && raw !== null ? raw : null; + } catch { + return null; + } +} + +function writeManifest( + appPath: string, + manifest: AppImageManifest, +): void { + writeFileSync(getManifestPath(appPath), `${JSON.stringify(manifest, null, 2)}\n`); +} + function parseDesktopFile(content: string): { name: string | null; comment: string | null; - icon: string | null; - exec: string | null; } { let name: string | null = null; let comment: string | null = null; - let icon: string | null = null; - let exec: string | null = null; let inSection = false; for (const line of content.split("\n")) { const trimmed = line.trim(); @@ -58,30 +99,20 @@ function parseDesktopFile(content: string): { if (eq === -1) continue; const key = trimmed.slice(0, eq).trim().toLowerCase(); const value = trimmed.slice(eq + 1).trim(); - switch (key) { - case "name": - name = value; - break; - case "comment": - comment = value; - break; - case "icon": - icon = value; - break; - case "exec": - exec = value; - break; - } + if (key === "name") name = value; + if (key === "comment") comment = value; } - return { name, comment, icon, exec }; + return { name, comment }; } function parseAppDataXml(content: string): { summary: string | null; description: string | null; + url: string | null; } { let summary: string | null = null; let description: string | null = null; + let url: string | null = null; const summaryMatch = content.match(/]*>([\s\S]*?)<\/summary>/i); if (summaryMatch) summary = summaryMatch[1].trim(); const descMatch = content.match(/]*>([\s\S]*?)<\/description>/i); @@ -89,70 +120,282 @@ function parseAppDataXml(content: string): { const inner = descMatch[1].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); description = inner || null; } - return { summary, description }; + const urlMatch = + content.match(/]*type="homepage"[^>]*>\s*([^<]+?)\s*<\/url>/i) ?? + content.match(/]*>\s*([^<]+?)\s*<\/url>/i); + if (urlMatch) url = urlMatch[1].trim(); + return { summary, description, url }; +} + +function readPngDimensions(buffer: Buffer): { width: number; height: number } | null { + if (buffer.length < 24 || buffer.readUInt32BE(12) !== 0x49484452) return null; + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; +} + +/** Find the largest PNG under the icon resource directories of an extracted AppImage. */ +function pickLargestIcon(root: string): string | null { + let best: string | null = null; + let bestArea = 0; + const scan = (dir: string) => { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const entry of entries) { + const full = join(dir, entry); + let isDirectory = false; + let isFile = false; + try { + const stat = statSync(full); + isDirectory = stat.isDirectory(); + isFile = stat.isFile(); + } catch { + continue; + } + if (isDirectory) { + scan(full); + } else if (isFile && entry.toLowerCase().endsWith(".png")) { + let buffer: Buffer; + try { + buffer = readFileSync(full); + } catch { + continue; + } + const dims = readPngDimensions(buffer); + if (dims) { + const area = dims.width * dims.height; + if (area > bestArea) { + bestArea = area; + best = full; + } + } + } + } + }; + scan(join(root, "usr", "share", "icons")); + scan(join(root, "usr", "share", "pixmaps")); + if (best) return best; + const dirIcon = join(root, ".DirIcon"); + if (existsSync(dirIcon)) { + try { + const resolved = realpathSync(dirIcon); + if (resolved.toLowerCase().endsWith(".png") && statSync(resolved).isFile()) { + return resolved; + } + } catch { + // ignore + } + } + return null; +} + +function findRootDesktop(root: string): string | null { + let entries: string[]; + try { + entries = readdirSync(root); + } catch { + return null; + } + for (const entry of entries) { + if (entry.startsWith(".") || !entry.endsWith(".desktop")) continue; + const full = join(root, entry); + try { + if (statSync(full).isFile()) return full; + } catch { + // ignore + } + } + return null; +} + +function findMetaInfo(root: string): string | null { + for (const dir of [join(root, "usr", "share", "metainfo"), join(root, "usr", "share", "appdata")]) { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + continue; + } + for (const entry of entries) { + if (/\.(metainfo|appdata)\.xml$/i.test(entry)) { + const full = join(dir, entry); + if (statSync(full).isFile()) return full; + } + } + } + return null; +} + +/** + * Best-effort metadata extraction: run the AppImage's embedded runtime, then + * persist the .desktop, metainfo, icon and a manifest into the sidecar. + * Never fails the install and never leaves temp files behind. + * Resolves true if usable metadata was extracted (icon, name or metainfo). + */ +async function extractAppImageSidecar(appPath: string): Promise { + const sidecar = getSidecarDir(appPath); + const fileName = basename(appPath); + const baseName = fileName.replace(/\.appimage$/i, ""); + const tempDir = mkdtempSync(join(tmpdir(), "appimage-extract-")); + try { + const result = await run(appPath, ["--appimage-extract"], { + cwd: tempDir, + timeout: EXTRACT_TIMEOUT, + }); + if (!result.ok) return false; + const root = join(tempDir, "squashfs-root"); + if (!existsSync(root)) return false; + + let name: string | null = null; + let comment: string | null = null; + let description: string | null = null; + let metaUrl: string | null = null; + let metaInfoPath: string | null = null; + + const desktopFile = findRootDesktop(root); + if (desktopFile) { + const parsed = parseDesktopFile(readFileSync(desktopFile, "utf8")); + name = parsed.name; + comment = parsed.comment; + } + metaInfoPath = findMetaInfo(root); + if (metaInfoPath) { + const xml = readFileSync(metaInfoPath, "utf8"); + const parsed = parseAppDataXml(xml); + if (parsed.summary) description = parsed.summary; + if (parsed.description) description = parsed.description; + if (parsed.url) metaUrl = parsed.url; + } + const iconSrc = pickLargestIcon(root); + + if (!iconSrc && !name && !comment && !metaInfoPath) return false; + + mkdirSync(sidecar, { recursive: true }); + let iconPath: string | null = null; + if (iconSrc) { + iconPath = join(sidecar, `icon${extname(iconSrc).toLowerCase()}`); + copyFileSync(iconSrc, iconPath); + } + if (metaInfoPath) { + copyFileSync(metaInfoPath, join(sidecar, "app.metainfo.xml")); + } + writeFileSync( + join(sidecar, "app.desktop"), + buildDesktopContent(appPath, baseName, { + name: name || baseName, + description: comment || description || "", + icon: iconPath, + }), + ); + writeManifest(appPath, { + url: metaUrl, + installedAt: new Date().toISOString(), + }); + return true; + } catch { + // best-effort + return false; + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +function buildDesktopContent( + appPath: string, + baseName: string, + info: Pick, +): string { + const exec = appPath.includes(" ") ? `"${appPath}"` : appPath; + const lines = [ + "[Desktop Entry]", + "Type=Application", + `Name=${info.name || baseName}`, + `Exec=${exec}`, + `Icon=${info.icon || baseName}`, + "Terminal=false", + "Categories=Utility;", + ]; + if (info.description) lines.push(`Comment=${info.description}`); + return `${lines.join("\n")}\n`; } export async function discoverAppImageFile( dir: string, ): Promise { - const lowerName = basename(dir).toLowerCase(); - if (!lowerName.endsWith(".appimage")) return null; - if (!existsSync(dir)) return null; + const fileName = basename(dir); + if (!fileName.toLowerCase().endsWith(".appimage")) return null; + try { + if (!statSync(dir).isFile()) return null; + } catch { + return null; + } - const baseName = basename(dir, ".AppImage"); - const desktopPath = [ - join(dirname(dir), `${baseName}.desktop`), - join(dirname(dir), `${baseName.toLowerCase()}.desktop`), - ].find((p) => existsSync(p)) ?? null; + const baseName = fileName.replace(/\.appimage$/i, ""); + const sidecar = getSidecarDir(dir); + const sidecarExists = existsSync(sidecar); let name = baseName; let description = ""; let icon: string | null = null; let url: string | null = null; + let desktopPath: string | null = null; - if (desktopPath && existsSync(desktopPath)) { - try { - const parsed = parseDesktopFile(readFileSync(desktopPath, "utf8")); - if (parsed.name) name = parsed.name; - if (parsed.comment) description = parsed.comment; - if (parsed.icon) icon = parsed.icon; - if (parsed.exec) { - const urlMatch = parsed.exec.match(/https?:\/\/[^\s"']+/); - if (urlMatch) url = urlMatch[0]; + if (sidecarExists) { + const manifest = readManifest(dir); + if (manifest?.url) url = manifest.url; + const sideDesktop = join(sidecar, "app.desktop"); + if (existsSync(sideDesktop)) { + desktopPath = sideDesktop; + try { + const parsed = parseDesktopFile(readFileSync(sideDesktop, "utf8")); + if (parsed.name) name = parsed.name; + if (parsed.comment) description = parsed.comment; + } catch { + // ignore parse errors + } + } + const sideMeta = join(sidecar, "app.metainfo.xml"); + if (existsSync(sideMeta)) { + try { + const parsed = parseAppDataXml(readFileSync(sideMeta, "utf8")); + if (parsed.summary) description = parsed.summary; + if (parsed.description) description = parsed.description; + if (parsed.url && !url) url = parsed.url; + } catch { + // ignore parse errors } - } catch { - // ignore parse errors } + const iconPng = join(sidecar, "icon.png"); + const iconSvg = join(sidecar, "icon.svg"); + if (existsSync(iconPng)) icon = iconPng; + else if (existsSync(iconSvg)) icon = null; } - let version = ""; - try { - const appDataPath = join( - dirname(desktopPath ?? ""), - "appdata", - `${baseName}.appdata.xml`, - ); - if (appDataPath && existsSync(appDataPath)) { - const parsed = parseAppDataXml(readFileSync(appDataPath, "utf8")); - if (parsed.summary) description = parsed.summary; - if (parsed.description) { - const verMatch = parsed.description.match(/(\d+\.(\d+\.)*\d+)/); - if (verMatch) version = verMatch[1]; + if (!desktopPath) { + const siblingDesktop = join(dirname(dir), `${baseName}.desktop`); + if (existsSync(siblingDesktop)) { + desktopPath = siblingDesktop; + try { + const parsed = parseDesktopFile(readFileSync(siblingDesktop, "utf8")); + if (parsed.name) name = parsed.name; + if (parsed.comment) description = parsed.comment; + } catch { + // ignore parse errors } } - } catch { - // ignore } return { + fileName, name, path: dir, - version, + version: parseVersionFromFileName(fileName), description, icon, url, desktopPath, - configDir: null, }; } @@ -162,20 +405,15 @@ export async function discoverAppImageFile( export async function fetchAppImages(): Promise { const appsDir = getApplicationsDir(); if (!existsSync(appsDir)) return []; - let entries: string[]; - try { - entries = readdirSync(appsDir); - } catch { - return []; - } const packages: AptPackage[] = []; - for (const entry of entries) { + for (const entry of readdirSync(appsDir)) { + if (!entry.toLowerCase().endsWith(".appimage")) continue; const fullPath = join(appsDir, entry); - if (!existsSync(fullPath)) continue; const info = await discoverAppImageFile(fullPath); if (!info) continue; packages.push({ name: info.name, + fileName: info.fileName, suite: "appimage", version: info.version || "unknown", arch: "", @@ -194,9 +432,22 @@ export async function fetchAppImages(): Promise { return packages.sort((a, b) => a.name.localeCompare(b.name)); } +export type AppImageInstallResult = { + ok: boolean; + error: string | null; + targetPath?: string; + info?: AppImageInfo; + hasMetadata?: boolean; +}; + +/** + * Install an AppImage from a local file by copying it into ~/Applications and + * extracting its icon/metadata into a sidecar directory. Downloads are not + * performed by this extension; users obtain the AppImage themselves. + */ export async function installAppImage( - source: { type: "url"; url: string } | { type: "file"; path: string }, -): Promise<{ ok: boolean; error: string | null; targetPath?: string }> { + path: string, +): Promise { const appsDir = getApplicationsDir(); if (!existsSync(appsDir)) { try { @@ -205,43 +456,29 @@ export async function installAppImage( return { ok: false, error: `Failed to create ${appsDir}` }; } } - let targetPath: string; - if (source.type === "url") { - const url = source.url; - const urlName = basename(url).split("?")[0] || "app.AppImage"; - targetPath = join(appsDir, urlName); - try { - const response = await fetch(url); - if (!response.ok) { - return { ok: false, error: `Download failed: ${response.status}` }; - } - const buffer = await response.arrayBuffer(); - const bytes = Buffer.from(buffer); - writeFileSync(targetPath, bytes); - chmodSync(targetPath, 0o755); - } catch (err) { - return { - ok: false, - error: `Download failed: ${err instanceof Error ? err.message : String(err)}`, - }; - } - } else { - const srcPath = source.path; - if (!existsSync(srcPath)) { - return { ok: false, error: `File not found: ${srcPath}` }; - } - targetPath = join(appsDir, basename(srcPath)); - try { - copyFileSync(srcPath, targetPath); - chmodSync(targetPath, 0o755); - } catch (err) { - return { - ok: false, - error: `Copy failed: ${err instanceof Error ? err.message : String(err)}`, - }; - } + if (!existsSync(path)) { + return { ok: false, error: `File not found: ${path}` }; + } + const targetPath = join(appsDir, basename(path)); + try { + copyFileSync(path, targetPath); + chmodSync(targetPath, 0o755); + } catch (err) { + return { + ok: false, + error: `Copy failed: ${err instanceof Error ? err.message : String(err)}`, + }; } - return { ok: true, error: null, targetPath }; + + const hasMetadata = await extractAppImageSidecar(targetPath); + const info = await discoverAppImageFile(targetPath); + return { + ok: true, + error: null, + targetPath, + info: info ?? undefined, + hasMetadata, + }; } /** @@ -250,9 +487,15 @@ export async function installAppImage( export async function installDesktopFile( appPath: string, location: "user" | "system", + info?: AppImageInfo, ): Promise<{ ok: boolean; error: string | null }> { - const appName = basename(appPath).replace(/\.AppImage$/i, ""); - const desktopContent = `[Desktop Entry]\nType=Application\nName=${appName}\nExec=${appPath}\nIcon=${appName}\n`; + const baseName = basename(appPath).replace(/\.appimage$/i, ""); + const infoForEntry: Pick = { + name: info?.name ?? baseName, + description: info?.description ?? "", + icon: info?.icon ?? null, + }; + const desktopContent = buildDesktopContent(appPath, baseName, infoForEntry); let desktopPath: string; if (location === "user") { @@ -260,14 +503,24 @@ export async function installDesktopFile( if (!existsSync(desktopDir)) { try { mkdirSync(desktopDir, { recursive: true }); - } catch { - return { ok: false, error: `Failed to create ${desktopDir}` }; + } catch (err) { + return { + ok: false, + error: `Failed to create ${desktopDir}: ${err instanceof Error ? err.message : String(err)}`, + }; } } - desktopPath = join(desktopDir, `${appName}.desktop`); - writeFileSync(desktopPath, desktopContent); + desktopPath = join(desktopDir, `${baseName}.desktop`); + try { + writeFileSync(desktopPath, desktopContent); + } catch (err) { + return { + ok: false, + error: `Failed to write desktop file: ${err instanceof Error ? err.message : String(err)}`, + }; + } } else { - desktopPath = join("/usr/share/applications", `${appName}.desktop`); + desktopPath = join("/usr/share/applications", `${baseName}.desktop`); const result = await run("pkexec", ["tee", desktopPath], { input: desktopContent, timeout: 30_000, @@ -283,7 +536,7 @@ export async function installDesktopFile( } export async function removeAppImage( - name: string, + fileName: string, depth: AppImageRemoveDepth, ): Promise<{ ok: boolean; error: string | null }> { const appsDir = getApplicationsDir(); @@ -291,50 +544,47 @@ export async function removeAppImage( return { ok: false, error: "Applications directory not found" }; } let targetPath: string | null = null; - let targetName: string | null = null; for (const entry of readdirSync(appsDir)) { - const fullPath = join(appsDir, entry); - if (!existsSync(fullPath)) continue; - try { - const info = await discoverAppImageFile(fullPath); - if (info && info.name === name) { - targetPath = info.path; - targetName = info.name; - break; - } - } catch { - continue; + if (!entry.toLowerCase().endsWith(".appimage")) continue; + if (entry === fileName || entry.toLowerCase() === fileName.toLowerCase()) { + targetPath = join(appsDir, entry); + break; } } - if (!targetPath || !targetName) { - return { ok: false, error: `AppImage "${name}" not found` }; + if (!targetPath) { + return { ok: false, error: `AppImage "${fileName}" not found` }; } - try { - if (depth === "file" || depth === "desktop" || depth === "config") { - rmSync(targetPath, { recursive: true, force: true }); + const baseName = basename(targetPath).replace(/\.appimage$/i, ""); + const sysDesktop = join("/usr/share/applications", `${baseName}.desktop`); + if ( + (depth === "desktop" || depth === "config") && + existsSync(sysDesktop) + ) { + const result = await run("pkexec", ["rm", "-f", sysDesktop], { + timeout: 60_000, + }); + if (!result.ok) { + return { + ok: false, + error: + result.stderr.trim() || + result.stdout.trim() || + "Failed to remove the system desktop entry", + }; } - if (depth === "desktop") { - const desktopFile = targetPath.replace(/\.AppImage$/i, ".desktop"); - if (existsSync(desktopFile)) { - rmSync(desktopFile, { force: true }); - } - const userDesktop = join(getUserDesktopDir(), `${targetName}.desktop`); - if (existsSync(userDesktop)) { - rmSync(userDesktop, { force: true }); - } - const sysDesktop = join( - "/usr/share/applications", - `${targetName}.desktop`, - ); - if (existsSync(sysDesktop)) { - rmSync(sysDesktop, { force: true }); - } + } + try { + rmSync(targetPath, { recursive: true, force: true }); + rmSync(getSidecarDir(targetPath), { recursive: true, force: true }); + if (depth === "desktop" || depth === "config") { + const siblingDesktop = join(appsDir, `${baseName}.desktop`); + if (existsSync(siblingDesktop)) rmSync(siblingDesktop, { force: true }); + const userDesktop = join(getUserDesktopDir(), `${baseName}.desktop`); + if (existsSync(userDesktop)) rmSync(userDesktop, { force: true }); } if (depth === "config") { - const configDir = join(homedir(), ".config", targetName); - if (existsSync(configDir)) { - rmSync(configDir, { recursive: true, force: true }); - } + const configDir = join(homedir(), ".config", baseName); + if (existsSync(configDir)) rmSync(configDir, { recursive: true, force: true }); } } catch (err) { return { @@ -343,59 +593,4 @@ export async function removeAppImage( }; } return { ok: true, error: null }; -} - -export async function checkAppImageUpdate( - name: string, -): Promise<{ ok: boolean; error: string | null; updateAvailable: boolean }> { - const appsDir = getApplicationsDir(); - if (!existsSync(appsDir)) { - return { ok: false, error: "Applications directory not found", updateAvailable: false }; - } - let targetPath: string | null = null; - let appUrl: string | null = null; - for (const entry of readdirSync(appsDir)) { - const fullPath = join(appsDir, entry); - if (!existsSync(fullPath)) continue; - try { - const info = await discoverAppImageFile(fullPath); - if (info && info.name === name) { - targetPath = info.path; - appUrl = info.url; - break; - } - } catch { - continue; - } - } - if (!targetPath) { - return { ok: false, error: `AppImage "${name}" not found`, updateAvailable: false }; - } - let localMtime = 0; - try { - localMtime = statSync(targetPath).mtimeMs; - } catch { - // ignore - } - if (!appUrl) { - return { ok: true, error: null, updateAvailable: false }; - } - try { - const response = await fetch(appUrl, { method: "HEAD" }); - if (!response.ok) { - return { ok: true, error: null, updateAvailable: false }; - } - const lastModified = response.headers.get("last-modified"); - if (lastModified) { - const remoteTime = new Date(lastModified).getTime(); - return { - ok: true, - error: null, - updateAvailable: remoteTime > localMtime, - }; - } - } catch { - // ignore network errors - } - return { ok: true, error: null, updateAvailable: false }; -} +} \ No newline at end of file diff --git a/extensions/apt-manager/src/lib/apt.ts b/extensions/apt-manager/src/lib/apt.ts index 433f6d65..bfcb77a1 100644 --- a/extensions/apt-manager/src/lib/apt.ts +++ b/extensions/apt-manager/src/lib/apt.ts @@ -31,6 +31,8 @@ export type AptPackage = { icon: string | null; /** Installation scope for Flatpak apps. */ installation?: "system" | "user"; + /** For AppImages: the on-disk filename (unique identity). */ + fileName?: string; }; /** diff --git a/extensions/apt-manager/src/lib/exec.ts b/extensions/apt-manager/src/lib/exec.ts index bc4b4c64..085675b8 100644 --- a/extensions/apt-manager/src/lib/exec.ts +++ b/extensions/apt-manager/src/lib/exec.ts @@ -16,6 +16,10 @@ export type ExecOptions = { * Kill the process and fail if it runs longer than this (ms). */ timeout?: number; + /** + * Working directory for the child process. + */ + cwd?: string; /** * Replace (or extend) the environment passed to the child. */ @@ -47,6 +51,7 @@ export function run( timeout: options.timeout ?? 120_000, maxBuffer: options.maxBuffer ?? DEFAULT_MAX_BUFFER, encoding: "utf8", + cwd: options.cwd, }, (error, stdout, stderr) => { if (!error) { diff --git a/extensions/apt-manager/src/lib/flatpakRemotes.ts b/extensions/apt-manager/src/lib/flatpakRemotes.ts index 132d72ec..04ec167c 100644 --- a/extensions/apt-manager/src/lib/flatpakRemotes.ts +++ b/extensions/apt-manager/src/lib/flatpakRemotes.ts @@ -76,7 +76,9 @@ export async function fetchFlatpakRemotes(): Promise<{ return { remotes, error: null }; } -function installationFlag(installation: FlatpakInstallation): string { +export function flatpakInstallationFlag( + installation: FlatpakInstallation, +): string { return installation === "user" ? "--user" : "--system"; } @@ -87,7 +89,7 @@ export function flatpakRemoteModifyArgs( ): string[] { return [ "remote-modify", - installationFlag(remote.installation), + flatpakInstallationFlag(remote.installation), enabled ? "--enable" : "--disable", remote.name, ]; @@ -97,7 +99,7 @@ export function flatpakRemoteModifyArgs( export function flatpakRemoteDeleteArgs(remote: FlatpakRemote): string[] { return [ "remote-delete", - installationFlag(remote.installation), + flatpakInstallationFlag(remote.installation), "--force", remote.name, ]; @@ -112,7 +114,7 @@ export function flatpakRemoteAddArgs( ): string[] { const args = [ "remote-add", - installationFlag(installation), + flatpakInstallationFlag(installation), "--if-not-exists", ]; if (title?.trim()) args.push(`--title=${title.trim()}`); @@ -125,7 +127,7 @@ export function flatpakAppstreamArgs( installation: FlatpakInstallation, name: string, ): string[] { - return ["update", "--appstream", installationFlag(installation), name]; + return ["update", "--appstream", flatpakInstallationFlag(installation), name]; } /** diff --git a/extensions/apt-manager/src/lib/sources.ts b/extensions/apt-manager/src/lib/sources.ts index a1987d69..c728a7df 100644 --- a/extensions/apt-manager/src/lib/sources.ts +++ b/extensions/apt-manager/src/lib/sources.ts @@ -329,7 +329,7 @@ export async function writeFilePrivileged( content: string, ): Promise { if (!pkexecAvailable()) { - return "`pkexec` was not found in PATH. Install polkit (e.g. `apt install policykit-1`) or use the 'Retry in Terminal' action with sudo."; + return "`pkexec` was not found in PATH. Install polkit (e.g. `apt install policykit-1`) or write the file yourself with `sudo` in a terminal."; } const result = await run("pkexec", ["tee", path], { input: content, @@ -344,7 +344,7 @@ export async function deleteFilePrivileged( path: string, ): Promise { if (!pkexecAvailable()) { - return "`pkexec` was not found in PATH. Install polkit (e.g. `apt install policykit-1`) or use the 'Retry in Terminal' action with sudo."; + return "`pkexec` was not found in PATH. Install polkit (e.g. `apt install policykit-1`) or remove the file yourself with `sudo` in a terminal."; } const result = await run("pkexec", ["rm", "-f", path], { timeout: 120_000 }); return result.ok ? null : result.stderr.trim() || "Failed to delete file"; diff --git a/extensions/apt-manager/src/views/AddRepo.tsx b/extensions/apt-manager/src/views/AddRepo.tsx index d2358325..5e8f08ae 100644 --- a/extensions/apt-manager/src/views/AddRepo.tsx +++ b/extensions/apt-manager/src/views/AddRepo.tsx @@ -10,6 +10,7 @@ import { useNavigation, } from "@vicinae/api"; import { useState } from "react"; +import { RunResult } from "../components/RunResult"; import { runAptUpdate } from "../lib/apt"; import { buildDeb822Source, @@ -23,7 +24,7 @@ type Props = { }; export function AddRepoForm({ onAdded }: Props) { - const { pop } = useNavigation(); + const { pop, push } = useNavigation(); const [isSubmitting, setIsSubmitting] = useState(false); const onSubmit = async (values: Form.Values) => { @@ -93,7 +94,22 @@ export function AddRepoForm({ onAdded }: Props) { const shouldUpdate = Boolean(values.updateNow); if (shouldUpdate) { - await runAptUpdate(); + const updateResult = await runAptUpdate(); + if (!updateResult.ok) { + toast.style = Toast.Style.Failure; + toast.title = "Repository added, but updating package lists failed"; + toast.message = + updateResult.stderr.trim().slice(0, 140) || + `exit code ${updateResult.code ?? "unknown"}`; + push( + , + ); + } pop(); return; } diff --git a/extensions/apt-manager/src/views/InstallAppImageView.tsx b/extensions/apt-manager/src/views/InstallAppImageView.tsx deleted file mode 100644 index 7016a715..00000000 --- a/extensions/apt-manager/src/views/InstallAppImageView.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { - Action, - ActionPanel, - Form, - Icon, - Toast, - showToast, - useNavigation, -} from "@vicinae/api"; -import { useState } from "react"; -import { installAppImage, installDesktopFile } from "../lib/appimage"; - -export function InstallAppImageView() { - const { pop } = useNavigation(); - const [isSubmitting, setIsSubmitting] = useState(false); - const [sourceType, setSourceType] = useState<"url" | "file">("url"); - const [desktopLocation, setDesktopLocation] = useState<"user" | "system">("user"); - - const onSubmit = async (values: Form.Values) => { - const source = String(values.sourceType ?? "url") as "url" | "file"; - const location = String(values.location ?? "user") as "user" | "system"; - const input = String(values.input ?? "").trim(); - if (!input) { - await showToast({ - style: Toast.Style.Failure, - title: "A URL or file path is required", - }); - return; - } - setIsSubmitting(true); - const toast = await showToast({ - style: Toast.Style.Animated, - title: source === "url" ? "Downloading AppImage" : "Installing AppImage", - }); - const result = source === "url" - ? await installAppImage({ type: "url", url: input }) - : await installAppImage({ type: "file", path: input }); - if (!result.ok) { - toast.style = Toast.Style.Failure; - toast.title = "Installation failed"; - toast.message = result.error ?? undefined; - setIsSubmitting(false); - return; - } - const desktopToast = await showToast({ - style: Toast.Style.Animated, - title: "Creating desktop entry", - }); - const desktopResult = result.targetPath - ? await installDesktopFile(result.targetPath, location) - : { ok: false, error: "No target path" }; - if (!desktopResult.ok) { - toast.style = Toast.Style.Failure; - toast.title = "AppImage installed but desktop entry failed"; - toast.message = desktopResult.error ?? undefined; - } else { - toast.style = Toast.Style.Success; - toast.title = "AppImage installed"; - toast.message = `Installed in ${location} desktop entry`; - } - setIsSubmitting(false); - pop(); - }; - - return ( -
- - - } - > - - - - - - - - - - - ); -} diff --git a/extensions/apt-manager/src/views/InstallPackageView.tsx b/extensions/apt-manager/src/views/InstallPackageView.tsx new file mode 100644 index 00000000..f33b5546 --- /dev/null +++ b/extensions/apt-manager/src/views/InstallPackageView.tsx @@ -0,0 +1,243 @@ +import { + Action, + ActionPanel, + Form, + Icon, + Toast, + showToast, + useNavigation, +} from "@vicinae/api"; +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, extname } from "node:path"; +import { useState } from "react"; +import { RunResult } from "../components/RunResult"; +import { installAppImage, installDesktopFile } from "../lib/appimage"; +import { type OperationResult, runPrivilegedApGet } from "../lib/apt"; +import { + flatpakInstallationFlag, + hasFlatpakBinary, + runFlatpakCommand, +} from "../lib/flatpakRemotes"; + +type PackageFileKind = "appimage" | "deb" | "flatpak"; + +function expandPath(input: string): string { + let path = input.trim(); + if (path.startsWith("~/")) path = `${homedir()}${path.slice(1)}`; + path = path.replace(/^~(?=$|[/])/, homedir()); + return path; +} + +function packageKindFromPath(path: string): PackageFileKind | null { + const ext = extname(path).toLowerCase(); + if (ext === ".appimage") return "appimage"; + if (ext === ".deb") return "deb"; + if (ext === ".flatpak") return "flatpak"; + return null; +} + +export function InstallPackageView() { + const { pop, push } = useNavigation(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [desktopLocation, setDesktopLocation] = useState<"user" | "system">("user"); + const [inputValue, setInputValue] = useState(""); + const [remoteName, setRemoteName] = useState("flathub"); + + const input = String(inputValue).trim(); + const kind = packageKindFromPath(expandPath(input)) ?? null; + + const showRunResult = ( + heading: string, + result: OperationResult, + sudoArgs?: string[], + sudoCommand?: string, + ) => { + push( + , + ); + }; + + const onSubmit = async () => { + if (!input) { + await showToast({ + style: Toast.Style.Failure, + title: "A file path is required", + }); + return; + } + const path = expandPath(input); + if (!existsSync(path)) { + await showToast({ + style: Toast.Style.Failure, + title: "File not found", + message: path, + }); + return; + } + const fileKind = packageKindFromPath(path); + if (!fileKind) { + await showToast({ + style: Toast.Style.Failure, + title: "Unsupported file type", + message: "Choose an .AppImage, .deb, or .flatpak file", + }); + return; + } + setIsSubmitting(true); + + if (fileKind === "appimage") { + const toast = await showToast({ + style: Toast.Style.Animated, + title: "Installing AppImage", + }); + const result = await installAppImage(path); + if (!result.ok) { + toast.style = Toast.Style.Failure; + toast.title = "Installation failed"; + toast.message = result.error ?? undefined; + setIsSubmitting(false); + return; + } + const desktopResult = result.targetPath + ? await installDesktopFile(result.targetPath, desktopLocation, result.info) + : { ok: false, error: "No target path" }; + if (!desktopResult.ok) { + toast.style = Toast.Style.Failure; + toast.title = "AppImage installed but desktop entry failed"; + toast.message = desktopResult.error ?? undefined; + } else { + toast.style = Toast.Style.Success; + toast.title = "AppImage installed"; + if (result.hasMetadata === false) { + toast.message = + "Could not read AppImage metadata; icon and update info unavailable"; + } else { + toast.message = `Installed in ${desktopLocation} desktop entry`; + } + } + setIsSubmitting(false); + pop(); + return; + } + + const fileLabel = basename(path); + if (fileKind === "deb") { + const label = `Install ${fileLabel}`; + const toast = await showToast({ style: Toast.Style.Animated, title: label }); + const result = await runPrivilegedApGet(["install", "-y", path], label); + setIsSubmitting(false); + if (result.ok) { + toast.style = Toast.Style.Success; + toast.title = `${fileLabel} installed`; + } else { + toast.style = Toast.Style.Failure; + toast.title = `${label} failed`; + toast.message = + result.stderr.trim().slice(0, 140) || + `exit code ${result.code ?? "unknown"}`; + } + showRunResult(label, result, ["install", "-y", path]); + pop(); + return; + } + + if (!hasFlatpakBinary()) { + await showToast({ + style: Toast.Style.Failure, + title: "Flatpak is not installed", + message: "Install the flatpak package to install .flatpak files", + }); + setIsSubmitting(false); + return; + } + const remote = String(remoteName).trim() || "flathub"; + const label = `Install ${fileLabel}`; + const toast = await showToast({ style: Toast.Style.Animated, title: label }); + const result = await runFlatpakCommand( + "system", + [ + "install", + flatpakInstallationFlag("system"), + "-y", + remote, + path, + ], + label, + ); + setIsSubmitting(false); + if (result.ok) { + toast.style = Toast.Style.Success; + toast.title = `${fileLabel} installed`; + } else { + toast.style = Toast.Style.Failure; + toast.title = `${label} failed`; + toast.message = + result.stderr.trim().slice(0, 140) || + `exit code ${result.code ?? "unknown"}`; + } + showRunResult(label, result, ["install", "-y", remote, path], "flatpak"); + pop(); + }; + + return ( +
+ + + } + > + setInputValue(String(value))} + autoFocus + /> + {kind === "appimage" ? ( + + setDesktopLocation(String(value) as "user" | "system") + } + > + + + + ) : null} + {kind === "flatpak" ? ( + setRemoteName(String(value))} + /> + ) : null} + + ); +} \ No newline at end of file From d22eef85b25d26ad79294640963262bbe1663996 Mon Sep 17 00:00:00 2001 From: ali00209 Date: Fri, 25 Sep 2026 14:04:25 +0500 Subject: [PATCH 10/12] fix(apt-manager): use unsquashfs for AppImage metadata, fix install view flow --- extensions/apt-manager/README.md | 9 ++++-- extensions/apt-manager/src/lib/appimage.ts | 29 +++++++++++++++---- extensions/apt-manager/src/views/AddRepo.tsx | 7 +++-- .../src/views/InstallPackageView.tsx | 22 +++++++------- 4 files changed, 46 insertions(+), 21 deletions(-) diff --git a/extensions/apt-manager/README.md b/extensions/apt-manager/README.md index 0e02e833..a09e9c89 100644 --- a/extensions/apt-manager/README.md +++ b/extensions/apt-manager/README.md @@ -18,8 +18,10 @@ Launched from the `apt` command: the extension and runs the matching method. - **AppImages** — install (from a local file) and remove AppImage applications installed in `~/Applications`. Download an AppImage yourself, then point the - extension at the file; it copies it into `~/Applications` and extracts its - icon and metadata for the launcher entry. + extension at the file; it copies it into `~/Applications` and, when + `unsquashfs` (package `squashfs-tools`) is available, extracts its icon and + metadata for the launcher entry. The AppImage is **never executed** during + installation. Without `squashfs-tools`, the launcher entry uses the filename. ## How it works @@ -46,3 +48,6 @@ npm run lint polkit agent of your desktop session or the fallback terminal action. - Optional: `flatpak` to manage Flatpak applications and repositories. The Flatpak sections are hidden when the CLI is not installed. +- Optional: `squashfs-tools` (`unsquashfs`) to extract AppImage icon and + metadata without running the file; without it AppImages are listed by their + filename. diff --git a/extensions/apt-manager/src/lib/appimage.ts b/extensions/apt-manager/src/lib/appimage.ts index 8f8614fa..eb5f2b5e 100644 --- a/extensions/apt-manager/src/lib/appimage.ts +++ b/extensions/apt-manager/src/lib/appimage.ts @@ -40,6 +40,21 @@ export interface AppImageInfo { const EXTRACT_TIMEOUT = 180_000; +const UNSQUASHFS_PATHS = [ + "/usr/bin/unsquashfs", + "/usr/sbin/unsquashfs", + "/bin/unsquashfs", +]; + +/** + * Locate the `unsquashfs` binary from squashfs-tools, the established + * non-executing tool used to extract AppImage contents. Returns null when it + * is not installed on this system. + */ +function findUnSquashfs(): string | null { + return UNSQUASHFS_PATHS.find((path) => existsSync(path)) ?? null; +} + function getApplicationsDir(): string { return join(homedir(), "Applications"); } @@ -229,23 +244,25 @@ function findMetaInfo(root: string): string | null { } /** - * Best-effort metadata extraction: run the AppImage's embedded runtime, then - * persist the .desktop, metainfo, icon and a manifest into the sidecar. + * Best-effort metadata extraction: unpack the AppImage's embedded filesystem + * with `unsquashfs` (squashfs-tools) and persist the .desktop, metainfo, icon + * and a manifest into the sidecar. The AppImage itself is never executed. * Never fails the install and never leaves temp files behind. * Resolves true if usable metadata was extracted (icon, name or metainfo). */ async function extractAppImageSidecar(appPath: string): Promise { + const unsquashfs = findUnSquashfs(); + if (!unsquashfs) return false; const sidecar = getSidecarDir(appPath); const fileName = basename(appPath); const baseName = fileName.replace(/\.appimage$/i, ""); - const tempDir = mkdtempSync(join(tmpdir(), "appimage-extract-")); + const tempDir = mkdtempSync(join(tmpdir(), "appimage-metadata-")); try { - const result = await run(appPath, ["--appimage-extract"], { - cwd: tempDir, + const result = await run(unsquashfs, ["-d", join(tempDir, "root"), appPath], { timeout: EXTRACT_TIMEOUT, }); if (!result.ok) return false; - const root = join(tempDir, "squashfs-root"); + const root = join(tempDir, "root"); if (!existsSync(root)) return false; let name: string | null = null; diff --git a/extensions/apt-manager/src/views/AddRepo.tsx b/extensions/apt-manager/src/views/AddRepo.tsx index 5e8f08ae..4a067a3f 100644 --- a/extensions/apt-manager/src/views/AddRepo.tsx +++ b/extensions/apt-manager/src/views/AddRepo.tsx @@ -95,12 +95,16 @@ export function AddRepoForm({ onAdded }: Props) { const shouldUpdate = Boolean(values.updateNow); if (shouldUpdate) { const updateResult = await runAptUpdate(); - if (!updateResult.ok) { + const updateFailed = !updateResult.ok; + if (updateFailed) { toast.style = Toast.Style.Failure; toast.title = "Repository added, but updating package lists failed"; toast.message = updateResult.stderr.trim().slice(0, 140) || `exit code ${updateResult.code ?? "unknown"}`; + } + pop(); + if (updateFailed) { push( , ); } - pop(); return; } pop(); diff --git a/extensions/apt-manager/src/views/InstallPackageView.tsx b/extensions/apt-manager/src/views/InstallPackageView.tsx index f33b5546..72b84aaa 100644 --- a/extensions/apt-manager/src/views/InstallPackageView.tsx +++ b/extensions/apt-manager/src/views/InstallPackageView.tsx @@ -117,7 +117,7 @@ export function InstallPackageView() { toast.title = "AppImage installed"; if (result.hasMetadata === false) { toast.message = - "Could not read AppImage metadata; icon and update info unavailable"; + "Could not read AppImage metadata; icon and app details unavailable"; } else { toast.message = `Installed in ${desktopLocation} desktop entry`; } @@ -143,8 +143,8 @@ export function InstallPackageView() { result.stderr.trim().slice(0, 140) || `exit code ${result.code ?? "unknown"}`; } - showRunResult(label, result, ["install", "-y", path]); pop(); + showRunResult(label, result, ["install", "-y", path]); return; } @@ -176,15 +176,15 @@ export function InstallPackageView() { toast.style = Toast.Style.Success; toast.title = `${fileLabel} installed`; } else { - toast.style = Toast.Style.Failure; - toast.title = `${label} failed`; - toast.message = - result.stderr.trim().slice(0, 140) || - `exit code ${result.code ?? "unknown"}`; - } - showRunResult(label, result, ["install", "-y", remote, path], "flatpak"); - pop(); - }; +toast.style = Toast.Style.Failure; + toast.title = `${label} failed`; + toast.message = + result.stderr.trim().slice(0, 140) || + `exit code ${result.code ?? "unknown"}`; + } + pop(); + showRunResult(label, result, ["install", "-y", remote, path], "flatpak"); + }; return (
Date: Mon, 28 Sep 2026 12:04:39 +0500 Subject: [PATCH 11/12] feat(apt-manager): batch package operations, apt logos, safe removal simulation - BatchPackages view: select multiple packages via a toggleable list and install/remove/upgrade them in a single apt-get command (entered from a pinned 'Select multiple packages' row per list) - Resolve apt package logos from the hicolor/pixmaps theme (findAptIcon), falling back to the Box icon when no logo exists - Simulate removals before running them and block when additional installed packages would be removed alongside the requested ones --- extensions/apt-manager/README.md | 3 + .../src/components/PackageActions.tsx | 38 +-- .../src/components/PackageView.tsx | 42 +++- extensions/apt-manager/src/lib/apt.ts | 100 ++++++++ .../apt-manager/src/views/BatchPackages.tsx | 225 ++++++++++++++++++ 5 files changed, 386 insertions(+), 22 deletions(-) create mode 100644 extensions/apt-manager/src/views/BatchPackages.tsx diff --git a/extensions/apt-manager/README.md b/extensions/apt-manager/README.md index a09e9c89..11a51d1a 100644 --- a/extensions/apt-manager/README.md +++ b/extensions/apt-manager/README.md @@ -9,6 +9,9 @@ Launched from the `apt` command: - **Installed packages** — list what's installed, remove/reinstall, show info. - **All packages** — browse the full apt cache (lazy-loaded), install on the fly. - **Upgradable packages** — see updates, upgrade individual packages. +- **Batch operations** — in the All / Installed / Upgradable lists, pick + "Select multiple packages…" to choose several packages and install, remove, + or upgrade them in a single `apt-get` command. - **Update all** — `apt update` then `apt-get upgrade --with-new-pkgs`, with confirmation. - **Clean up system** — `apt-get autoremove --purge` then `apt-get autoclean`. - **Repositories** — list sources from `sources.list`/`sources.list.d` (legacy one-line diff --git a/extensions/apt-manager/src/components/PackageActions.tsx b/extensions/apt-manager/src/components/PackageActions.tsx index bc2fb68e..33ee1ac0 100644 --- a/extensions/apt-manager/src/components/PackageActions.tsx +++ b/extensions/apt-manager/src/components/PackageActions.tsx @@ -12,7 +12,7 @@ import { flatpakInstallationFlag, runFlatpakCommand, } from "../lib/flatpakRemotes"; -import { runPrivilegedApGet, runPrivilegedCommand } from "../lib/apt"; +import { runPrivilegedApGet, runPrivilegedCommand, simulateAptRemove } from "../lib/apt"; import { removeAppImage } from "../lib/appimage"; import type { FlatpakInstallation } from "../lib/flatpakRemotes"; import { askConfirm } from "../lib/confirm"; @@ -84,6 +84,24 @@ export function PackageActions({ onRefresh(); }; + const removeAptWithGuard = async (name: string) => { + const simulation = await simulateAptRemove([name]); + if (simulation.additional.length > 0) { + await showToast({ + style: Toast.Style.Failure, + title: `Removal blocked for ${name}`, + message: `${simulation.additional.length} installed package(s) depend on it: ${simulation.additional.slice(0, 5).join(", ")}${simulation.additional.length > 5 ? ", …" : ""}`, + }); + return; + } + await runPrivileged( + "apt-get", + ["remove", "-y", name], + `Remove ${name}`, + `Remove ${name}? Its configuration files are kept.`, + ); + }; + const toggleDetail = ( - runPrivileged( - "apt-get", - ["remove", "-y", pkg.name], - `Remove ${pkg.name}`, - `Remove ${pkg.name}? Its configuration files are kept.`, - ) - } + onAction={() => removeAptWithGuard(pkg.name)} /> - runPrivileged( - "apt-get", - ["remove", "-y", pkg.name], - `Remove ${pkg.name}`, - `Remove ${pkg.name}? Its configuration files are kept.`, - ) - } + onAction={() => removeAptWithGuard(pkg.name)} /> ) : null} {pkg.flags.upgradable ? ( diff --git a/extensions/apt-manager/src/components/PackageView.tsx b/extensions/apt-manager/src/components/PackageView.tsx index d8842659..3f65b25e 100644 --- a/extensions/apt-manager/src/components/PackageView.tsx +++ b/extensions/apt-manager/src/components/PackageView.tsx @@ -1,7 +1,8 @@ -import { Color, Icon, List } from "@vicinae/api"; +import { Action, ActionPanel, Color, Icon, List } from "@vicinae/api"; import { useEffect, useMemo, useState } from "react"; import type { AptPackage, PackageListKind } from "../lib/apt"; -import { fetchPackageList, flathubAppFromListed } from "../lib/apt"; +import { fetchPackageList, findAptIcon, flathubAppFromListed } from "../lib/apt"; +import { BatchPackages, type BatchActionKind } from "../views/BatchPackages"; import { PackageActions } from "./PackageActions"; import { AptAppDetail, AppImageDetail, FlathubAppDetail } from "./PackageListItemDetail"; @@ -60,6 +61,11 @@ export function PackageView({ kind, title, emptyTitle }: Props) { const isLoading = packages === null; const hasMore = filtered.length > visible; + const batchKind: BatchActionKind = + kind === "installed" ? "remove" : kind === "upgradable" ? "upgrade" : "install"; + const aptCandidates = filtered.slice(0, visible).filter((pkg) => pkg.manager === "apt"); + const batchVerb = batchKind === "remove" ? "Remove" : batchKind === "upgrade" ? "Upgrade" : "Install"; + const refresh = () => setReloadKey((value) => value + 1); return ( @@ -89,7 +95,32 @@ export function PackageView({ kind, title, emptyTitle }: Props) { description={query ? `No packages match "${query}".` : undefined} /> ) : ( - filtered.slice(0, visible).map((pkg) => { + <> + {aptCandidates.length > 0 && ( + + + } + /> + + } + /> + )} + {filtered.slice(0, visible).map((pkg) => { const id = pkg.fileName ?? `${pkg.name}/${pkg.arch}`; return ( ); - }) + })} + )} ); diff --git a/extensions/apt-manager/src/lib/apt.ts b/extensions/apt-manager/src/lib/apt.ts index bfcb77a1..abcb0e16 100644 --- a/extensions/apt-manager/src/lib/apt.ts +++ b/extensions/apt-manager/src/lib/apt.ts @@ -1,4 +1,5 @@ import { existsSync } from "node:fs"; +import { join } from "node:path"; import { run } from "./exec"; import { type FlathubApp, @@ -131,6 +132,61 @@ const LIST_ARGS: Record = { upgradable: ["--upgradable"], }; +const APT_ICON_ROOTS = [ + "/usr/share/icons/hicolor/scalable/apps", + "/usr/share/icons/hicolor/256x256/apps", + "/usr/share/icons/hicolor/128x128/apps", + "/usr/share/icons/hicolor/64x64/apps", + "/usr/share/icons/hicolor/48x48/apps", + "/usr/share/pixmaps", +]; + +/** + * Common package-name suffixes whose icon lives under the base name, e.g. the + * `firefox-esr` package ships its icon as `firefox`. + */ +const APT_ICON_SUFFIXES = ["-esr", "-bin", "-lib", "-base", "-common", "-core"]; + +const aptIconCache = new Map(); +let hicolorChecked = false; +let hicolorAvailable = false; + +/** + * Best-effort lookup of a Debian package's icon from the installed hicolor + * icon theme or `/usr/share/pixmaps`. Package names rarely match file names + * exactly, so misses return null and the caller falls back to a generic icon. + */ +export function findAptIcon(pkgName: string): string | null { + const cached = aptIconCache.get(pkgName); + if (cached !== undefined) return cached; + if (!hicolorChecked) { + hicolorAvailable = existsSync("/usr/share/icons/hicolor"); + hicolorChecked = true; + } + let found: string | null = null; + if (hicolorAvailable) { + const names = [pkgName]; + for (const suffix of APT_ICON_SUFFIXES) { + if (pkgName.length > suffix.length && pkgName.endsWith(suffix)) { + names.push(pkgName.slice(0, -suffix.length)); + } + } + outer: for (const root of APT_ICON_ROOTS) { + for (const base of names) { + for (const ext of ["png", "svg"]) { + const candidate = join(root, `${base}.${ext}`); + if (existsSync(candidate)) { + found = candidate; + break outer; + } + } + } + } + } + aptIconCache.set(pkgName, found); + return found; +} + /** * Fetch a list of packages via `apt list`. Runs without elevated privileges * since it only reads the local package cache. @@ -374,6 +430,50 @@ export async function runAptCleanup(): Promise { return runPrivilegedApGet(["autoclean"], "Clean package cache"); } +export type AptRemoveSimulation = { + /** Every package apt would remove in the simulated run. */ + wouldRemove: string[]; + /** Packages apt would remove beyond the ones explicitly requested. */ + additional: string[]; +}; + +/** + * Simulate `apt-get remove` for the given names without changing the system, + * and report which packages would be removed. Additional removals are the + * installed packages that depend on one of the requested ones and would be + * torn down with it. + */ +export async function simulateAptRemove( + names: string[], +): Promise { + const empty: AptRemoveSimulation = { wouldRemove: [], additional: [] }; + if (names.length === 0) return empty; + const result = await run("apt-get", ["--simulate", "remove", ...names], { + timeout: 60_000, + }); + if (!result.ok) return empty; + const requested = new Set(names); + const wouldRemove = parseRemovedBlock(result.stdout); + return { + wouldRemove, + additional: wouldRemove.filter((name) => !requested.has(name)), + }; +} + +/** + * Extract the packages apt would remove from a `--simulate` run by reading the + * per-package action lines at the end of the output (`Remv`/`Purg`), which + * reliably identify the removed packages regardless of the header text. + */ +function parseRemovedBlock(stdout: string): string[] { + const names: string[] = []; + for (const line of stdout.split("\n")) { + const match = /^\s*(Remv|Purg)\s+(\S+)/.exec(line); + if (match) names.push(match[2]); + } + return names; +} + export function resultMarkdown( result: OperationResult, heading: string, diff --git a/extensions/apt-manager/src/views/BatchPackages.tsx b/extensions/apt-manager/src/views/BatchPackages.tsx new file mode 100644 index 00000000..b730898d --- /dev/null +++ b/extensions/apt-manager/src/views/BatchPackages.tsx @@ -0,0 +1,225 @@ +import { + Action, + ActionPanel, + Color, + Icon, + List, + Toast, + showToast, + useNavigation, +} from "@vicinae/api"; +import { useMemo, useState } from "react"; +import { RunResult } from "../components/RunResult"; +import { type AptPackage, findAptIcon, runPrivilegedApGet, simulateAptRemove } from "../lib/apt"; +import { askConfirm } from "../lib/confirm"; + +export type BatchActionKind = "install" | "remove" | "upgrade"; + +const VERB: Record = { + install: "Install", + remove: "Remove", + upgrade: "Upgrade", +}; + +function buildArgs(kind: BatchActionKind, names: string[]): string[] { + if (kind === "remove") return ["remove", "-y", ...names]; + if (kind === "upgrade") return ["install", "--only-upgrade", "-y", ...names]; + return ["install", "-y", ...names]; +} + +type Props = { + kind: BatchActionKind; + candidates: AptPackage[]; + navigationTitle: string; + onDone: () => void; +}; + +export function BatchPackages({ + kind, + candidates, + navigationTitle, + onDone, +}: Props) { + const { pop, push } = useNavigation(); + const [selected, setSelected] = useState>(() => new Set()); + const [query, setQuery] = useState(""); + const [running, setRunning] = useState(false); + + const verb = VERB[kind]; + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (q === "") return candidates; + return candidates.filter((pkg) => pkg.name.toLowerCase().includes(q)); + }, [candidates, query]); + + const toggle = (name: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(name)) { + next.delete(name); + } else { + next.add(name); + } + return next; + }); + }; + + const run = async () => { + const names = [...selected]; + if (names.length === 0) { + await showToast({ + style: Toast.Style.Failure, + title: "No packages selected", + message: "Enter and select at least one package first", + }); + return; + } + if (kind === "remove") { + const simulation = await simulateAptRemove(names); + if (simulation.additional.length > 0) { + await showToast({ + style: Toast.Style.Failure, + title: "Removal blocked", + message: `${simulation.additional.length} additional installed package(s) would be removed: ${simulation.additional.slice(0, 5).join(", ")}${simulation.additional.length > 5 ? ", …" : ""}`, + }); + return; + } + } + const args = buildArgs(kind, names); + const label = `${verb} ${names.length.toString()} package${names.length === 1 ? "" : "s"}`; + const confirmed = await askConfirm( + label, + `Run "apt-get ${args.join(" ")}"?`, + verb, + ); + if (!confirmed) return; + setRunning(true); + const toast = await showToast({ + style: Toast.Style.Animated, + title: label, + }); + const result = await runPrivilegedApGet(args, label); + if (result.ok) { + toast.style = Toast.Style.Success; + toast.title = label; + toast.message = "Done"; + } else { + toast.style = Toast.Style.Failure; + toast.title = `${label} failed`; + toast.message = + result.stderr.trim().slice(0, 140) || + `exit code ${result.code ?? "unknown"}`; + } + pop(); + push( + , + ); + setRunning(false); + onDone(); + }; + + return ( + + 0 + ? `${verb} ${selected.size} package${selected.size === 1 ? "" : "s"}` + : `No packages selected (${candidates.length} available)` + } + icon={Icon.Bolt} + actions={ + + 0 ? verb : "Select packages first"} + onAction={run} + /> + {candidates.length > 1 && ( + + setSelected((prev) => { + const next = new Set(prev); + for (const pkg of filtered) next.add(pkg.name); + return next; + }) + } + /> + )} + + } + /> + {selected.size > 0 && ( + + setSelected(() => new Set())} + /> + + } + /> + )} + {filtered.map((pkg) => { + const isSelected = selected.has(pkg.name); + return ( + + toggle(pkg.name)} + /> + + } + /> + ); + })} + {filtered.length === 0 && ( + + )} + + ); +} \ No newline at end of file From f310ead823e7a83a7893563edc74ef2bc6249afc Mon Sep 17 00:00:00 2001 From: ali00209 Date: Mon, 28 Sep 2026 12:18:37 +0500 Subject: [PATCH 12/12] fix(apt-manager): extract AppImage payload at its offset, qualify batch packages by arch Type 2 AppImages append the SquashFS filesystem after an ELF runtime, so unsquashfs needs the payload offset. Find it by scanning for the superblock magic and validating the superblock fields, then pass it through -o. Extraction failure stays non-fatal, and unsquashfs exit code 2 (non-fatal symlink/xattr errors) no longer discards a completed extraction. Batch selection identified packages by name only, so entries sharing a name across architectures collided on List ids, toggled together, and the unqualified name could operate on the wrong architecture. Identify and select by name:arch, pass those specs to apt-get, and compare the removal simulation against apt's bare-name output. --- extensions/apt-manager/src/lib/appimage.ts | 88 ++++++++++++++++++- extensions/apt-manager/src/lib/apt.ts | 17 ++-- .../apt-manager/src/views/BatchPackages.tsx | 54 +++++++----- 3 files changed, 126 insertions(+), 33 deletions(-) diff --git a/extensions/apt-manager/src/lib/appimage.ts b/extensions/apt-manager/src/lib/appimage.ts index eb5f2b5e..1ea25082 100644 --- a/extensions/apt-manager/src/lib/appimage.ts +++ b/extensions/apt-manager/src/lib/appimage.ts @@ -1,9 +1,13 @@ import { chmodSync, + closeSync, copyFileSync, existsSync, + fstatSync, mkdirSync, mkdtempSync, + openSync, + readSync, readdirSync, readFileSync, realpathSync, @@ -46,6 +50,11 @@ const UNSQUASHFS_PATHS = [ "/bin/unsquashfs", ]; +/** Superblock magic ("hsqs" in little-endian) every SquashFS image starts with. */ +const SQUASHFS_MAGIC = Buffer.from("hsqs", "ascii"); +const SQUASHFS_SUPERBLOCK_SIZE = 96; +const SQUASHFS_SCAN_CHUNK = 4 * 1024 * 1024; + /** * Locate the `unsquashfs` binary from squashfs-tools, the established * non-executing tool used to extract AppImage contents. Returns null when it @@ -55,6 +64,71 @@ function findUnSquashfs(): string | null { return UNSQUASHFS_PATHS.find((path) => existsSync(path)) ?? null; } +/** + * Validate a SquashFS superblock candidate at `at` inside `buf`, rejecting the + * `hsqs` byte sequences that occur in ordinary data. Checks the format version, + * the block size and that the declared image size fits in the remaining bytes. + */ +function isSquashfsSuperblock(buf: Buffer, available: number): boolean { + if (buf.readUInt16LE(28) !== 4) return false; + const blockSize = buf.readUInt32LE(12); + if (blockSize < 4096 || blockSize > 1_048_576) return false; + if ((blockSize & (blockSize - 1)) !== 0) return false; + const blockLog = buf.readUInt16LE(22); + if (blockLog < 12 || blockLog > 20) return false; + const bytesUsed = Number(buf.readBigUInt64LE(40)); + return bytesUsed > 0 && bytesUsed <= available; +} + +/** + * Find the byte offset of the SquashFS payload inside a type 2 AppImage. The + * filesystem is appended after the ELF runtime, so `unsquashfs` only finds it + * with an explicit `-o`. Returns null when no valid superblock is present (for + * example a type 1 AppImage, which is an ISO 9660 image instead). + */ +function findSquashfsOffset(appPath: string): number | null { + let fd: number; + try { + fd = openSync(appPath, "r"); + } catch { + return null; + } + try { + const size = fstatSync(fd).size; + const chunk = Buffer.allocUnsafe(SQUASHFS_SCAN_CHUNK); + const superblock = Buffer.allocUnsafe(SQUASHFS_SUPERBLOCK_SIZE); + let base = 0; + while (base < size) { + const read = readSync(fd, chunk, 0, SQUASHFS_SCAN_CHUNK, base); + if (read < SQUASHFS_MAGIC.length) return null; + let at = 0; + while (at + SQUASHFS_MAGIC.length <= read) { + const found = chunk.indexOf(SQUASHFS_MAGIC, at); + if (found === -1) break; + const offset = base + found; + // The superblock is read straight from the file so that a + // candidate at the end of the chunk is still validated in full. + if ( + readSync(fd, superblock, 0, SQUASHFS_SUPERBLOCK_SIZE, offset) === + SQUASHFS_SUPERBLOCK_SIZE && + isSquashfsSuperblock(superblock, size - offset) + ) { + return offset; + } + at = found + 1; + } + // Overlap the next read so that magic bytes spanning two chunks are + // still seen in full. + base += read - (SQUASHFS_MAGIC.length - 1); + } + return null; + } catch { + return null; + } finally { + closeSync(fd); + } +} + function getApplicationsDir(): string { return join(homedir(), "Applications"); } @@ -253,15 +327,21 @@ function findMetaInfo(root: string): string | null { async function extractAppImageSidecar(appPath: string): Promise { const unsquashfs = findUnSquashfs(); if (!unsquashfs) return false; + const offset = findSquashfsOffset(appPath); + if (offset === null) return false; const sidecar = getSidecarDir(appPath); const fileName = basename(appPath); const baseName = fileName.replace(/\.appimage$/i, ""); const tempDir = mkdtempSync(join(tmpdir(), "appimage-metadata-")); try { - const result = await run(unsquashfs, ["-d", join(tempDir, "root"), appPath], { - timeout: EXTRACT_TIMEOUT, - }); - if (!result.ok) return false; + const result = await run( + unsquashfs, + ["-o", String(offset), "-d", join(tempDir, "root"), appPath], + { timeout: EXTRACT_TIMEOUT }, + ); + // Exit code 2 means non-fatal errors (symlinks, xattrs) after a + // completed extraction, so only a real failure aborts here. + if (!result.ok && result.code !== 2) return false; const root = join(tempDir, "root"); if (!existsSync(root)) return false; diff --git a/extensions/apt-manager/src/lib/apt.ts b/extensions/apt-manager/src/lib/apt.ts index abcb0e16..84ba78fb 100644 --- a/extensions/apt-manager/src/lib/apt.ts +++ b/extensions/apt-manager/src/lib/apt.ts @@ -438,21 +438,22 @@ export type AptRemoveSimulation = { }; /** - * Simulate `apt-get remove` for the given names without changing the system, - * and report which packages would be removed. Additional removals are the - * installed packages that depend on one of the requested ones and would be - * torn down with it. + * Simulate `apt-get remove` for the given specifications without changing the + * system, and report which packages would be removed. Specifications may be + * architecture-qualified (`name:arch`); apt reports the removals by bare name. + * `additional` holds the installed packages that depend on one of the requested + * ones and would be torn down with it. */ export async function simulateAptRemove( - names: string[], + specs: string[], ): Promise { const empty: AptRemoveSimulation = { wouldRemove: [], additional: [] }; - if (names.length === 0) return empty; - const result = await run("apt-get", ["--simulate", "remove", ...names], { + if (specs.length === 0) return empty; + const result = await run("apt-get", ["--simulate", "remove", ...specs], { timeout: 60_000, }); if (!result.ok) return empty; - const requested = new Set(names); + const requested = new Set(specs.map((spec) => spec.split(":")[0])); const wouldRemove = parseRemovedBlock(result.stdout); return { wouldRemove, diff --git a/extensions/apt-manager/src/views/BatchPackages.tsx b/extensions/apt-manager/src/views/BatchPackages.tsx index b730898d..085348de 100644 --- a/extensions/apt-manager/src/views/BatchPackages.tsx +++ b/extensions/apt-manager/src/views/BatchPackages.tsx @@ -21,10 +21,19 @@ const VERB: Record = { upgrade: "Upgrade", }; -function buildArgs(kind: BatchActionKind, names: string[]): string[] { - if (kind === "remove") return ["remove", "-y", ...names]; - if (kind === "upgrade") return ["install", "--only-upgrade", "-y", ...names]; - return ["install", "-y", ...names]; +function buildArgs(kind: BatchActionKind, specs: string[]): string[] { + if (kind === "remove") return ["remove", "-y", ...specs]; + if (kind === "upgrade") return ["install", "--only-upgrade", "-y", ...specs]; + return ["install", "-y", ...specs]; +} + +/** + * Identify a package by name and architecture. On multiarch systems apt lists + * the same name once per architecture, so the name alone is ambiguous; the + * qualified `name:arch` form is also what apt-get expects on the command line. + */ +function specOf(pkg: AptPackage): string { + return pkg.arch ? `${pkg.name}:${pkg.arch}` : pkg.name; } type Props = { @@ -50,24 +59,26 @@ export function BatchPackages({ const filtered = useMemo(() => { const q = query.trim().toLowerCase(); if (q === "") return candidates; - return candidates.filter((pkg) => pkg.name.toLowerCase().includes(q)); + return candidates.filter( + (pkg) => + pkg.name.toLowerCase().includes(q) || pkg.arch.toLowerCase().includes(q), + ); }, [candidates, query]); - const toggle = (name: string) => { + const toggle = (spec: string) => { setSelected((prev) => { const next = new Set(prev); - if (next.has(name)) { - next.delete(name); + if (next.has(spec)) { + next.delete(spec); } else { - next.add(name); + next.add(spec); } return next; }); }; - const run = async () => { - const names = [...selected]; - if (names.length === 0) { + const specs = [...selected]; + if (specs.length === 0) { await showToast({ style: Toast.Style.Failure, title: "No packages selected", @@ -76,7 +87,7 @@ export function BatchPackages({ return; } if (kind === "remove") { - const simulation = await simulateAptRemove(names); + const simulation = await simulateAptRemove(specs); if (simulation.additional.length > 0) { await showToast({ style: Toast.Style.Failure, @@ -86,8 +97,8 @@ export function BatchPackages({ return; } } - const args = buildArgs(kind, names); - const label = `${verb} ${names.length.toString()} package${names.length === 1 ? "" : "s"}`; + const args = buildArgs(kind, specs); + const label = `${verb} ${specs.length.toString()} package${specs.length === 1 ? "" : "s"}`; const confirmed = await askConfirm( label, `Run "apt-get ${args.join(" ")}"?`, @@ -154,7 +165,7 @@ export function BatchPackages({ onAction={() => setSelected((prev) => { const next = new Set(prev); - for (const pkg of filtered) next.add(pkg.name); + for (const pkg of filtered) next.add(specOf(pkg)); return next; }) } @@ -179,13 +190,14 @@ export function BatchPackages({ /> )} {filtered.map((pkg) => { - const isSelected = selected.has(pkg.name); + const spec = specOf(pkg); + const isSelected = selected.has(spec); return ( toggle(pkg.name)} + onAction={() => toggle(spec)} /> }