diff --git a/extensions/apt-manager/.gitignore b/extensions/apt-manager/.gitignore new file mode 100644 index 00000000..a0b7ce6a --- /dev/null +++ b/extensions/apt-manager/.gitignore @@ -0,0 +1,4 @@ +node_modules +vicinae-env.d.ts +AGENT.md +.agent diff --git a/extensions/apt-manager/README.md b/extensions/apt-manager/README.md new file mode 100644 index 00000000..11a51d1a --- /dev/null +++ b/extensions/apt-manager/README.md @@ -0,0 +1,56 @@ +# 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. +- **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 + and deb822 `.sources`), add new repos as deb822 `.sources` files, enable/disable/remove. +- **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, 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 + +- Read-only operations (`apt list`, `apt-cache show`) run without privileges. +- 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 + +```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. +- 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/assets/extension_icon.png b/extensions/apt-manager/assets/extension_icon.png new file mode 100644 index 00000000..cf29a7ba Binary files /dev/null and b/extensions/apt-manager/assets/extension_icon.png differ 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..a716705f --- /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 with Flatpak and AppImage support", + "categories": ["System"], + "license": "MIT", + "author": "ali00209", + "contributors": [], + "pastContributors": [], + "icon": "extension_icon.png", + "commands": [ + { + "name": "apt", + "title": "APT", + "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" + } + ], + "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..a7ddd64f --- /dev/null +++ b/extensions/apt-manager/src/apt.tsx @@ -0,0 +1,298 @@ +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"; +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(); + + 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", + undefined, + "Run apt update then upgrade all packages?", + { + label: "Retry Update in Terminal (sudo)", + args: [ + "sudo", + "bash", + "-c", + "apt-get update && apt-get upgrade -y --with-new-pkgs", + ], + }, + ) + } + extraActions={ + + } + /> + + runAndShow( + runAptCleanup, + "Clean up system", + 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 && ( + } + /> + } + /> + )} + + + ); +} + +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[] | undefined, + confirmMessage: string | null, + terminalRetry?: { label: string; args: string[] }, + ) => { + 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..33ee1ac0 --- /dev/null +++ b/extensions/apt-manager/src/components/PackageActions.tsx @@ -0,0 +1,297 @@ +import { + Action, + ActionPanel, + Icon, + Keyboard, + Toast, + showToast, + useNavigation, +} from "@vicinae/api"; +import type { AptPackage, PackageListKind } from "../lib/apt"; +import { + flatpakInstallationFlag, + runFlatpakCommand, +} from "../lib/flatpakRemotes"; +import { runPrivilegedApGet, runPrivilegedCommand, simulateAptRemove } from "../lib/apt"; +import { removeAppImage } from "../lib/appimage"; +import type { FlatpakInstallation } from "../lib/flatpakRemotes"; +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, + installation?: FlatpakInstallation, + ) => { + 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) + : command === "flatpak" + ? () => runFlatpakCommand(installation ?? "system", 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 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 = ( + + ); + + const copyName = ( + + ); + + if (pkg.manager === "flatpak") { + return ( + + {toggleDetail} + {pkg.flags.installed ? ( + + runPrivileged( + "flatpak", + [ + "uninstall", + flatpakInstallationFlag(pkg.installation ?? "system"), + "-y", + pkg.name, + ], + `Remove ${pkg.name}`, + `Remove the Flatpak application ${pkg.name}?`, + pkg.installation, + ) + } + /> + ) : ( + + runPrivileged( + "flatpak", + [ + "install", + flatpakInstallationFlag(pkg.installation ?? "system"), + "-y", + "flathub", + pkg.name, + ], + `Install ${pkg.name}`, + null, + pkg.installation, + ) + } + /> + )} + {copyName} + + ); + } + + 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} + removeAptWithGuard(pkg.name)} + /> + + runPrivileged( + "apt-get", + ["install", "--reinstall", "-y", pkg.name], + `Reinstall ${pkg.name}`, + null, + ) + } + /> + {copyName} + + ); + } + + if (kind === "upgradable" && pkg.manager === "apt") { + 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 ? ( + removeAptWithGuard(pkg.name)} + /> + ) : 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..895b30a2 --- /dev/null +++ b/extensions/apt-manager/src/components/PackageListItemDetail.tsx @@ -0,0 +1,206 @@ +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 && ( + + )} + + } + /> + ); +} + +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 new file mode 100644 index 00000000..3f65b25e --- /dev/null +++ b/extensions/apt-manager/src/components/PackageView.tsx @@ -0,0 +1,211 @@ +import { Action, ActionPanel, Color, Icon, List } from "@vicinae/api"; +import { useEffect, useMemo, useState } from "react"; +import type { AptPackage, PackageListKind } 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"; + +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 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 ( + setVisible((value) => value + PAGE_STEP), + }} + > + {error ? ( + + ) : !isLoading && filtered.length === 0 ? ( + + ) : ( + <> + {aptCandidates.length > 0 && ( + + + } + /> + + } + /> + )} + {filtered.slice(0, visible).map((pkg) => { + const id = pkg.fileName ?? `${pkg.name}/${pkg.arch}`; + return ( + + ) : pkg.manager === "appimage" ? ( + + ) : ( + + ) + } + actions={ + setShowingDetail((value) => !value)} + /> + } + /> + ); + })} + + )} + + ); +} + +function accessoriesFor( + kind: PackageListKind, + pkg: AptPackage, +): List.Item.Accessory[] { + if (pkg.manager === "appimage") { + return [{ tag: { color: Color.Orange, value: "appimage" } }]; + } + 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..77bdd4dc --- /dev/null +++ b/extensions/apt-manager/src/components/RunResult.tsx @@ -0,0 +1,57 @@ +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; + /** Exact terminal retry for operations that span multiple commands. */ + terminalRetry?: { label: string; args: string[] } | null; +}; + +export function RunResult({ + heading, + title, + result, + sudoArgs, + sudoCommand, + terminalRetry, +}: Props) { + const markdown = resultMarkdown(result, heading); + const retryCommand = sudoCommand ?? "apt-get"; + + const actions = ( + + + {terminalRetry ? ( + + ) : sudoArgs && sudoArgs.length > 0 ? ( + + ) : null} + + ); + + return ( + + ); +} diff --git a/extensions/apt-manager/src/lib/appimage.ts b/extensions/apt-manager/src/lib/appimage.ts new file mode 100644 index 00000000..1ea25082 --- /dev/null +++ b/extensions/apt-manager/src/lib/appimage.ts @@ -0,0 +1,693 @@ +import { + chmodSync, + closeSync, + copyFileSync, + existsSync, + fstatSync, + mkdirSync, + mkdtempSync, + openSync, + readSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { basename, dirname, extname, join } from "node:path"; +import { run } from "./exec"; +import type { AptPackage } from "./apt"; + +export type AppImageRemoveDepth = "file" | "desktop" | "config"; + +export interface AppImageManifest { + url: string | null; + installedAt: string; +} + +export interface AppImageInfo { + /** On-disk filename of the AppImage (unique identity). */ + fileName: string; + /** Display name (from the embedded desktop/metainfo, else the filename). */ + name: string; + /** Full path to the AppImage file. */ + path: string; + version: string; + description: string; + /** Absolute path to an extracted PNG icon, or null if unavailable. */ + icon: string | null; + url: string | null; + desktopPath: string | null; +} + +const EXTRACT_TIMEOUT = 180_000; + +const UNSQUASHFS_PATHS = [ + "/usr/bin/unsquashfs", + "/usr/sbin/unsquashfs", + "/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 + * is not installed on this system. + */ +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"); +} + +function getUserDesktopDir(): string { + return join(homedir(), ".local", "share", "applications"); +} + +function getSidecarDir(appPath: string): string { + return `${appPath}.d`; +} + +function getManifestPath(appPath: string): string { + return join(getSidecarDir(appPath), "manifest.json"); +} + +export function parseVersionFromFileName(fileName: string): string { + const base = fileName.replace(/\.appimage$/i, ""); + const match = base.match(/(\d+\.\d+(?:\.\d+)*)/); + return match ? match[1] : ""; +} + +function readManifest(appPath: string): Partial | 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; +} { + let name: string | null = null; + let comment: string | null = null; + let inSection = false; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (trimmed === "[Desktop Entry]") { + inSection = true; + continue; + } + if (!inSection || trimmed === "" || trimmed.startsWith("#")) continue; + if (trimmed.startsWith("[") && trimmed.endsWith("]")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim().toLowerCase(); + const value = trimmed.slice(eq + 1).trim(); + if (key === "name") name = value; + if (key === "comment") comment = value; + } + 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); + if (descMatch) { + const inner = descMatch[1].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); + description = inner || null; + } + 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: 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 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, + ["-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; + + 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 fileName = basename(dir); + if (!fileName.toLowerCase().endsWith(".appimage")) return null; + try { + if (!statSync(dir).isFile()) return null; + } catch { + return 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 (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 + } + } + const iconPng = join(sidecar, "icon.png"); + const iconSvg = join(sidecar, "icon.svg"); + if (existsSync(iconPng)) icon = iconPng; + else if (existsSync(iconSvg)) icon = null; + } + + 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 + } + } + } + + return { + fileName, + name, + path: dir, + version: parseVersionFromFileName(fileName), + description, + icon, + url, + desktopPath, + }; +} + +/** + * Discover installed AppImages in ~/Applications and return them as AptPackage entries. + */ +export async function fetchAppImages(): Promise { + const appsDir = getApplicationsDir(); + if (!existsSync(appsDir)) return []; + const packages: AptPackage[] = []; + for (const entry of readdirSync(appsDir)) { + if (!entry.toLowerCase().endsWith(".appimage")) continue; + const fullPath = join(appsDir, entry); + const info = await discoverAppImageFile(fullPath); + if (!info) continue; + packages.push({ + name: info.name, + fileName: info.fileName, + 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 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( + path: string, +): Promise { + const appsDir = getApplicationsDir(); + if (!existsSync(appsDir)) { + try { + mkdirSync(appsDir, { recursive: true }); + } catch { + return { ok: false, error: `Failed to create ${appsDir}` }; + } + } + 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)}`, + }; + } + + const hasMetadata = await extractAppImageSidecar(targetPath); + const info = await discoverAppImageFile(targetPath); + return { + ok: true, + error: null, + targetPath, + info: info ?? undefined, + hasMetadata, + }; +} + +/** + * Create a .desktop file for an AppImage in the specified location. + */ +export async function installDesktopFile( + appPath: string, + location: "user" | "system", + info?: AppImageInfo, +): Promise<{ ok: boolean; error: string | null }> { + 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") { + const desktopDir = getUserDesktopDir(); + if (!existsSync(desktopDir)) { + try { + mkdirSync(desktopDir, { recursive: true }); + } catch (err) { + return { + ok: false, + error: `Failed to create ${desktopDir}: ${err instanceof Error ? err.message : String(err)}`, + }; + } + } + 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", `${baseName}.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 }; +} + +export async function removeAppImage( + fileName: 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; + for (const entry of readdirSync(appsDir)) { + if (!entry.toLowerCase().endsWith(".appimage")) continue; + if (entry === fileName || entry.toLowerCase() === fileName.toLowerCase()) { + targetPath = join(appsDir, entry); + break; + } + } + if (!targetPath) { + return { ok: false, error: `AppImage "${fileName}" not found` }; + } + 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", + }; + } + } + 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", baseName); + 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 }; +} \ No newline at end of file diff --git a/extensions/apt-manager/src/lib/apt.ts b/extensions/apt-manager/src/lib/apt.ts new file mode 100644 index 00000000..84ba78fb --- /dev/null +++ b/extensions/apt-manager/src/lib/apt.ts @@ -0,0 +1,499 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { run } from "./exec"; +import { + type FlathubApp, + type FlathubListedApp, + fetchFlathubInstalled, + fetchFlathubRemote, + findFlatpakIcon, +} from "./flathub"; +import { fetchAppImages } from "./appimage"; + +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" | "appimage"; + /** Local path to an app icon, if one is available. */ + icon: string | null; + /** Installation scope for Flatpak apps. */ + installation?: "system" | "user"; + /** For AppImages: the on-disk filename (unique identity). */ + fileName?: string; +}; + +/** + * 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"], +}; + +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. + * 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), + ); + const appImages = await fetchAppImages(); + return [...aptInstalled, ...flatpakInstalled, ...appImages].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), + ); + const appImages = await fetchAppImages(); + return [...all, ...flatpakAvailable, ...appImages].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), + installation: pkg.installation, + }; +} + +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; + +export 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 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 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( + specs: string[], +): Promise { + const empty: AptRemoveSimulation = { wouldRemove: [], additional: [] }; + 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(specs.map((spec) => spec.split(":")[0])); + 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, +): 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..085675b8 --- /dev/null +++ b/extensions/apt-manager/src/lib/exec.ts @@ -0,0 +1,69 @@ +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; + /** + * Working directory for the child process. + */ + cwd?: string; + /** + * 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 = { ...process.env, LC_ALL: "C" }; + 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", + cwd: options.cwd, + }, + (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..a1049ed7 --- /dev/null +++ b/extensions/apt-manager/src/lib/flathub.ts @@ -0,0 +1,198 @@ +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; + installation?: "system" | "user"; +}; + +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 system = await run( + "flatpak", + ["list", "--app", "--system", `--columns=${LIST_COLUMNS}`], + { timeout: 60_000 }, + ); + 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; +} + +/** + * 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..04ec167c --- /dev/null +++ b/extensions/apt-manager/src/lib/flatpakRemotes.ts @@ -0,0 +1,153 @@ +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 }; +} + +export function flatpakInstallationFlag( + 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", + flatpakInstallationFlag(remote.installation), + enabled ? "--enable" : "--disable", + remote.name, + ]; +} + +/** Build the arguments to delete a remote. */ +export function flatpakRemoteDeleteArgs(remote: FlatpakRemote): string[] { + return [ + "remote-delete", + flatpakInstallationFlag(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", + flatpakInstallationFlag(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", flatpakInstallationFlag(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..c728a7df --- /dev/null +++ b/extensions/apt-manager/src/lib/sources.ts @@ -0,0 +1,382 @@ +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"; + +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 { + if (!pkexecAvailable()) { + 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, + timeout: 120_000, + }); + return result.ok + ? null + : result.stderr.trim() || result.stdout.trim() || "Failed to write file"; +} + +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 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"; +} + +/** + * 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..4a067a3f --- /dev/null +++ b/extensions/apt-manager/src/views/AddRepo.tsx @@ -0,0 +1,188 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { + Action, + ActionPanel, + Form, + Icon, + showToast, + Toast, + useNavigation, +} from "@vicinae/api"; +import { useState } from "react"; +import { RunResult } from "../components/RunResult"; +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, push } = 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`); + 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, + 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) { + const updateResult = await runAptUpdate(); + 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( + , + ); + } + 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/BatchPackages.tsx b/extensions/apt-manager/src/views/BatchPackages.tsx new file mode 100644 index 00000000..085348de --- /dev/null +++ b/extensions/apt-manager/src/views/BatchPackages.tsx @@ -0,0 +1,237 @@ +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, 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 = { + 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) || pkg.arch.toLowerCase().includes(q), + ); + }, [candidates, query]); + + const toggle = (spec: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(spec)) { + next.delete(spec); + } else { + next.add(spec); + } + return next; + }); + }; + const run = async () => { + const specs = [...selected]; + if (specs.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(specs); + 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, specs); + const label = `${verb} ${specs.length.toString()} package${specs.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(specOf(pkg)); + return next; + }) + } + /> + )} + + } + /> + {selected.size > 0 && ( + + setSelected(() => new Set())} + /> + + } + /> + )} + {filtered.map((pkg) => { + const spec = specOf(pkg); + const isSelected = selected.has(spec); + return ( + + toggle(spec)} + /> + + } + /> + ); + })} + {filtered.length === 0 && ( + + )} + + ); +} \ No newline at end of file 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/InstallPackageView.tsx b/extensions/apt-manager/src/views/InstallPackageView.tsx new file mode 100644 index 00000000..72b84aaa --- /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 app details 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"}`; + } + pop(); + showRunResult(label, result, ["install", "-y", path]); + 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"}`; + } + pop(); + showRunResult(label, result, ["install", "-y", remote, path], "flatpak"); + }; + + 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 diff --git a/extensions/apt-manager/src/views/Repos.tsx b/extensions/apt-manager/src/views/Repos.tsx new file mode 100644 index 00000000..e308c259 --- /dev/null +++ b/extensions/apt-manager/src/views/Repos.tsx @@ -0,0 +1,270 @@ +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"); + } + } + if (!enabled) { + lines.splice(repo.endLine, 0, "Enabled: no"); + } + 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..4451aafa --- /dev/null +++ b/extensions/apt-manager/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Node 16", + "include": ["src/**/*"], + "compilerOptions": { + "module": "commonjs", + "target": "es2020", + "strict": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "jsx": "react-jsx", + "types": ["node"] + } +}