Compare commits

..

3 commits
main ... upm

131 changed files with 3431 additions and 961 deletions

View file

@ -2,25 +2,92 @@
## Workspace Shape
`aim` is a Rust workspace with two main crates:
`upm` is a Rust workspace with three main crates today and a fourth planned frontend:
- `crates/aim-core`: source normalization, provider adapters, install/update planning, payload installation, registry persistence, and desktop integration.
- `crates/aim-cli`: argument parsing, config loading, terminal UX, prompting, progress reporting, and summary rendering.
- `crates/upm-core`: the application layer for `upm`. It owns command orchestration, module contracts, module registry and composition, registry persistence, install policies, desktop integration, and the unified frontend-facing API that both the CLI and a future GUI will call.
- `crates/upm`: the CLI frontend over `upm-core`. It handles argument parsing, config loading, terminal UX, prompting, progress reporting, summary rendering, and config-driven module presentation.
- `crates/upm-appimage`: the AppImage package-manager module. It should own AppImage-specific acquisition backends, artifact selection, and install-resolution behavior.
- `crates/upm-ui` (planned): a GUI frontend over `upm-core`, not a second application layer.
The split keeps product logic in `aim-core` so additional frontends can reuse the same install and update pipeline.
The intended split is strict:
- `upm-core` is effectively the application
- `upm` is one frontend over that application
- `upm-ui` will be another frontend over that application
- package-manager modules own their own implementation detail and speak to `upm-core` through normalized traits
That keeps frontend-agnostic logic in `upm-core`, makes a future GUI a first-class consumer instead of a later retrofit, and prevents frontend layers from accumulating package-manager-specific behavior.
## Application Boundary
The architectural boundary is:
- `upm` may know which modules exist for configuration, enablement, disablement, priority, and display
- `upm-ui` should operate under the same rule as the CLI: it talks to `upm-core`, not directly to modules
- `upm` must not talk directly to a package-manager module or implement module-specific logic
- `upm-core` owns the unified application interface used by the CLI now and a GUI later
- `upm-core` owns module registration, composition, enablement checks, and request fan-out
- `upm-core` fans requests out to enabled modules and aggregates normalized results
- each module owns its own internal backends, source quirks, artifact selection, and provider-specific rules
In practical terms, `upm-core` is where the product behavior lives. The CLI should remain replaceable.
## Public API Shape
`upm-core` should expose one high-level application facade to frontend crates.
- the public boundary should be an application-facing type such as `UpmApp`
- the facade should present operations like search, add, show, update, remove, and config management in product terms
- frontends should not compose lower-level orchestration services themselves
That public facade should stay thin. The internal implementation in `upm-core` can and should be split into smaller services such as:
- module registry and module loading
- search orchestration
- add planning and execution
- show resolution
- update planning and execution
- configuration and state services
This gives both frontends one stable application boundary without turning the facade into a god object. The orchestration depth stays inside `upm-core`, where it belongs.
## Module Tree
The intended tree is:
- `upm-core`
- public application facade
- internal orchestration services
- module registry and composition
- normalized contracts for package-manager modules
- frontend crates
- `upm` for CLI concerns only
- `upm-ui` for GUI concerns only
- module crates
- `upm-appimage`
- AppImageHub backend
- GitHub-backed AppImage acquisition
- GitLab-backed AppImage acquisition
- SourceForge-backed AppImage acquisition
- direct AppImage URL handling
- AppImage-specific artifact and metadata rules
The important constraint is that the top layer understands package-manager modules, not the inner mechanics of how each module finds or resolves artifacts.
## Core Flow
The main execution path is:
1. Parse CLI input and load runtime config in `aim-cli`.
2. Resolve the query into a normalized source in `aim-core`.
3. Build an add or update plan through provider adapters and artifact selection.
4. Download the selected AppImage into a staged path.
5. Verify integrity metadata when available.
6. Commit the payload into the managed install location.
7. Write desktop integration artifacts and refresh helper caches.
8. Persist registry state atomically.
1. Parse CLI input and load runtime config in `upm`.
2. Call the unified application facade in `upm-core`.
3. Let `upm-core` route the request into internal orchestration services.
4. Let those services select enabled modules and fan the request out through normalized module traits.
5. Aggregate normalized results into an add, show, update, search, or remove flow.
6. Download the selected AppImage into a staged path when the chosen module requires it.
7. Verify integrity metadata when available.
8. Commit the payload into the managed install location.
9. Write desktop integration artifacts and refresh helper caches.
10. Persist registry state atomically.
## Source And Provider Model
@ -33,7 +100,18 @@ Supported source classes currently include:
- direct URLs
- local file imports
Provider-specific resolution lives in `crates/aim-core/src/adapters` and `crates/aim-core/src/source`.
Core orchestration and normalized module contracts live in `crates/upm-core`. Package-manager-specific behavior belongs in module crates.
For the AppImage module, that means `crates/upm-appimage` is the package-manager boundary and should grow to own AppImage-specific backing sources internally. `upm-core` should coordinate the module through normalized traits, not absorb AppImageHub, GitHub-backed AppImage discovery, GitLab-backed AppImage discovery, SourceForge-backed AppImage discovery, or direct AppImage URL handling as first-class application concepts.
## Runtime Interface
The rename to `upm` is a hard cutover:
- runtime overrides use `UPM_*`
- legacy `AIM_*` runtime overrides are not read
- default config, registry, payload, and desktop-entry paths use `upm` names
- helper audit logging uses `UPM_DEBUG_EXTERNAL_HELPERS=1`
## Security Hardening State
@ -51,9 +129,9 @@ The remaining deferred AppImageHub host-trust concern is tracked in `security-is
## Persistence And Integration
- Registry writes are atomic and live under the registry store implementation in `aim-core`.
- Registry writes are atomic and live under the registry store implementation in `upm-core`.
- Managed payload, desktop entry, and icon paths are resolved from install policy and scope.
- Desktop integration refresh uses external helpers when available and now supports env-gated audit logging through `AIM_DEBUG_EXTERNAL_HELPERS=1`.
- Desktop integration refresh uses external helpers when available and now supports env-gated audit logging through `UPM_DEBUG_EXTERNAL_HELPERS=1`.
## Planning And Audit Artifacts

214
.architecture/roadmap.md Normal file
View file

@ -0,0 +1,214 @@
# UPM Roadmap
## Direction
The project is evolving from a focused AppImage manager into `upm`, a modular universal package manager. The target system manages multiple package-manager modules through a shared headless application core, keeps frontend crates thin, and leaves room for future GUI frontends.
The initial rename-and-extraction slice has now landed as a hard cutover: the runtime surface is `upm`, the shared core is `upm-core`, and AppImage support is beginning its transition into `upm-appimage` instead of remaining embedded directly into the core.
The near-term goal is a Linux-first platform with honest cross-platform architecture. Phase 2 will implement Linux package sources while establishing the abstractions needed for later macOS support and possible Windows support.
## Confirmed Product Decisions
- `aim-cli` becomes `upm`.
- `aim-core` stops being the all-in-one backend and is split.
- AppImage support becomes an installable module named `upm-appimage`.
- Shared orchestration, config, state, resolution, ranking, and frontend-facing APIs move into `upm-core`.
- The `upm` crate becomes a thin CLI frontend over `upm-core`.
- A future `upm-ui` crate will become a GUI frontend over the same `upm-core` application boundary.
- The rename is a hard cutover. Legacy `AIM_*` runtime interfaces are removed rather than preserved.
- The declarative package file starts in a hybrid mode and is intended to become the source of truth over time.
- Every `upm` invocation should detect drift between declared state and observed system state, then auto-sync metadata as needed.
- Phase 2 is Linux implementation first, with macOS-oriented provider abstractions and packaging seams designed now.
- Feature delivery should happen as vertical slices rather than a single large refactor.
- The `upm` branch is the effective trunk for this evolution work and should be treated as the integration base for future UPM feature branches and worktrees.
## Architectural Destination
### Workspace Shape
The intended workspace shape after the initial refactor is:
- `upm`: thin CLI frontend, ratatui config UI, command routing, presentation.
- `upm-ui`: future GUI frontend over the same application core.
- `upm-core`: headless application layer, public application facade, internal orchestration services, module registry, state model, declarative sync engine, ranking, policies, and frontend-agnostic APIs.
- `upm-appimage`: AppImage package-manager module extracted from the current `aim-core` implementation.
- future module crates: `upm-pacman`, `upm-aur`, `upm-flatpak`, `upm-cargo`, `upm-npm`, and later macOS or Windows-specific modules.
### Module Model
UPM should stay modular in both code and packaging:
- modules can be enabled or disabled by config
- providers can be ranked by user priority
- distro packaging can offer grouped installs such as `upm-full`
- lighter installs can ship only the core and selected modules
This means module capabilities, discovery, search, install, remove, inspect, and sync behavior need stable interfaces in `upm-core` rather than package-manager-specific branching in the CLI or GUI.
The public API shape should be one application facade in `upm-core`, backed by smaller internal services. Frontends should call the facade; they should not compose lower-level services or talk directly to modules.
### State Model
The long-term model is declarative and config-first, but Phase 2 begins with a hybrid approach:
- `upm`-managed actions update the declarative config directly
- `upm update` and normal command entrypoints can inspect live system state
- drift detection reconciles unmanaged or changed packages into the config representation
- over time the config becomes authoritative and reconciliation becomes stricter
## Phase 2 Milestones
### Milestone 0: Rename And Split Foundation
Deliver the naming and ownership transition without changing product scope yet.
Goals:
- rename workspace crates and package outputs from `aim` to `upm`
- create `upm-core` by extracting reusable infrastructure from `aim-core`
- reduce the CLI crate to a frontend over headless APIs
- isolate current AppImage-specific logic into `upm-appimage`
- remove direct AppImage composition from the CLI and move module composition into `upm-core`
- preserve current AppImage functionality and tests during the move
Exit criteria:
- `upm` binary replaces `aim`
- workspace builds under new crate names
- AppImage flows still work end-to-end through the new layering
### Milestone 1: AppImage On The New Core
Make the current AppImage implementation the first real package-manager module on the modular architecture.
Goals:
- establish `upm-core` as the sole application boundary for CLI and future GUI frontends
- validate the module contract using AppImage as the reference implementation
- move AppImage-specific acquisition backends behind `upm-appimage`
- prove the CLI can treat AppImage as just one enabled module
Exit criteria:
- AppImage support is no longer special-cased as the whole product
- CLI command paths run through the `upm-core` application facade
- AppImage acquisition minutia no longer lives as top-level application concepts in `upm-core`
### Milestone 2: Linux Native Sources
Add the first non-AppImage providers as Linux-focused vertical slices.
Initial supported sources:
- pacman
- AUR
- Flatpak
- cargo global installs
- npm global installs
Goals:
- implement provider discovery and capability coverage for each source
- normalize package identity, version, installed state, and update candidates across providers
- support search, inspect, install, remove, and update planning where each provider can do so safely
- capture provider limitations explicitly rather than faking uniformity
Exit criteria:
- multi-source search works across enabled providers
- installs and removals work through a consistent command model
- provider-specific metadata is normalized enough for ranking and sync
### Milestone 3: Provider Priority And Config UX
Expose modularity directly in the terminal interface.
Goals:
- build a ratatui configuration menu
- allow enablement and disablement per provider
- allow explicit search and install priority ordering
- allow configuration of module weight, source preference, and future policy toggles
- make search ranking obey configured priority instead of hard-coded source bias
Exit criteria:
- users can manage provider selection and ranking from the TUI
- search results are explainable in terms of configured preference order
### Milestone 4: Declarative Package State And Drift Sync
Introduce the nix-like experience incrementally.
Goals:
- define the declarative package file format in `config.toml` or a closely related managed file
- track installed packages by provider in a normalized state model
- implement `upm update` as both package refresh and state reconciliation
- scan currently installed packages from supported providers and build or refresh the declared package set
- auto-sync detected drift during any `upm` command invocation
Phase 2 intent:
- begin in hybrid mode
- move steadily toward config-first behavior
- avoid destructive reconciliation until provider semantics are trustworthy
Exit criteria:
- users can bootstrap a declarative package definition from the current machine state
- repeated `upm` runs keep declared and observed state aligned
- state drift is surfaced clearly and reconciled predictably
### Milestone 5: Packaging, Distribution, And Platform Seams
Make the modular architecture real at packaging time, not just in code.
Goals:
- define packaging layout for standalone core, selected modules, and full installs
- support distro-level grouped packages such as `upm-full`
- ensure unsupported modules degrade cleanly on the wrong OS or distro
- add macOS provider and packaging seams to `upm-core` even if Linux remains the only implemented provider set in Phase 2
Exit criteria:
- module packaging strategy is documented and testable
- cross-platform abstractions exist without blocking Linux delivery
## Phase 2 Non-Goals
The following are explicitly not required to complete Phase 2:
- full macOS module implementation
- Windows module implementation
- GUI frontend delivery
- forcing strict config-authoritative reconciliation before provider behavior is stable
- shipping every conceivable Linux package manager in the first expansion
## Success Criteria
Phase 2 is successful when the project can credibly be described as a modular package manager rather than an AppImage manager with extra adapters.
That means:
- the product name, workspace shape, and binary identity are all `upm`
- AppImage support is only one module among several
- Linux users can manage packages from the first targeted provider set
- ranking and enablement are user-controlled through config and TUI
- declarative state exists, is importable from the live system, and stays synchronized through normal use
- the architecture is ready for GUI and later platform expansion without another major rewrite
## Immediate Planning Order
Implementation plans should follow this order:
1. rename and crate extraction
2. application facade definition and AppImage migration behind a real module boundary
3. Linux provider onboarding in a stable order, likely `pacman` then `Flatpak`, then `AUR`, then `cargo`, then `npm`
4. ratatui configuration and ranking UX
5. declarative state model, drift detection, and `update` sync behavior
6. packaging layout and `upm-full` distribution strategy
This order keeps the refactor defensible, gives each slice a usable product outcome, and avoids locking future provider work into AppImage-era assumptions.

View file

@ -0,0 +1,410 @@
# UPM Rename And Core Extraction Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Rename the product from `aim` to `upm`, remove legacy `aim` runtime interfaces, extract the shared headless backend into `upm-core`, and move AppImage-specific transport and provider logic into a separate `upm-appimage` module without regressing current AppImage workflows.
**Architecture:** Execute this in vertical slices. First rename the workspace, binary, paths, environment interfaces, and tests to `upm` without carrying legacy `aim` compatibility. Next introduce a narrow provider-composition seam in `upm-core` so AppImage-specific add and search logic can move into `upm-appimage` without creating a dependency cycle. Finally rewire the `upm` CLI to assemble built-in providers, update docs, and run full verification.
**Tech Stack:** Rust workspace, Cargo manifests, clap CLI, ratatui frontend crate, core domain/app modules, fixture-backed provider tests, workspace-wide `cargo test` and `cargo clippy`.
---
### Task 1: Rename the workspace, binary, and default runtime paths to `upm`
**Files:**
- Modify: `Cargo.toml`
- Rename: `crates/aim-cli` -> `crates/upm`
- Rename: `crates/aim-core` -> `crates/upm-core`
- Modify: `crates/upm/Cargo.toml`
- Modify: `crates/upm/src/main.rs`
- Modify: `crates/upm/src/lib.rs`
- Modify: `crates/upm/src/cli/args.rs`
- Modify: `crates/upm/src/config.rs`
- Modify: `crates/upm/src/cli/config.rs`
- Modify: `crates/upm-core/Cargo.toml`
- Modify: `crates/upm-core/src/platform/mod.rs`
- Modify: `crates/upm-core/src/integration/paths.rs`
- Modify: `crates/upm-core/src/integration/policy.rs`
- Test: `crates/upm/tests/cli_smoke.rs`
- Test: `crates/upm/tests/cli_commands.rs`
- Test: `crates/upm/tests/config_loading.rs`
- Test: `crates/upm-core/tests/install_paths.rs`
- Test: `crates/upm-core/tests/install_policy.rs`
**Step 1: Write the failing rename expectations**
Update the selected tests to assert:
- the binary name is `upm`
- clap parses `upm` instead of `aim`
- default config path is `~/.config/upm/config.toml`
- default registry path is `~/.local/share/upm/registry.toml`
- default managed payload roots are `.local/lib/upm/appimages` and `/opt/upm/appimages`
- desktop entry filenames use `upm-<stable-id>.desktop`
**Step 2: Run the focused tests to verify failure**
Run:
```bash
cargo test --package aim-cli --test cli_smoke
cargo test --package aim-cli --test config_loading
cargo test --package aim-core --test install_paths
cargo test --package aim-core --test install_policy
```
Expected: FAIL because the workspace still exposes `aim`, `aim-cli`, `aim-core`, and `aim` default paths.
**Step 3: Perform the crate and manifest rename**
Run:
```bash
git mv crates/aim-cli crates/upm
git mv crates/aim-core crates/upm-core
```
Then update:
- workspace members and default members in `Cargo.toml`
- package names to `upm` and `upm-core`
- binary name to `upm`
- crate imports from `aim_core` to `upm_core`
- crate imports from `aim_cli` to `upm`
- clap command name from `aim` to `upm`
- default config, registry, payload-root, and desktop-entry paths to `upm`
**Step 4: Run the focused tests to verify pass**
Run:
```bash
cargo test --package upm --test cli_smoke
cargo test --package upm --test cli_commands
cargo test --package upm --test config_loading
cargo test --package upm-core --test install_paths
cargo test --package upm-core --test install_policy
```
Expected: PASS.
**Step 5: Commit**
```bash
git add Cargo.toml crates/upm crates/upm-core
git commit -m "refactor: rename workspace to upm"
```
### Task 2: Remove remaining `aim`-named runtime interfaces
**Files:**
- Modify: `crates/upm/src/config.rs`
- Modify: `crates/upm/src/cli/config.rs`
- Modify: `crates/upm/src/lib.rs`
- Modify: `crates/upm/src/ui/prompt.rs`
- Modify: `crates/upm-core/src/platform/mod.rs`
- Modify: `crates/upm-core/src/source/github.rs`
- Modify: `crates/upm-core/src/source/appimagehub.rs`
- Modify: `crates/upm-core/src/integration/refresh.rs`
- Test: `crates/upm/tests/config_loading.rs`
**Step 1: Write the failing strict-rename expectations**
Update representative tests to cover:
- config lookup uses `UPM_CONFIG_PATH`
- registry lookup uses `UPM_REGISTRY_PATH`
- old `AIM_*` config and registry overrides are ignored
- tracking preference uses `UPM_TRACKING_PREFERENCE`
- old `AIM_TRACKING_PREFERENCE` is ignored
- provider fixture execution uses the renamed `UPM_*` interfaces through CLI-facing tests
- managed install and summary output use `upm` paths and desktop prefixes
**Step 2: Run the focused tests to verify failure**
Run:
```bash
cargo test --package upm --test config_loading
cargo test --package upm --test search_cli
cargo test --package upm --test end_to_end_cli
cargo test --package upm --test ui_summary
```
Expected: FAIL because representative CLI and config flows still depend on old `aim` names.
**Step 3: Remove the remaining `aim` interfaces**
Update the codebase so renamed runtime interfaces are consistently `upm`:
- environment variable names use `UPM_*`
- helper/debug prefixes print `[upm]`
- GitHub user agent identifies as `upm/0.1`
- old `aim` compatibility reads are removed instead of preserved
**Step 4: Run the focused tests to verify pass**
Run:
```bash
cargo test --package upm --test config_loading
cargo test --package upm --test search_cli
cargo test --package upm --test end_to_end_cli
cargo test --package upm --test ui_summary
```
Expected: PASS.
**Step 5: Commit**
```bash
git add crates/upm/src/config.rs crates/upm/src/cli/config.rs crates/upm/src/lib.rs crates/upm/src/ui/prompt.rs crates/upm-core/src/platform/mod.rs crates/upm-core/src/source/github.rs crates/upm-core/src/source/appimagehub.rs crates/upm-core/src/integration/refresh.rs crates/upm/tests/config_loading.rs
git commit -m "refactor: remove remaining aim runtime interfaces"
```
### Task 3: Add a provider-composition seam in `upm-core`
**Files:**
- Create: `crates/upm-core/src/app/providers.rs`
- Modify: `crates/upm-core/src/app/mod.rs`
- Modify: `crates/upm-core/src/app/add.rs`
- Modify: `crates/upm-core/src/app/search.rs`
- Modify: `crates/upm-core/src/lib.rs`
- Create: `crates/upm-core/tests/provider_registry.rs`
**Step 1: Write the failing provider-composition tests**
Create `crates/upm-core/tests/provider_registry.rs` with two focused tests:
- `build_search_results_with_registered_providers_uses_external_hits` using a stub external search provider
- `build_add_plan_with_registered_providers_delegates_appimagehub_like_sources` using a stub external add provider that returns a fixed artifact and release
The tests should prove that `upm-core` orchestration can consume provider-supplied search and add behavior without hardcoding AppImage-specific modules.
**Step 2: Run the focused tests to verify failure**
Run:
```bash
cargo test --package upm-core --test provider_registry
```
Expected: FAIL because the orchestration layer still hardcodes AppImageHub in `app/add.rs` and `app/search.rs`.
**Step 3: Introduce the narrow composition API**
Create `crates/upm-core/src/app/providers.rs` with minimal types:
- `pub trait ExternalAddProvider`
- `pub struct ExternalAddResolution`
- `pub struct ProviderRegistry<'a>`
Requirements:
- `ProviderRegistry` carries `search_providers: Vec<&'a dyn SearchProvider>` and `external_add_providers: Vec<&'a dyn ExternalAddProvider>`
- `build_search_results` can delegate to `build_search_results_with` using providers supplied by the caller
- `build_add_plan_with_reporter_and_policy` gets a sibling entrypoint that accepts a `ProviderRegistry`
- core built-ins remain in `upm-core`; only AppImage-specific exact-resolution and search logic should move behind the new registry seam
Keep the interface intentionally small. Do not attempt plugin loading or dynamic discovery yet.
**Step 4: Run the focused tests to verify pass**
Run:
```bash
cargo test --package upm-core --test provider_registry
```
Expected: PASS.
**Step 5: Commit**
```bash
git add crates/upm-core/src/app/providers.rs crates/upm-core/src/app/mod.rs crates/upm-core/src/app/add.rs crates/upm-core/src/app/search.rs crates/upm-core/src/lib.rs crates/upm-core/tests/provider_registry.rs
git commit -m "refactor: add provider composition seam to upm-core"
```
### Task 4: Extract AppImage-specific logic into `upm-appimage`
**Files:**
- Modify: `Cargo.toml`
- Create: `crates/upm-appimage/Cargo.toml`
- Create: `crates/upm-appimage/src/lib.rs`
- Create: `crates/upm-appimage/src/add.rs`
- Create: `crates/upm-appimage/src/search.rs`
- Create: `crates/upm-appimage/src/source/mod.rs`
- Create: `crates/upm-appimage/src/source/appimagehub.rs`
- Modify: `crates/upm-core/src/adapters/mod.rs`
- Modify: `crates/upm-core/src/source/mod.rs`
- Modify: `crates/upm-core/src/app/add.rs`
- Modify: `crates/upm-core/src/app/search.rs`
- Create: `crates/upm-appimage/tests/appimagehub_search.rs`
- Modify: `crates/upm-core/tests/adapter_contract.rs`
- Modify: `crates/upm-core/tests/adapter_smoke.rs`
**Step 1: Write the failing extracted-module test**
Create `crates/upm-appimage/tests/appimagehub_search.rs` by moving the current AppImageHub search expectations out of `upm-core` and updating imports to target the new crate.
Also update the affected `upm-core` tests so they no longer import `AppImageHubAdapter` from `upm-core` directly.
**Step 2: Run the focused tests to verify failure**
Run:
```bash
cargo test --package upm-appimage --test appimagehub_search
```
Expected: FAIL because the new crate does not exist yet.
**Step 3: Create the new crate and move AppImageHub implementation into it**
Move the AppImageHub-specific code into the new crate:
- transport and fixture logic from `upm-core/src/source/appimagehub.rs`
- AppImage-backed exact-resolution logic into `crates/upm-appimage/src/add.rs` implementing `ExternalAddProvider`
- AppImageHub search provider logic out of `upm-core/src/app/search.rs` into `crates/upm-appimage/src/search.rs`
`upm-appimage` should depend on `upm-core`, not the other way around.
Leave `SourceKind::AppImageHub`, `SourceInputKind::AppImageHub*`, and `NormalizedSourceKind::AppImageHub` in `upm-core` for this milestone. The deeper provider/domain generalization belongs to the next milestone.
**Step 4: Remove direct AppImageHub wiring from `upm-core`**
Update `upm-core` so it no longer declares:
- `pub mod appimagehub;` in `src/adapters/mod.rs`
- `pub mod appimagehub;` in `src/source/mod.rs`
- built-in AppImageHub search-provider construction in `src/app/search.rs`
- direct `AppImageHubAdapter` imports in `src/app/add.rs`
After this step, AppImage behavior should exist only through the provider registry seam from Task 3.
**Step 5: Run the focused tests to verify pass**
Run:
```bash
cargo test --package upm-appimage --test appimagehub_search
cargo test --package upm-core --test adapter_contract
cargo test --package upm-core --test adapter_smoke
```
Expected: PASS.
**Step 6: Commit**
```bash
git add Cargo.toml crates/upm-appimage crates/upm-core/src/adapters/mod.rs crates/upm-core/src/source/mod.rs crates/upm-core/src/app/add.rs crates/upm-core/src/app/search.rs crates/upm-core/tests/adapter_contract.rs crates/upm-core/tests/adapter_smoke.rs
git commit -m "refactor: extract appimage support into upm-appimage"
```
### Task 5: Rewire the `upm` CLI to assemble built-in providers from modules
**Files:**
- Modify: `crates/upm/Cargo.toml`
- Create: `crates/upm/src/providers.rs`
- Modify: `crates/upm/src/lib.rs`
- Test: `crates/upm/tests/search_cli.rs`
- Test: `crates/upm/tests/end_to_end_cli.rs`
- Test: `crates/upm/tests/ui_summary.rs`
**Step 1: Write the failing CLI integration expectations**
Update CLI integration tests to prove that:
- `upm search firefox` still includes AppImageHub results
- direct `upm appimagehub/2338455` install flow still succeeds through the CLI
- the final summary output still renders the new `upm`-prefixed paths and desktop-entry names
**Step 2: Run the focused tests to verify failure**
Run:
```bash
cargo test --package upm --test search_cli
cargo test --package upm --test end_to_end_cli
cargo test --package upm --test ui_summary
```
Expected: FAIL because `upm` does not yet assemble AppImage providers through the extracted module.
**Step 3: Add CLI-side provider assembly**
Create `crates/upm/src/providers.rs` that:
- builds the `ProviderRegistry` for `upm-core`
- registers the `upm-appimage` search provider
- registers the `upm-appimage` external add provider
Update `crates/upm/src/lib.rs` so dispatch paths call the provider-aware core entrypoints instead of hardcoded core defaults.
Do not move progress rendering or config loading into `upm-core`; the CLI remains the presentation layer.
**Step 4: Run the focused tests to verify pass**
Run:
```bash
cargo test --package upm --test search_cli
cargo test --package upm --test end_to_end_cli
cargo test --package upm --test ui_summary
```
Expected: PASS.
**Step 5: Commit**
```bash
git add crates/upm/Cargo.toml crates/upm/src/providers.rs crates/upm/src/lib.rs crates/upm/tests/search_cli.rs crates/upm/tests/end_to_end_cli.rs crates/upm/tests/ui_summary.rs
git commit -m "refactor: compose providers from upm modules"
```
### Task 6: Update docs and run full workspace verification
**Files:**
- Modify: `README.md`
- Modify: `.architecture/overview.md`
- Modify: `.architecture/roadmap.md`
**Step 1: Update product and architecture docs**
Document:
- the workspace rename to `upm`
- `upm-core` as the headless application layer
- `upm-appimage` as the first installable provider module
- compatibility behavior for existing `aim` config and registry locations
- the fact that provider composition now happens in the CLI rather than through hardcoded AppImage paths in `upm-core`
**Step 2: Verify the docs mention the new structure**
Run:
```bash
rg -n "upm-core|upm-appimage|legacy aim|ProviderRegistry|upm" README.md .architecture/overview.md .architecture/roadmap.md
```
Expected: matches showing the renamed crates, provider split, and compatibility note.
**Step 3: Run the full verification suite**
Run:
```bash
cargo fmt --all
cargo test --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
```
Expected: PASS.
**Step 4: Commit**
```bash
git add README.md .architecture/overview.md .architecture/roadmap.md
git commit -m "docs: describe upm core and module split"
```

View file

@ -0,0 +1,288 @@
# AppImage On The New Core Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Make `upm-appimage` the first real package-manager module by moving AppImage-specific acquisition paths behind a module boundary and exposing a single application facade from `upm-core` to the CLI and future GUI.
**Architecture:** `upm-core` becomes the application boundary. It should own a public facade, internal orchestration services, and module registration and composition. `upm` stays a thin frontend and must stop composing AppImage behavior directly. `upm-appimage` becomes the AppImage package-manager module and should absorb AppImageHub plus the other AppImage-producing backends that are still modeled as top-level source concepts in the core.
**Tech Stack:** Rust workspace, `upm`, `upm-core`, `upm-appimage`, Cargo integration tests, fixture-backed provider tests, CLI end-to-end tests.
---
### Task 1: Define the public application facade in `upm-core`
**Files:**
- Modify: `crates/upm-core/src/app/`
- Modify: `crates/upm-core/src/lib.rs`
- Test: `crates/upm-core/tests/`
**Step 1: Write the failing facade expectations**
Add focused tests proving that:
- `upm-core` exposes one public application-facing entrypoint for frontend consumers
- that entrypoint can be constructed without the CLI owning module composition
- the public surface delegates to internal services instead of exposing module wiring details
Keep the assertions about API shape and ownership, not AppImage specifics.
**Step 2: Run the focused tests to verify failure**
Run:
```bash
cargo test --package upm-core
```
Expected: FAIL because the current public surface still assumes narrower provider plumbing and does not expose the intended facade cleanly.
**Step 3: Implement the minimal facade**
Introduce or reshape the public API so `upm-core` exposes a single high-level application facade.
The public boundary should:
- represent product operations such as search, add, show, update, remove, and config handling
- hide module registry and orchestration details from frontends
- stay thin and delegate work to internal services
Do not add dynamic plugin loading yet.
**Step 4: Run the focused tests to verify pass**
Run:
```bash
cargo test --package upm-core
```
Expected: PASS.
**Step 5: Commit**
```bash
git add crates/upm-core/src crates/upm-core/tests
git commit -m "feat: add application facade to upm-core"
```
### Task 2: Move module composition out of the CLI and into `upm-core`
**Files:**
- Modify: `crates/upm-core/src/app/`
- Modify: `crates/upm/src/lib.rs`
- Modify: `crates/upm/src/providers.rs`
- Test: `crates/upm-core/tests/`
- Test: `crates/upm/tests/end_to_end_cli.rs`
**Step 1: Write the failing ownership expectations**
Add coverage proving that:
- the CLI does not assemble AppImage module composition directly
- the application facade can build or receive module composition internally
- CLI command paths still behave the same through the new boundary
Prefer one core ownership test and one CLI integration test.
**Step 2: Run the focused tests to verify failure**
Run:
```bash
cargo test --package upm --test end_to_end_cli
```
Expected: FAIL because the CLI still owns direct provider assembly.
**Step 3: Move composition into `upm-core`**
Update the architecture so:
- `upm-core` owns module registration and composition
- the CLI constructs the application facade rather than AppImage-specific registries
- direct module composition in `crates/upm/src/providers.rs` is removed or reduced to generic application bootstrapping
Keep CLI UX, rendering, and summary formatting unchanged.
**Step 4: Run the focused tests to verify pass**
Run:
```bash
cargo test --package upm --test end_to_end_cli
```
Expected: PASS.
**Step 5: Commit**
```bash
git add crates/upm-core/src crates/upm/src/lib.rs crates/upm/src/providers.rs crates/upm/tests/end_to_end_cli.rs
git commit -m "refactor: move module composition into upm-core"
```
### Task 3: Turn `upm-appimage` into the AppImage package-manager boundary
**Files:**
- Modify: `crates/upm-appimage/src/`
- Modify: `crates/upm-core/src/domain/source.rs`
- Modify: `crates/upm-core/src/app/add.rs`
- Modify: `crates/upm-core/src/app/show.rs`
- Modify: `crates/upm-core/src/app/update.rs`
- Test: `crates/upm-appimage/tests/`
- Test: `crates/upm-core/tests/`
**Step 1: Write the failing module-boundary expectations**
Add coverage proving that:
- AppImage-backed acquisition through GitHub, GitLab, SourceForge, direct URLs, and AppImageHub resolves through `upm-appimage`
- `upm-core` no longer treats those AppImage-producing backends as top-level package-manager concepts
- add, show, and update continue to work through normalized module contracts
Prefer module-focused tests plus a small number of core integration tests.
**Step 2: Run the focused tests to verify failure**
Run:
```bash
cargo test --package upm-appimage
cargo test --package upm-core
```
Expected: FAIL because AppImage acquisition paths are still split between the core and the AppImage module.
**Step 3: Move AppImage-specific acquisition logic behind the module**
Reshape the source and module boundary so:
- AppImage-specific GitHub, GitLab, SourceForge, AppImageHub, and direct URL handling lives in `upm-appimage`
- `upm-core` coordinates AppImage work through normalized module contracts
- core source taxonomy is reduced or reframed so package-manager concepts stay above backend minutia
Do not over-generalize for Flatpak or future providers yet. Only extract what the AppImage module demonstrably needs.
**Step 4: Run the focused tests to verify pass**
Run:
```bash
cargo test --package upm-appimage
cargo test --package upm-core
```
Expected: PASS.
**Step 5: Commit**
```bash
git add crates/upm-appimage/src crates/upm-core/src crates/upm-appimage/tests crates/upm-core/tests
git commit -m "refactor: make upm-appimage the appimage module boundary"
```
### Task 4: Route search, add, show, and update through the application facade
**Files:**
- Modify: `crates/upm-core/src/app/`
- Modify: `crates/upm/src/lib.rs`
- Modify: `crates/upm/tests/end_to_end_cli.rs`
- Modify: `crates/upm/tests/ui_summary.rs`
- Test: `crates/upm-core/tests/`
**Step 1: Write the failing facade-routing expectations**
Add end-to-end coverage proving that AppImage support is fully module-driven:
- `search` flows through the public application facade
- `add` flows through the public application facade
- `show` flows through the public application facade
- `update` flows through the public application facade
- user-facing summaries still render truthful `upm` paths and origins
Keep the assertions focused on boundary correctness rather than UI restyling.
**Step 2: Run the focused tests to verify failure**
Run:
```bash
cargo test --package upm --test end_to_end_cli
cargo test --package upm --test ui_summary
```
Expected: FAIL until the facade is the normal command path.
**Step 3: Tighten application-boundary validation**
Update the tests so they prove:
- frontend command handlers call the application facade rather than module-specific helpers
- AppImage is composed only through `upm-core` and `upm-appimage`
- AppImage is not reintroduced as a hardcoded built-in in the CLI
Do not reintroduce package-manager-specific branching in the CLI.
**Step 4: Run the focused tests to verify pass**
Run:
```bash
cargo test --package upm --test end_to_end_cli
cargo test --package upm --test ui_summary
```
Expected: PASS.
**Step 5: Commit**
```bash
git add crates/upm-core/src crates/upm/src/lib.rs crates/upm/tests/end_to_end_cli.rs crates/upm/tests/ui_summary.rs crates/upm-core/tests
git commit -m "refactor: route commands through upm-core facade"
```
### Task 5: Lock the architecture into docs and verify the workspace
**Files:**
- Modify: `.architecture/overview.md`
- Modify: `.architecture/roadmap.md`
- Modify: `README.md`
**Step 1: Update docs for the new module model**
Document:
- `upm-core` as the application boundary
- one public application facade over smaller internal services
- CLI and GUI as thin frontends
- `upm-appimage` as the AppImage package-manager boundary with internal acquisition backends
**Step 2: Verify the docs mention the agreed architecture**
Run:
```bash
rg -n "upm-core|facade|upm-ui|upm-appimage|module" README.md .architecture/overview.md .architecture/roadmap.md
```
Expected: matches describing the application boundary, thin frontends, and module ownership.
**Step 3: Run full verification**
Run:
```bash
cargo fmt --all
cargo test --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
```
Expected: PASS.
**Step 4: Commit**
```bash
git add README.md .architecture/overview.md .architecture/roadmap.md
git commit -m "docs: describe application facade architecture"
```

View file

@ -11,3 +11,7 @@ IMPORTANT TO CHECK BEFORE ANY COMMIT!!
Architecture under `.architecture` must be maintained with each change.
- Security issues stumbled upon or noticed during execution **already in code** must live in `security-issues.md`. Newly added issues during execution or planning should be raised to the user and/or dealt with, instead of growing the list.
- An overview of the workspace, should live in `overview.md`.
- A roadmap of the planned direction & features in `roadmap.md`.
## UPM work
We are currently using the `upm` branch as our "main" for all `upm` work, meaning feature branches/worktrees will merge back into here.

91
Cargo.lock generated
View file

@ -11,41 +11,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "aim-cli"
version = "0.1.0"
dependencies = [
"aim-core",
"assert_cmd",
"clap",
"console 0.16.3",
"crossterm",
"dialoguer",
"indicatif",
"libc",
"predicates",
"ratatui",
"serde",
"tempfile",
"toml",
]
[[package]]
name = "aim-core"
version = "0.1.0"
dependencies = [
"base64",
"fs2",
"md5",
"quick-xml",
"reqwest",
"serde",
"serde_yaml",
"sha2",
"tempfile",
"toml",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@ -1966,6 +1931,62 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "upm"
version = "0.1.0"
dependencies = [
"assert_cmd",
"clap",
"console 0.16.3",
"crossterm",
"dialoguer",
"indicatif",
"libc",
"predicates",
"ratatui",
"serde",
"tempfile",
"toml",
"upm-appimage",
"upm-core",
]
[[package]]
name = "upm-appimage"
version = "0.1.0"
dependencies = [
"quick-xml",
"reqwest",
"serde",
"upm-core",
"upm-module-api",
]
[[package]]
name = "upm-core"
version = "0.1.0"
dependencies = [
"base64",
"fs2",
"md5",
"quick-xml",
"reqwest",
"serde",
"serde_yaml",
"sha2",
"tempfile",
"toml",
"upm-appimage",
"upm-module-api",
]
[[package]]
name = "upm-module-api"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "url"
version = "2.5.8"

View file

@ -1,10 +1,12 @@
[workspace]
members = [
"crates/aim-core",
"crates/aim-cli",
"crates/upm-module-api",
"crates/upm-core",
"crates/upm-appimage",
"crates/upm",
]
default-members = [
"crates/aim-cli",
"crates/upm",
]
resolver = "2"

View file

@ -1,24 +1,26 @@
# aim
AppImage Manager
# upm
Universal Package Manager
`aim` is a Rust Cargo workspace for managing AppImages from multiple source types.
`upm` is a Rust Cargo workspace for a modular package manager with a shared headless application core, thin frontend crates, and package-manager modules.
## Workspace
- `crates/aim-core`: business logic, source adapters, registry, install/update planning
- `crates/aim-cli`: thin terminal frontend for parsing, prompting, and rendering
- `crates/upm-core`: headless application layer for query normalization, orchestration, module registration and composition, registry persistence, install/update planning, and the unified frontend-facing API
- `crates/upm`: thin terminal frontend for argument parsing, config loading, prompting, progress reporting, and summary rendering
- `crates/upm-appimage`: AppImage package-manager module responsible for AppImage-specific acquisition and resolution behavior
- `crates/upm-ui` (planned): GUI frontend over `upm-core`
The split is intentional so a future GUI client can reuse `aim-core` without moving logic out of the shared library.
The split is intentional so future frontends can reuse `upm-core`, while package-manager behavior stays modular instead of being hardcoded into the core or the CLI.
## Commands
```text
aim <QUERY>
aim
aim update
aim list
aim search <QUERY>
aim remove <QUERY>
upm <QUERY>
upm
upm update
upm list
upm search <QUERY>
upm remove <QUERY>
```
## Query Forms
@ -36,22 +38,22 @@ aim remove <QUERY>
## Search
`aim search <QUERY>` is part of v0.9 finalisation.
`upm search <QUERY>` is part of the initial modular module surface.
- search is provider-extensible and currently includes GitHub plus AppImageHub
- search is module-extensible and currently includes the built-in core search path plus AppImage-backed search sources
- search results should resolve to install-ready queries such as `owner/repo` and `appimagehub/<id>`
- the search model is provider-extensible for future phases
- module composition belongs in `upm-core`, not in the CLI frontend
## Scope Overrides
By default `aim` auto-detects whether to use user or system scope. Override that with:
By default `upm` auto-detects whether to use user or system scope. Override that with:
- `--user`
- `--system`
## Config
Runtime config is loaded from `~/.config/aim/config.toml` or `$XDG_CONFIG_HOME/aim/config.toml`.
Runtime config is loaded from `~/.config/upm/config.toml` or `$XDG_CONFIG_HOME/upm/config.toml`.
Example:
@ -63,13 +65,20 @@ allow_http = true
- `allow_http` only permits user-supplied `http://` inputs such as direct URL installs or updates from previously installed direct HTTP origins
- provider-resolved downloads such as AppImageHub artifacts remain HTTPS-only even when `allow_http = true`
## Breaking Rename
- `upm` is a hard rename from `aim`
- runtime overrides now use `UPM_*` names such as `UPM_CONFIG_PATH` and `UPM_REGISTRY_PATH`
- old `AIM_*` runtime overrides are intentionally ignored
- default config and registry locations now live under `upm` paths
## Current Flow Shape
- `aim <QUERY>` installs direct provider matches when available, otherwise falls back to search results, shows live progress on stderr, prints an `Installation Summary` on stdout for installs, and renders an `Installation Review` when tracking needs confirmation
- bare `aim` prints an `Update Review` without mutating the registry
- `aim update` executes the pending updates, streams live status on stderr, then prints an `Update Summary`
- `aim list` renders either `Installed Apps` or `No installed apps yet`
- `aim remove <QUERY>` resolves a registered application name, streams removal progress on stderr, then prints a `Removal Summary`
- `upm <QUERY>` installs direct provider matches when available, otherwise falls back to search results, shows live progress on stderr, prints an `Installation Summary` on stdout for installs, and renders an `Installation Review` when tracking needs confirmation
- bare `upm` prints an `Update Review` without mutating the registry
- `upm update` executes the pending updates, streams live status on stderr, then prints an `Update Summary`
- `upm list` renders either `Installed Apps` or `No installed apps yet`
- `upm remove <QUERY>` resolves a registered application name, streams removal progress on stderr, then prints a `Removal Summary`
## Terminal UX

View file

@ -1,66 +0,0 @@
use aim_cli::config::{CliConfig, ConfigError, SearchConfig, load_from_path};
use tempfile::tempdir;
#[test]
fn missing_config_file_returns_defaults() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
let config = load_from_path(&path).unwrap();
assert_eq!(config, CliConfig::default());
assert_eq!(config.search, SearchConfig::default());
assert!(!config.allow_http);
assert!(config.search.bottom_to_top);
assert!(!config.search.skip_confirmation);
assert_eq!(config.theme.accent, "#b388ff");
assert_eq!(config.theme.accent_secondary, "#d5c2ff");
assert_eq!(config.theme.dim, "#7f7396");
}
#[test]
fn search_section_overrides_defaults() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"allow_http = true\n\n[search]\nbottom_to_top = false\nskip_confirmation = true\n\n[theme]\naccent = \"#9f6bff\"\naccent_secondary = \"#efe7ff\"\ndim = \"#6b6480\"\n",
)
.unwrap();
let config = load_from_path(&path).unwrap();
assert_eq!(
config,
CliConfig {
allow_http: true,
search: SearchConfig {
bottom_to_top: false,
skip_confirmation: true,
},
theme: aim_cli::config::ThemeConfig {
accent: "#9f6bff".to_owned(),
accent_secondary: "#efe7ff".to_owned(),
dim: "#6b6480".to_owned(),
},
}
);
}
#[test]
fn malformed_toml_returns_path_aware_error() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[search\nskip_confirmation = true\n").unwrap();
let error = load_from_path(&path).unwrap_err();
match error {
ConfigError::Parse {
path: error_path, ..
} => {
assert_eq!(error_path, path);
}
other => panic!("expected parse error, got {other:?}"),
}
}

View file

@ -1,21 +0,0 @@
use std::path::Path;
use aim_core::domain::app::InstallScope;
use aim_core::integration::paths::{desktop_entry_path, managed_appimage_path};
#[test]
fn user_scope_path_lands_under_home_managed_dir() {
let path = managed_appimage_path(Path::new("/home/test"), InstallScope::User, "bat");
assert_eq!(
path,
Path::new("/home/test/.local/lib/aim/appimages/bat.AppImage")
);
}
#[test]
fn system_scope_desktop_entry_uses_system_prefix() {
let path = desktop_entry_path(Path::new("/home/test"), InstallScope::System, "bat");
assert_eq!(path, Path::new("/usr/share/applications/aim-bat.desktop"));
}

View file

@ -0,0 +1,17 @@
[package]
name = "upm-appimage"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
path = "src/lib.rs"
[dependencies]
quick-xml.workspace = true
reqwest.workspace = true
serde.workspace = true
upm-module-api = { path = "../upm-module-api" }
[dev-dependencies]
upm-core = { path = "../upm-core" }

View file

@ -0,0 +1,196 @@
use crate::source::appimagehub::{
AppImageHubError, AppImageHubTransport, resolve_appimagehub_item, resolve_appimagehub_item_with,
};
use upm_module_api::adapters::traits::{
AdapterCapabilities, AdapterError, AdapterResolution, AdapterResolveOutcome, SourceAdapter,
};
use upm_module_api::app::providers::{ExternalAddProvider, ExternalAddResolution};
use upm_module_api::domain::source::{
NormalizedSourceKind, ResolvedRelease, SourceInputKind, SourceKind, SourceRef,
};
use upm_module_api::domain::update::{
ArtifactCandidate, ChannelPreference, UpdateChannelKind, UpdateStrategy,
};
pub struct AppImageHubAdapter;
impl AppImageHubAdapter {
pub fn resolve_source_with<T: AppImageHubTransport + ?Sized>(
&self,
source: &SourceRef,
transport: &T,
) -> Result<AdapterResolveOutcome, AdapterError> {
if source.kind != SourceKind::AppImageHub {
return Err(AdapterError::UnsupportedSource);
}
let resolved = resolve_appimagehub_item_with(source, transport)
.map_err(|error| AdapterError::ResolutionFailed(render_appimagehub_error(&error)))?;
match resolved {
Some(item) => Ok(AdapterResolveOutcome::Resolved(AdapterResolution {
source: item.source,
release: ResolvedRelease {
version: item.version,
prerelease: false,
},
})),
None => Ok(AdapterResolveOutcome::NoInstallableArtifact {
source: source.clone(),
}),
}
}
}
impl SourceAdapter for AppImageHubAdapter {
fn id(&self) -> &'static str {
"appimagehub"
}
fn capabilities(&self) -> AdapterCapabilities {
AdapterCapabilities {
supports_search: true,
supports_exact_resolution: true,
}
}
fn repository_source_kind(&self) -> Option<SourceKind> {
Some(SourceKind::AppImageHub)
}
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
let source = resolve_appimagehub_query(query)?;
if source.kind != SourceKind::AppImageHub {
return Err(AdapterError::UnsupportedQuery);
}
Ok(source)
}
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
match resolve_appimagehub_item(source)
.map_err(|error| AdapterError::ResolutionFailed(render_appimagehub_error(&error)))?
{
Some(item) => Ok(AdapterResolution {
source: item.source,
release: ResolvedRelease {
version: item.version,
prerelease: false,
},
}),
None => Err(AdapterError::ResolutionFailed(
"appimagehub item has no installable AppImage artifact".to_owned(),
)),
}
}
fn resolve_supported_source(
&self,
source: &SourceRef,
) -> Result<AdapterResolveOutcome, AdapterError> {
let transport = crate::source::appimagehub::default_transport();
self.resolve_source_with(source, transport.as_ref())
}
}
pub struct AppImageHubAddProvider {
transport: Box<dyn AppImageHubTransport>,
}
impl AppImageHubAddProvider {
pub fn new(transport: Box<dyn AppImageHubTransport>) -> Self {
Self { transport }
}
}
impl ExternalAddProvider for AppImageHubAddProvider {
fn id(&self) -> &'static str {
"appimagehub"
}
fn resolve(&self, source: &SourceRef) -> Result<Option<ExternalAddResolution>, AdapterError> {
if source.kind != SourceKind::AppImageHub {
return Ok(None);
}
let adapter = AppImageHubAdapter;
let resolution = match adapter.resolve_source_with(source, self.transport.as_ref())? {
AdapterResolveOutcome::Resolved(resolution) => resolution,
AdapterResolveOutcome::NoInstallableArtifact { .. } => return Ok(None),
};
let Some(resolved_item) = resolve_appimagehub_item_with(source, self.transport.as_ref())
.map_err(|error| AdapterError::ResolutionFailed(format!("{error:?}")))?
else {
return Ok(None);
};
Ok(Some(ExternalAddResolution {
resolution,
selected_artifact: ArtifactCandidate {
url: resolved_item.download.url.clone(),
version: resolved_item.version.clone(),
arch: resolved_item.download.arch.clone(),
trusted_checksum: None,
weak_checksum_md5: resolved_item.download.md5sum.clone(),
selection_reason: "provider-release".to_owned(),
},
update_strategy: UpdateStrategy {
preferred: ChannelPreference {
kind: UpdateChannelKind::DirectAsset,
locator: resolved_item.download.url.clone(),
reason: "provider-release".to_owned(),
},
alternates: Vec::new(),
},
display_name_hint: Some(resolved_item.title),
}))
}
}
fn render_appimagehub_error(error: &AppImageHubError) -> String {
match error {
AppImageHubError::FixtureItemMissing(id) => {
format!("missing appimagehub fixture item {id}")
}
AppImageHubError::InsecureDownloadUrl(url) => {
format!("insecure appimagehub download url: {url}")
}
AppImageHubError::Parse(error) => error.to_string(),
AppImageHubError::Transport(error) => error.to_string(),
AppImageHubError::UnsupportedSource(locator) => {
format!("unsupported appimagehub source: {locator}")
}
}
}
fn resolve_appimagehub_query(query: &str) -> Result<SourceRef, AdapterError> {
let trimmed = query.trim();
let id = if let Some(id) = trimmed.strip_prefix("appimagehub/") {
id
} else if let Some(id) = trimmed.strip_prefix("https://www.appimagehub.com/p/") {
id
} else if let Some(id) = trimmed.strip_prefix("http://www.appimagehub.com/p/") {
id
} else {
return Err(AdapterError::UnsupportedQuery);
};
if !id.chars().all(|ch| ch.is_ascii_digit()) {
return Err(AdapterError::UnsupportedQuery);
}
Ok(SourceRef {
kind: SourceKind::AppImageHub,
locator: format!("https://www.appimagehub.com/p/{id}"),
input_kind: if trimmed.starts_with("appimagehub/") {
SourceInputKind::AppImageHubShorthand
} else {
SourceInputKind::AppImageHubUrl
},
normalized_kind: NormalizedSourceKind::AppImageHub,
canonical_locator: Some(id.to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
})
}

View file

@ -0,0 +1,6 @@
pub mod add;
pub mod search;
pub mod source;
pub use add::{AppImageHubAdapter, AppImageHubAddProvider};
pub use search::AppImageHubSearchProvider;

View file

@ -0,0 +1,107 @@
use crate::source::appimagehub::{
AppImageHubSearchError, AppImageHubTransport, search_appimagehub_with,
};
use upm_module_api::app::search::{SearchProvider, SearchProviderError};
use upm_module_api::domain::search::{SearchInstallStatus, SearchQuery, SearchResult};
pub struct AppImageHubSearchProvider {
transport: Box<dyn AppImageHubTransport>,
}
impl AppImageHubSearchProvider {
pub fn new(transport: Box<dyn AppImageHubTransport>) -> Self {
Self { transport }
}
}
impl SearchProvider for AppImageHubSearchProvider {
fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError> {
let hits =
search_appimagehub_with(&query.text, query.remote_limit, self.transport.as_ref())
.map_err(|error| {
SearchProviderError::new(
"appimagehub",
&render_appimagehub_search_error(&error),
)
})?;
let normalized_query = normalize_lookup(&query.text);
let mut ranked_hits = hits
.into_iter()
.enumerate()
.map(|(index, hit)| {
(
appimagehub_remote_match_rank(
&normalized_query,
&hit.name,
hit.summary.as_deref(),
),
index,
hit,
)
})
.collect::<Vec<_>>();
ranked_hits.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
Ok(ranked_hits
.into_iter()
.map(|(_, _, hit)| SearchResult {
provider_id: "appimagehub".to_owned(),
display_name: hit.name,
description: hit.summary,
source_locator: hit.detail_page,
install_query: format!("appimagehub/{}", hit.id),
canonical_locator: hit.id,
version: Some(hit.version),
install_status: SearchInstallStatus::Available,
})
.collect())
}
}
fn normalize_lookup(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn appimagehub_remote_match_rank(query: &str, name: &str, summary: Option<&str>) -> u8 {
let name = normalize_lookup(name);
let summary = summary.map(normalize_lookup);
if name == query {
return 0;
}
if name.starts_with(query) {
return 1;
}
if name.contains(query) {
return 2;
}
if summary
.as_deref()
.map(|summary| summary.starts_with(query))
.unwrap_or(false)
{
return 3;
}
if summary
.as_deref()
.map(|summary| summary.contains(query))
.unwrap_or(false)
{
return 4;
}
5
}
fn render_appimagehub_search_error(error: &AppImageHubSearchError) -> String {
match error {
AppImageHubSearchError::Parse(inner) => inner.to_string(),
AppImageHubSearchError::Transport(inner) => inner.to_string(),
}
}

View file

@ -0,0 +1,517 @@
use std::env;
use std::time::Duration;
use upm_module_api::domain::source::SourceRef;
const DEFAULT_APPIMAGEHUB_API_BASE: &str = "https://api.appimagehub.com/ocs/v1/content";
const GLOBAL_FIXTURE_MODE_ENV: &str = "UPM_FIXTURE_MODE";
const FIXTURE_MODE_ENV: &str = "UPM_APPIMAGEHUB_FIXTURE_MODE";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppImageHubDownload {
pub url: String,
pub name: String,
pub package_type: Option<String>,
pub arch: Option<String>,
pub md5sum: Option<String>,
pub version: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppImageHubItem {
pub id: String,
pub name: String,
pub version: String,
pub summary: Option<String>,
pub detail_page: String,
pub tags: Vec<String>,
pub downloads: Vec<AppImageHubDownload>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppImageHubSearchHit {
pub id: String,
pub name: String,
pub version: String,
pub summary: Option<String>,
pub detail_page: String,
pub tags: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ResolvedAppImageHubItem {
pub source: SourceRef,
pub title: String,
pub version: String,
pub download: AppImageHubDownload,
}
pub trait AppImageHubTransport {
fn fetch_item(&self, id: &str) -> Result<AppImageHubItem, AppImageHubError>;
fn search_items(
&self,
query: &str,
limit: usize,
) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError>;
}
pub fn default_transport() -> Box<dyn AppImageHubTransport> {
if env::var(GLOBAL_FIXTURE_MODE_ENV).ok().as_deref() == Some("1")
|| env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1")
{
Box::new(FixtureAppImageHubTransport)
} else {
Box::new(ReqwestAppImageHubTransport::new())
}
}
pub fn resolve_appimagehub_item(
source: &SourceRef,
) -> Result<Option<ResolvedAppImageHubItem>, AppImageHubError> {
let transport = default_transport();
resolve_appimagehub_item_with(source, transport.as_ref())
}
pub fn resolve_appimagehub_item_with<T: AppImageHubTransport + ?Sized>(
source: &SourceRef,
transport: &T,
) -> Result<Option<ResolvedAppImageHubItem>, AppImageHubError> {
let item = transport.fetch_item(source_id(source)?)?;
let Some(download) = item
.downloads
.iter()
.find(|download| is_appimage_download(download))
else {
return Ok(None);
};
validate_download_url(&download.url)?;
Ok(Some(ResolvedAppImageHubItem {
source: source.clone(),
title: item.name.clone(),
version: resolved_version(&item, download),
download: download.clone(),
}))
}
pub fn search_appimagehub(
query: &str,
limit: usize,
) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
let transport = default_transport();
search_appimagehub_with(query, limit, transport.as_ref())
}
pub fn search_appimagehub_with<T: AppImageHubTransport + ?Sized>(
query: &str,
limit: usize,
transport: &T,
) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
transport.search_items(query, limit)
}
pub struct ReqwestAppImageHubTransport {
client: reqwest::blocking::Client,
api_base: String,
}
impl Default for ReqwestAppImageHubTransport {
fn default() -> Self {
Self::new()
}
}
impl ReqwestAppImageHubTransport {
pub fn new() -> Self {
Self {
client: reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("reqwest client should build"),
api_base: env::var("UPM_APPIMAGEHUB_API_BASE")
.unwrap_or_else(|_| DEFAULT_APPIMAGEHUB_API_BASE.to_owned()),
}
}
}
impl AppImageHubTransport for ReqwestAppImageHubTransport {
fn fetch_item(&self, id: &str) -> Result<AppImageHubItem, AppImageHubError> {
let url = format!("{}/data/{id}", self.api_base);
let xml = self
.client
.get(url)
.send()
.map_err(AppImageHubError::Transport)?
.error_for_status()
.map_err(AppImageHubError::Transport)?
.text()
.map_err(AppImageHubError::Transport)?;
parse_item_xml(&xml)
}
fn search_items(
&self,
query: &str,
limit: usize,
) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
let url = format!("{}/data", self.api_base);
let xml = self
.client
.get(url)
.query(&[("search", query), ("pagesize", &limit.to_string())])
.send()
.map_err(AppImageHubSearchError::Transport)?
.error_for_status()
.map_err(AppImageHubSearchError::Transport)?
.text()
.map_err(AppImageHubSearchError::Transport)?;
parse_search_xml(&xml)
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct FixtureAppImageHubTransport;
impl AppImageHubTransport for FixtureAppImageHubTransport {
fn fetch_item(&self, id: &str) -> Result<AppImageHubItem, AppImageHubError> {
fixture_item(id).ok_or_else(|| AppImageHubError::FixtureItemMissing(id.to_owned()))
}
fn search_items(
&self,
query: &str,
limit: usize,
) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
Ok(fixture_search_results(query, limit))
}
}
#[derive(Debug)]
pub enum AppImageHubError {
FixtureItemMissing(String),
InsecureDownloadUrl(String),
Parse(quick_xml::DeError),
Transport(reqwest::Error),
UnsupportedSource(String),
}
#[derive(Debug)]
pub enum AppImageHubSearchError {
Parse(quick_xml::DeError),
Transport(reqwest::Error),
}
#[derive(serde::Deserialize)]
struct OcsSingleResponse {
data: OcsSingleData,
}
#[derive(serde::Deserialize)]
struct OcsSingleData {
content: OcsContent,
}
#[derive(serde::Deserialize)]
struct OcsSearchResponse {
data: OcsSearchData,
}
#[derive(serde::Deserialize)]
struct OcsSearchData {
#[serde(default)]
content: Vec<OcsContent>,
}
#[derive(serde::Deserialize)]
struct OcsContent {
id: String,
name: String,
version: Option<String>,
summary: Option<String>,
detailpage: Option<String>,
tags: Option<String>,
downloadlink1: Option<String>,
downloadname1: Option<String>,
download_package_type1: Option<String>,
download_package_arch1: Option<String>,
downloadmd5sum1: Option<String>,
download_version1: Option<String>,
downloadlink2: Option<String>,
downloadname2: Option<String>,
download_package_type2: Option<String>,
download_package_arch2: Option<String>,
downloadmd5sum2: Option<String>,
download_version2: Option<String>,
downloadlink3: Option<String>,
downloadname3: Option<String>,
download_package_type3: Option<String>,
download_package_arch3: Option<String>,
downloadmd5sum3: Option<String>,
download_version3: Option<String>,
}
fn parse_item_xml(xml: &str) -> Result<AppImageHubItem, AppImageHubError> {
let parsed =
quick_xml::de::from_str::<OcsSingleResponse>(xml).map_err(AppImageHubError::Parse)?;
Ok(content_to_item(parsed.data.content))
}
fn parse_search_xml(xml: &str) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
if !xml.contains("<id>") {
return Ok(Vec::new());
}
let parsed =
quick_xml::de::from_str::<OcsSearchResponse>(xml).map_err(AppImageHubSearchError::Parse)?;
Ok(parsed
.data
.content
.into_iter()
.map(|content| AppImageHubSearchHit {
id: content.id,
name: content.name,
version: normalize_version_text(content.version.as_deref()),
summary: content.summary,
detail_page: content
.detailpage
.unwrap_or_else(|| "https://www.appimagehub.com".to_owned()),
tags: split_tags(content.tags.as_deref()),
})
.collect())
}
fn content_to_item(content: OcsContent) -> AppImageHubItem {
let detail_page = content
.detailpage
.clone()
.unwrap_or_else(|| "https://www.appimagehub.com".to_owned());
let summary = content.summary.clone();
let tags = split_tags(content.tags.as_deref());
let downloads = collect_downloads(&content);
AppImageHubItem {
id: content.id,
name: content.name,
version: normalize_version_text(content.version.as_deref()),
summary,
detail_page,
tags,
downloads,
}
}
fn validate_download_url(url: &str) -> Result<(), AppImageHubError> {
if !url.starts_with("https://") {
return Err(AppImageHubError::InsecureDownloadUrl(url.to_owned()));
}
Ok(())
}
fn collect_downloads(content: &OcsContent) -> Vec<AppImageHubDownload> {
let mut downloads = Vec::new();
for download in [
download_slot(
content.downloadlink1.as_deref(),
content.downloadname1.as_deref(),
content.download_package_type1.as_deref(),
content.download_package_arch1.as_deref(),
content.downloadmd5sum1.as_deref(),
content.download_version1.as_deref(),
),
download_slot(
content.downloadlink2.as_deref(),
content.downloadname2.as_deref(),
content.download_package_type2.as_deref(),
content.download_package_arch2.as_deref(),
content.downloadmd5sum2.as_deref(),
content.download_version2.as_deref(),
),
download_slot(
content.downloadlink3.as_deref(),
content.downloadname3.as_deref(),
content.download_package_type3.as_deref(),
content.download_package_arch3.as_deref(),
content.downloadmd5sum3.as_deref(),
content.download_version3.as_deref(),
),
]
.into_iter()
.flatten()
{
downloads.push(download);
}
downloads
}
fn download_slot(
link: Option<&str>,
name: Option<&str>,
package_type: Option<&str>,
arch: Option<&str>,
md5sum: Option<&str>,
version: Option<&str>,
) -> Option<AppImageHubDownload> {
let url = link?.trim();
if url.is_empty() {
return None;
}
Some(AppImageHubDownload {
url: url.to_owned(),
name: name.unwrap_or("download").trim().to_owned(),
package_type: trim_optional(package_type),
arch: trim_optional(arch),
md5sum: trim_optional(md5sum),
version: trim_optional(version),
})
}
fn trim_optional(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn normalize_version_text(value: Option<&str>) -> String {
let value = value.map(str::trim).filter(|value| !value.is_empty());
match value {
Some("Latest") | Some("latest") | None => "latest".to_owned(),
Some(other) => other.to_owned(),
}
}
fn split_tags(tags: Option<&str>) -> Vec<String> {
tags.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|tag| !tag.is_empty())
.map(ToOwned::to_owned)
.collect()
}
fn source_id(source: &SourceRef) -> Result<&str, AppImageHubError> {
source
.canonical_locator
.as_deref()
.or_else(|| source.locator.rsplit('/').next())
.filter(|value| !value.is_empty())
.ok_or_else(|| AppImageHubError::UnsupportedSource(source.locator.clone()))
}
fn is_appimage_download(download: &AppImageHubDownload) -> bool {
download
.package_type
.as_deref()
.map(|kind| kind.eq_ignore_ascii_case("appimage"))
.unwrap_or(false)
|| download.name.ends_with(".AppImage")
}
fn resolved_version(item: &AppImageHubItem, download: &AppImageHubDownload) -> String {
download
.version
.as_deref()
.map(|value| normalize_version_text(Some(value)))
.filter(|value| value != "latest")
.unwrap_or_else(|| item.version.clone())
}
fn fixture_item(id: &str) -> Option<AppImageHubItem> {
let insecure_http = env::var("UPM_APPIMAGEHUB_FIXTURE_INSECURE_HTTP")
.ok()
.as_deref()
== Some("1");
let bad_md5 = env::var("UPM_APPIMAGEHUB_FIXTURE_BAD_MD5").ok().as_deref() == Some("1");
match id {
"2338455" => Some(AppImageHubItem {
id: "2338455".to_owned(),
name: "Firefox by Mozilla - Official AppImage Edition".to_owned(),
version: "latest".to_owned(),
summary: Some("Take control of your internet with the Firefox browser".to_owned()),
detail_page: "https://www.appimagehub.com/p/2338455".to_owned(),
tags: vec![
"appimage".to_owned(),
"x86-64".to_owned(),
"desktop".to_owned(),
"release-stable".to_owned(),
],
downloads: vec![AppImageHubDownload {
url: if insecure_http {
"http://files06.pling.com/api/files/download/firefox-x86-64.AppImage".to_owned()
} else {
"https://files06.pling.com/api/files/download/firefox-x86-64.AppImage"
.to_owned()
},
name: "firefox-x86-64.AppImage".to_owned(),
package_type: Some("appimage".to_owned()),
arch: Some("x86-64".to_owned()),
md5sum: Some(if bad_md5 {
"00000000000000000000000000000000".to_owned()
} else {
"2a685cf45213d5a2a243273fa68dafa6".to_owned()
}),
version: None,
}],
}),
"2337998" => Some(AppImageHubItem {
id: "2337998".to_owned(),
name: "Example Non-AppImage Package".to_owned(),
version: "latest".to_owned(),
summary: Some("An item that does not expose an AppImage download".to_owned()),
detail_page: "https://www.appimagehub.com/p/2337998".to_owned(),
tags: vec!["desktop".to_owned()],
downloads: vec![AppImageHubDownload {
url: "https://files06.pling.com/api/files/download/example.deb".to_owned(),
name: "example.deb".to_owned(),
package_type: Some("debian-package".to_owned()),
arch: Some("x86-64".to_owned()),
md5sum: None,
version: Some("2.1.1".to_owned()),
}],
}),
_ => None,
}
}
fn fixture_search_results(query: &str, limit: usize) -> Vec<AppImageHubSearchHit> {
let query = query.trim().to_ascii_lowercase();
let fixtures = [
AppImageHubSearchHit {
id: "2338455".to_owned(),
name: "Firefox by Mozilla - Official AppImage Edition".to_owned(),
version: "latest".to_owned(),
summary: Some("Take control of your internet with the Firefox browser".to_owned()),
detail_page: "https://www.appimagehub.com/p/2338455".to_owned(),
tags: vec!["browser".to_owned(), "appimage".to_owned()],
},
AppImageHubSearchHit {
id: "2338484".to_owned(),
name: "Waterfox".to_owned(),
version: "latest".to_owned(),
summary: Some("Open Source, Private Browsing".to_owned()),
detail_page: "https://www.appimagehub.com/p/2338484".to_owned(),
tags: vec!["browser".to_owned(), "appimage".to_owned()],
},
];
fixtures
.into_iter()
.filter(|item| {
item.name.to_ascii_lowercase().contains(&query)
|| item
.tags
.iter()
.any(|tag| tag.to_ascii_lowercase().contains(&query))
})
.take(limit)
.collect()
}

View file

@ -0,0 +1 @@
pub mod appimagehub;

View file

@ -1,12 +1,16 @@
use aim_core::app::search::{
AppImageHubSearchProvider, GitHubSearchProvider, SearchProvider, SearchProviderError,
build_search_results_with,
use upm_appimage::add::{AppImageHubAdapter, AppImageHubAddProvider};
use upm_appimage::search::AppImageHubSearchProvider;
use upm_appimage::source::appimagehub::FixtureAppImageHubTransport;
use upm_core::adapters::traits::AdapterResolveOutcome;
use upm_core::app::providers::ExternalAddProvider;
use upm_core::app::query::resolve_query;
use upm_core::app::search::{
GitHubSearchProvider, SearchProvider, SearchProviderError, build_search_results_with,
};
use aim_core::domain::app::AppRecord;
use aim_core::domain::search::{SearchInstallStatus, SearchQuery, SearchResult};
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use aim_core::source::appimagehub::FixtureAppImageHubTransport;
use aim_core::source::github::FixtureGitHubTransport;
use upm_core::domain::app::AppRecord;
use upm_core::domain::search::{SearchInstallStatus, SearchQuery, SearchResult};
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use upm_core::source::github::FixtureGitHubTransport;
struct StubProvider {
hit: SearchResult,
@ -20,7 +24,7 @@ impl SearchProvider for StubProvider {
#[test]
fn appimagehub_search_provider_maps_hits_to_install_ready_results() {
let provider = AppImageHubSearchProvider::new(&FixtureAppImageHubTransport);
let provider = AppImageHubSearchProvider::new(Box::new(FixtureAppImageHubTransport));
let results = provider.search(&SearchQuery::new("firefox")).unwrap();
@ -34,7 +38,7 @@ fn appimagehub_search_provider_maps_hits_to_install_ready_results() {
#[test]
fn appimagehub_hits_are_annotated_as_installed_by_canonical_id() {
let provider = AppImageHubSearchProvider::new(&FixtureAppImageHubTransport);
let provider = AppImageHubSearchProvider::new(Box::new(FixtureAppImageHubTransport));
let installed = vec![AppRecord {
stable_id: "firefox".to_owned(),
display_name: "Firefox by Mozilla - Official AppImage Edition".to_owned(),
@ -72,7 +76,7 @@ fn appimagehub_hits_are_annotated_as_installed_by_canonical_id() {
#[test]
fn search_can_merge_github_and_appimagehub_providers() {
let github = GitHubSearchProvider::new(&FixtureGitHubTransport);
let appimagehub = AppImageHubSearchProvider::new(&FixtureAppImageHubTransport);
let appimagehub = AppImageHubSearchProvider::new(Box::new(FixtureAppImageHubTransport));
let stub = StubProvider {
hit: SearchResult {
provider_id: "github".to_owned(),
@ -106,3 +110,40 @@ fn search_can_merge_github_and_appimagehub_providers() {
.any(|hit| hit.provider_id == "appimagehub")
);
}
#[test]
fn appimagehub_adapter_resolves_installable_items_through_fixture_transport() {
let adapter = AppImageHubAdapter;
let source = resolve_query("appimagehub/2338455").unwrap();
let resolution = adapter
.resolve_source_with(&source, &FixtureAppImageHubTransport)
.unwrap();
assert!(matches!(
resolution,
AdapterResolveOutcome::Resolved(resolution)
if resolution.source.kind == SourceKind::AppImageHub
&& resolution.source.canonical_locator.as_deref() == Some("2338455")
&& resolution.release.version == "latest"
));
}
#[test]
fn appimagehub_add_provider_resolves_external_add_plan() {
let provider = AppImageHubAddProvider::new(Box::new(FixtureAppImageHubTransport));
let source = resolve_query("appimagehub/2338455").unwrap();
let resolution = provider.resolve(&source).unwrap().unwrap();
assert_eq!(resolution.resolution.source.kind, SourceKind::AppImageHub);
assert_eq!(resolution.resolution.release.version, "latest");
assert_eq!(
resolution.selected_artifact.url,
"https://files06.pling.com/api/files/download/firefox-x86-64.AppImage"
);
assert_eq!(
resolution.display_name_hint.as_deref(),
Some("Firefox by Mozilla - Official AppImage Edition")
);
}

View file

@ -1,5 +1,5 @@
[package]
name = "aim-core"
name = "upm-core"
version.workspace = true
edition.workspace = true
license.workspace = true
@ -17,6 +17,8 @@ serde.workspace = true
serde_yaml.workspace = true
sha2.workspace = true
toml.workspace = true
upm-appimage = { path = "../upm-appimage" }
upm-module-api = { path = "../upm-module-api" }
[dev-dependencies]
tempfile.workspace = true

View file

@ -1,4 +1,3 @@
pub mod appimagehub;
pub mod direct_url;
pub mod github;
pub mod gitlab;
@ -11,14 +10,7 @@ use crate::adapters::traits::SourceAdapter;
use crate::domain::source::SourceRef;
pub fn all_adapter_kinds() -> Vec<&'static str> {
vec![
"appimagehub",
"github",
"gitlab",
"direct-url",
"zsync",
"sourceforge",
]
vec!["github", "gitlab", "direct-url", "zsync", "sourceforge"]
}
pub fn supports_source<A: SourceAdapter + ?Sized>(adapter: &A, source: &SourceRef) -> bool {

View file

@ -0,0 +1 @@
pub use upm_module_api::adapters::traits::*;

View file

@ -3,7 +3,6 @@ use std::fs::{self, File};
use std::io::Read;
use std::path::{Path, PathBuf};
use crate::adapters::appimagehub::AppImageHubAdapter;
use crate::adapters::direct_url::DirectUrlAdapter;
use crate::adapters::gitlab::GitLabAdapter;
use crate::adapters::sourceforge::SourceForgeAdapter;
@ -14,6 +13,7 @@ use crate::app::interaction::{InteractionKind, InteractionRequest};
use crate::app::progress::{
NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter,
};
use crate::app::providers::{ExternalAddResolution, ProviderRegistry};
use crate::app::query::{ResolveQueryError, resolve_query};
use crate::app::scope::{ScopeOverride, resolve_install_scope_with_default};
use crate::domain::app::{AppRecord, InstallMetadata, InstallScope};
@ -25,14 +25,14 @@ use crate::integration::install::{
use crate::integration::policy::{IntegrationMode, resolve_install_policy};
use crate::metadata::parse_document;
use crate::platform::probe_live_host;
use crate::source::appimagehub::resolve_appimagehub_item;
use crate::source::github::{
GitHubDiscoveryError, GitHubTransport, discover_github_candidates_with, http_client_policy,
};
use crate::update::channels::build_channels;
use crate::update::ranking::{rank_channels, select_artifact, to_preference};
const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_FIXTURE_MODE";
const GLOBAL_FIXTURE_MODE_ENV: &str = "UPM_FIXTURE_MODE";
const FIXTURE_MODE_ENV: &str = "UPM_GITHUB_FIXTURE_MODE";
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct AddSecurityPolicy {
@ -42,11 +42,12 @@ pub struct AddSecurityPolicy {
pub fn build_add_plan(query: &str) -> Result<AddPlan, BuildAddPlanError> {
let transport = crate::source::github::default_transport();
let mut reporter = NoopReporter;
build_add_plan_with_reporter_and_policy(
build_add_plan_with_reporter_and_policy_and_registry(
query,
transport.as_ref(),
&mut reporter,
AddSecurityPolicy::default(),
&ProviderRegistry::default(),
)
}
@ -55,11 +56,12 @@ pub fn build_add_plan_with<T: GitHubTransport + ?Sized>(
transport: &T,
) -> Result<AddPlan, BuildAddPlanError> {
let mut reporter = NoopReporter;
build_add_plan_with_reporter_and_policy(
build_add_plan_with_reporter_and_policy_and_registry(
query,
transport,
&mut reporter,
AddSecurityPolicy::default(),
&ProviderRegistry::default(),
)
}
@ -68,11 +70,40 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
transport: &T,
reporter: &mut impl ProgressReporter,
) -> Result<AddPlan, BuildAddPlanError> {
build_add_plan_with_reporter_and_policy(
build_add_plan_with_reporter_and_policy_and_registry(
query,
transport,
reporter,
AddSecurityPolicy::default(),
&ProviderRegistry::default(),
)
}
pub fn build_add_plan_with_registered_providers<T: GitHubTransport + ?Sized>(
query: &str,
transport: &T,
providers: &ProviderRegistry<'_>,
policy: AddSecurityPolicy,
) -> Result<AddPlan, BuildAddPlanError> {
let mut reporter = NoopReporter;
build_add_plan_with_reporter_and_policy_and_registry(
query,
transport,
&mut reporter,
policy,
providers,
)
}
pub fn build_add_plan_with_reporter_and_registered_providers<T: GitHubTransport + ?Sized>(
query: &str,
transport: &T,
reporter: &mut impl ProgressReporter,
providers: &ProviderRegistry<'_>,
policy: AddSecurityPolicy,
) -> Result<AddPlan, BuildAddPlanError> {
build_add_plan_with_reporter_and_policy_and_registry(
query, transport, reporter, policy, providers,
)
}
@ -81,6 +112,22 @@ pub fn build_add_plan_with_reporter_and_policy<T: GitHubTransport + ?Sized>(
transport: &T,
reporter: &mut impl ProgressReporter,
policy: AddSecurityPolicy,
) -> Result<AddPlan, BuildAddPlanError> {
build_add_plan_with_reporter_and_policy_and_registry(
query,
transport,
reporter,
policy,
&ProviderRegistry::default(),
)
}
fn build_add_plan_with_reporter_and_policy_and_registry<T: GitHubTransport + ?Sized>(
query: &str,
transport: &T,
reporter: &mut impl ProgressReporter,
policy: AddSecurityPolicy,
providers: &ProviderRegistry<'_>,
) -> Result<AddPlan, BuildAddPlanError> {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::ResolveQuery,
@ -91,8 +138,7 @@ pub fn build_add_plan_with_reporter_and_policy<T: GitHubTransport + ?Sized>(
let mut interactions = Vec::new();
let mut parsed_metadata = Vec::new();
let mut display_name_hint = None;
let (resolution, selected_artifact, update_strategy) = match source.kind {
let (resolution, selected_artifact, update_strategy, display_name_hint) = match source.kind {
SourceKind::GitHub => {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::DiscoverRelease,
@ -148,6 +194,7 @@ pub fn build_add_plan_with_reporter_and_policy<T: GitHubTransport + ?Sized>(
},
artifact,
strategy,
None,
)
}
SourceKind::GitLab => {
@ -188,59 +235,29 @@ pub fn build_add_plan_with_reporter_and_policy<T: GitHubTransport + ?Sized>(
selection_reason: "provider-release".to_owned(),
};
(resolution, artifact, strategy)
(resolution, artifact, strategy, None)
}
SourceKind::AppImageHub => {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::DiscoverRelease,
message: "discovering release".to_owned(),
});
let adapter = AppImageHubAdapter;
let resolution = match adapter
.resolve_source(&source)
.map_err(|error| BuildAddPlanError::Adapter("appimagehub", error))?
if let Some(external_resolution) =
resolve_registered_external_add_provider(&source, providers)?
{
AdapterResolveOutcome::Resolved(resolution) => resolution,
AdapterResolveOutcome::NoInstallableArtifact { source } => {
return Err(BuildAddPlanError::NoInstallableArtifact { source });
}
};
let resolved_item = resolve_appimagehub_item(&resolution.source)
.map_err(|error| {
BuildAddPlanError::Adapter(
"appimagehub",
crate::adapters::traits::AdapterError::ResolutionFailed(format!(
"{error:?}"
)),
)
})?
.ok_or(BuildAddPlanError::NoInstallableArtifact {
source: resolution.source.clone(),
})?;
display_name_hint = Some(resolved_item.title.clone());
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SelectArtifact,
message: "selecting artifact".to_owned(),
});
let artifact = ArtifactCandidate {
url: resolved_item.download.url.clone(),
version: resolved_item.version.clone(),
arch: resolved_item.download.arch.clone(),
trusted_checksum: None,
weak_checksum_md5: resolved_item.download.md5sum.clone(),
selection_reason: "provider-release".to_owned(),
};
let strategy = UpdateStrategy {
preferred: crate::domain::update::ChannelPreference {
kind: crate::domain::update::UpdateChannelKind::DirectAsset,
locator: resolved_item.download.url.clone(),
reason: "provider-release".to_owned(),
},
alternates: Vec::new(),
};
(resolution, artifact, strategy)
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SelectArtifact,
message: "selecting artifact".to_owned(),
});
(
external_resolution.resolution,
external_resolution.selected_artifact,
external_resolution.update_strategy,
external_resolution.display_name_hint,
)
} else {
return Err(BuildAddPlanError::NoInstallableArtifact { source });
}
}
SourceKind::DirectUrl => {
reporter.report(&OperationEvent::StageChanged {
@ -274,7 +291,7 @@ pub fn build_add_plan_with_reporter_and_policy<T: GitHubTransport + ?Sized>(
alternates: Vec::new(),
};
(resolution, artifact, strategy)
(resolution, artifact, strategy, None)
}
SourceKind::SourceForge => {
reporter.report(&OperationEvent::StageChanged {
@ -315,7 +332,7 @@ pub fn build_add_plan_with_reporter_and_policy<T: GitHubTransport + ?Sized>(
alternates: Vec::new(),
};
(resolution, artifact, strategy)
(resolution, artifact, strategy, None)
}
_ => {
reporter.report(&OperationEvent::StageChanged {
@ -345,7 +362,7 @@ pub fn build_add_plan_with_reporter_and_policy<T: GitHubTransport + ?Sized>(
},
alternates: Vec::new(),
};
(resolution, artifact, strategy)
(resolution, artifact, strategy, None)
}
};
@ -359,6 +376,21 @@ pub fn build_add_plan_with_reporter_and_policy<T: GitHubTransport + ?Sized>(
})
}
fn resolve_registered_external_add_provider(
source: &crate::domain::source::SourceRef,
providers: &ProviderRegistry<'_>,
) -> Result<Option<ExternalAddResolution>, BuildAddPlanError> {
for provider in &providers.external_add_providers {
match provider.resolve(source) {
Ok(Some(resolution)) => return Ok(Some(resolution)),
Ok(None) => continue,
Err(error) => return Err(BuildAddPlanError::Adapter(provider.id(), error)),
}
}
Ok(None)
}
pub fn prefer_latest_tracking(mut plan: AddPlan) -> AddPlan {
if let Some(index) = plan
.update_strategy
@ -465,7 +497,7 @@ pub fn install_app_with_reporter(
install_home,
&policy
.desktop_entry_root
.join(format!("aim-{}.desktop", record.stable_id)),
.join(format!("upm-{}.desktop", record.stable_id)),
);
let icon_path = resolve_target_path(
install_home,
@ -475,7 +507,7 @@ pub fn install_app_with_reporter(
stage: OperationStage::DownloadArtifact,
message: "downloading artifact".to_owned(),
});
let staging_root = install_home.join(".local/share/aim/staging");
let staging_root = install_home.join(".local/share/upm/staging");
let staged_payload_path = staged_appimage_path(&staging_root, &record.stable_id);
let artifact_size_bytes = download_artifact_to_staged_path_with_reporter(
&plan.selected_artifact.url,
@ -630,7 +662,9 @@ fn download_artifact_to_staged_path_with_reporter(
) -> Result<u64, InstallAppError> {
let policy = http_client_policy();
if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") {
if env::var(GLOBAL_FIXTURE_MODE_ENV).ok().as_deref() == Some("1")
|| env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1")
{
let bytes = b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82";
return download_to_staged_path_with_retries(staged_payload_path, reporter, policy, || {
Ok((

View file

@ -0,0 +1,182 @@
use std::path::Path;
use crate::app::add::{
AddPlan, AddSecurityPolicy, BuildAddPlanError, InstalledApp,
build_add_plan_with_registered_providers,
build_add_plan_with_reporter_and_registered_providers, install_app_with_reporter,
};
use crate::app::list::{ListRow, build_list_rows};
use crate::app::progress::ProgressReporter;
use crate::app::providers::ProviderRegistry;
use crate::app::remove::{RemovalResult, RemoveRegisteredAppError, remove_registered_app};
use crate::app::search::{SearchError, SearchProvider, build_search_results_with};
use crate::app::show::build_show_result_with;
use crate::app::update::{
BuildUpdatePlanError, ExecuteUpdatesError, build_update_plan,
execute_updates_with_reporter_and_policy,
};
use crate::domain::app::{AppRecord, InstallScope};
use crate::domain::search::{SearchQuery, SearchResults};
use crate::domain::show::{InstalledShow, ShowResult, ShowResultError};
use crate::domain::update::{UpdateExecutionResult, UpdatePlan};
use crate::source::github::{GitHubTransport, default_transport};
pub struct UpmApp<'a> {
github_transport: Box<dyn GitHubTransport>,
providers: ProviderRegistry<'a>,
}
pub struct UpmAppBuilder<'a> {
github_transport: Option<Box<dyn GitHubTransport>>,
providers: ProviderRegistry<'a>,
}
impl UpmApp<'static> {
pub fn new() -> Self {
Self::builder()
.with_provider_registry(default_provider_registry())
.build()
}
}
impl Default for UpmApp<'static> {
fn default() -> Self {
Self::new()
}
}
impl<'a> UpmApp<'a> {
pub fn builder() -> UpmAppBuilder<'a> {
UpmAppBuilder {
github_transport: None,
providers: ProviderRegistry::default(),
}
}
pub fn search(
&self,
query: &SearchQuery,
installed_apps: &[AppRecord],
) -> Result<SearchResults, SearchError> {
let github_provider =
crate::app::search::GitHubSearchProvider::new(self.github_transport.as_ref());
let mut resolved_providers = vec![&github_provider as &dyn SearchProvider];
resolved_providers.extend(
self.providers
.search_providers
.iter()
.map(|provider| provider.as_ref() as &dyn SearchProvider),
);
build_search_results_with(query, installed_apps, &resolved_providers)
}
pub fn build_add_plan(
&self,
query: &str,
policy: AddSecurityPolicy,
) -> Result<AddPlan, BuildAddPlanError> {
build_add_plan_with_registered_providers(
query,
self.github_transport.as_ref(),
&self.providers,
policy,
)
}
pub fn build_add_plan_with_reporter(
&self,
query: &str,
reporter: &mut impl ProgressReporter,
policy: AddSecurityPolicy,
) -> Result<AddPlan, BuildAddPlanError> {
build_add_plan_with_reporter_and_registered_providers(
query,
self.github_transport.as_ref(),
reporter,
&self.providers,
policy,
)
}
pub fn install_app(
&self,
query: &str,
plan: &AddPlan,
install_home: &Path,
requested_scope: InstallScope,
reporter: &mut impl ProgressReporter,
) -> Result<InstalledApp, crate::app::add::InstallAppError> {
install_app_with_reporter(query, plan, install_home, requested_scope, reporter)
}
pub fn show(
&self,
query: &str,
installed_apps: &[AppRecord],
) -> Result<ShowResult, ShowResultError> {
build_show_result_with(query, installed_apps, self.github_transport.as_ref())
}
pub fn show_all(&self, installed_apps: &[AppRecord]) -> Vec<InstalledShow> {
crate::app::show::build_installed_show_results(installed_apps)
}
pub fn list(&self, apps: &[AppRecord]) -> Vec<ListRow> {
build_list_rows(apps)
}
pub fn build_update_plan(
&self,
apps: &[AppRecord],
) -> Result<UpdatePlan, BuildUpdatePlanError> {
build_update_plan(apps)
}
pub fn execute_updates(
&self,
apps: &[AppRecord],
install_home: &Path,
reporter: &mut impl ProgressReporter,
policy: AddSecurityPolicy,
) -> Result<UpdateExecutionResult, ExecuteUpdatesError> {
execute_updates_with_reporter_and_policy(apps, install_home, reporter, policy)
}
pub fn remove_registered_app(
&self,
query: &str,
apps: &[AppRecord],
install_home: &Path,
) -> Result<RemovalResult, RemoveRegisteredAppError> {
remove_registered_app(query, apps, install_home)
}
}
impl<'a> UpmAppBuilder<'a> {
pub fn with_github_transport(mut self, github_transport: Box<dyn GitHubTransport>) -> Self {
self.github_transport = Some(github_transport);
self
}
pub fn with_provider_registry(mut self, providers: ProviderRegistry<'a>) -> Self {
self.providers = providers;
self
}
pub fn build(self) -> UpmApp<'a> {
UpmApp {
github_transport: self.github_transport.unwrap_or_else(default_transport),
providers: self.providers,
}
}
}
fn default_provider_registry() -> ProviderRegistry<'static> {
ProviderRegistry::default()
.with_search_provider(upm_appimage::AppImageHubSearchProvider::new(
upm_appimage::source::appimagehub::default_transport(),
))
.with_external_add_provider(upm_appimage::AppImageHubAddProvider::new(
upm_appimage::source::appimagehub::default_transport(),
))
}

View file

@ -1,11 +1,15 @@
pub mod add;
pub mod application;
pub mod identity;
pub mod interaction;
pub mod list;
pub mod progress;
pub mod providers;
pub mod query;
pub mod remove;
pub mod scope;
pub mod search;
pub mod show;
pub mod update;
pub use application::{UpmApp, UpmAppBuilder};

View file

@ -0,0 +1 @@
pub use upm_module_api::app::providers::*;

View file

@ -1,35 +1,15 @@
use crate::app::providers::ProviderRegistry;
use crate::domain::app::AppRecord;
use crate::domain::search::{
InstalledSearchMatch, SearchInstallStatus, SearchQuery, SearchResult, SearchResults,
SearchWarning,
};
use crate::source::appimagehub::{
AppImageHubSearchError, AppImageHubTransport, search_appimagehub_with,
};
use crate::source::github::{
GitHubSearchError, GitHubTransport, TransportRelease, default_transport,
search_github_repositories_with,
};
use std::collections::HashSet;
pub trait SearchProvider {
fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchProviderError {
pub provider_id: String,
pub message: String,
}
impl SearchProviderError {
pub fn new(provider_id: &str, message: &str) -> Self {
Self {
provider_id: provider_id.to_owned(),
message: message.to_owned(),
}
}
}
pub use upm_module_api::app::search::{SearchProvider, SearchProviderError};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SearchError {
@ -40,17 +20,31 @@ pub fn build_search_results(
query: &SearchQuery,
installed_apps: &[AppRecord],
) -> Result<SearchResults, SearchError> {
let github_transport = default_transport();
let appimagehub_transport = crate::source::appimagehub::default_transport();
let github_provider = GitHubSearchProvider::new(github_transport.as_ref());
let appimagehub_provider = AppImageHubSearchProvider::new(appimagehub_transport.as_ref());
build_search_results_with(
build_search_results_with_registered_providers(
query,
installed_apps,
&[&github_provider, &appimagehub_provider],
&ProviderRegistry::default(),
)
}
pub fn build_search_results_with_registered_providers(
query: &SearchQuery,
installed_apps: &[AppRecord],
providers: &ProviderRegistry<'_>,
) -> Result<SearchResults, SearchError> {
let github_transport = default_transport();
let github_provider = GitHubSearchProvider::new(github_transport.as_ref());
let mut resolved_providers = vec![&github_provider as &dyn SearchProvider];
resolved_providers.extend(
providers
.search_providers
.iter()
.map(|provider| provider.as_ref() as &dyn SearchProvider),
);
build_search_results_with(query, installed_apps, &resolved_providers)
}
pub fn build_search_results_with(
query: &SearchQuery,
installed_apps: &[AppRecord],
@ -94,58 +88,6 @@ impl<'a, T: GitHubTransport + ?Sized> GitHubSearchProvider<'a, T> {
}
}
pub struct AppImageHubSearchProvider<'a, T: AppImageHubTransport + ?Sized> {
transport: &'a T,
}
impl<'a, T: AppImageHubTransport + ?Sized> AppImageHubSearchProvider<'a, T> {
pub fn new(transport: &'a T) -> Self {
Self { transport }
}
}
impl<T: AppImageHubTransport + ?Sized> SearchProvider for AppImageHubSearchProvider<'_, T> {
fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError> {
let hits = search_appimagehub_with(&query.text, query.remote_limit, self.transport)
.map_err(|error| {
SearchProviderError::new("appimagehub", &render_appimagehub_search_error(&error))
})?;
let normalized_query = normalize_lookup(&query.text);
let mut ranked_hits = hits
.into_iter()
.enumerate()
.map(|(index, hit)| {
(
appimagehub_remote_match_rank(
&normalized_query,
&hit.name,
hit.summary.as_deref(),
),
index,
hit,
)
})
.collect::<Vec<_>>();
ranked_hits.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
Ok(ranked_hits
.into_iter()
.map(|(_, _, hit)| SearchResult {
provider_id: "appimagehub".to_owned(),
display_name: hit.name,
description: hit.summary,
source_locator: hit.detail_page,
install_query: format!("appimagehub/{}", hit.id),
canonical_locator: hit.id,
version: Some(hit.version),
install_status: SearchInstallStatus::Available,
})
.collect())
}
}
impl<T: GitHubTransport + ?Sized> SearchProvider for GitHubSearchProvider<'_, T> {
fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError> {
let name_only_query = format!("{} in:name", query.text);
@ -392,45 +334,3 @@ fn render_github_search_error(error: &GitHubSearchError) -> String {
GitHubSearchError::Transport(inner) => inner.to_string(),
}
}
fn appimagehub_remote_match_rank(query: &str, name: &str, summary: Option<&str>) -> u8 {
let name = normalize_lookup(name);
let summary = summary.map(normalize_lookup);
if name == query {
return 0;
}
if name.starts_with(query) {
return 1;
}
if name.contains(query) {
return 2;
}
if summary
.as_deref()
.map(|summary| summary.starts_with(query))
.unwrap_or(false)
{
return 3;
}
if summary
.as_deref()
.map(|summary| summary.contains(query))
.unwrap_or(false)
{
return 4;
}
5
}
fn render_appimagehub_search_error(error: &AppImageHubSearchError) -> String {
match error {
AppImageHubSearchError::Parse(inner) => inner.to_string(),
AppImageHubSearchError::Transport(inner) => inner.to_string(),
}
}

View file

@ -291,7 +291,7 @@ fn stage_existing_installation(
}
let stage_dir = install_home
.join(".local/share/aim/rollback")
.join(".local/share/upm/rollback")
.join(&app.stable_id);
fs::create_dir_all(&stage_dir)
.map_err(|error| format!("failed to create rollback staging directory: {error}"))?;

View file

@ -0,0 +1 @@
pub use upm_module_api::domain::search::*;

View file

@ -0,0 +1 @@
pub use upm_module_api::domain::source::*;

View file

@ -1,4 +1,7 @@
use crate::domain::app::AppRecord;
pub use upm_module_api::domain::update::{
ArtifactCandidate, ChannelPreference, UpdateChannelKind, UpdateStrategy,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum ParsedMetadataKind {
@ -31,25 +34,6 @@ pub struct ParsedMetadata {
pub confidence: u8,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum UpdateChannelKind {
GitHubReleases,
ElectronBuilder,
Zsync,
DirectAsset,
}
impl UpdateChannelKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::GitHubReleases => "github-releases",
Self::ElectronBuilder => "electron-builder",
Self::Zsync => "zsync",
Self::DirectAsset => "direct-asset-lineage",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct UpdateChannel {
pub kind: UpdateChannelKind,
@ -66,30 +50,6 @@ pub struct UpdateChannel {
pub prerelease: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct ChannelPreference {
pub kind: UpdateChannelKind,
pub locator: String,
pub reason: String,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct UpdateStrategy {
pub preferred: ChannelPreference,
#[serde(default)]
pub alternates: Vec<ChannelPreference>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ArtifactCandidate {
pub url: String,
pub version: String,
pub arch: Option<String>,
pub trusted_checksum: Option<String>,
pub weak_checksum_md5: Option<String>,
pub selection_reason: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UpdatePlan {
pub items: Vec<PlannedUpdate>,

View file

@ -11,7 +11,7 @@ pub fn managed_appimage_path(home_dir: &Path, scope: InstallScope, app_id: &str)
}
pub fn desktop_entry_path(home_dir: &Path, scope: InstallScope, app_id: &str) -> PathBuf {
scope_applications_dir(home_dir, scope).join(format!("aim-{app_id}.desktop"))
scope_applications_dir(home_dir, scope).join(format!("upm-{app_id}.desktop"))
}
pub fn icon_path(home_dir: &Path, scope: InstallScope, app_id: &str) -> PathBuf {

View file

@ -37,7 +37,7 @@ pub fn resolve_install_policy(
(DistroFamily::Immutable, InstallScope::System) if capabilities.is_immutable => {
Ok(InstallPolicy {
scope: InstallScope::User,
payload_root: PathBuf::from(".local/lib/aim/appimages"),
payload_root: PathBuf::from(".local/lib/upm/appimages"),
desktop_entry_root: PathBuf::from(".local/share/applications"),
icon_root: PathBuf::from(".local/share/icons/hicolor/256x256/apps"),
integration_mode: IntegrationMode::Degraded,
@ -57,7 +57,7 @@ pub fn resolve_install_policy(
}),
_ => Ok(InstallPolicy {
scope: InstallScope::User,
payload_root: PathBuf::from(".local/lib/aim/appimages"),
payload_root: PathBuf::from(".local/lib/upm/appimages"),
desktop_entry_root: PathBuf::from(".local/share/applications"),
icon_root: PathBuf::from(".local/share/icons/hicolor/256x256/apps"),
integration_mode: if capabilities.has_desktop_session {

View file

@ -62,7 +62,7 @@ fn icon_theme_root(icon_path: &Path) -> PathBuf {
}
fn audit_helper(helper: &Path, args: &[&Path]) {
if env::var("AIM_DEBUG_EXTERNAL_HELPERS").ok().as_deref() != Some("1") {
if env::var("UPM_DEBUG_EXTERNAL_HELPERS").ok().as_deref() != Some("1") {
return;
}
@ -72,7 +72,7 @@ fn audit_helper(helper: &Path, args: &[&Path]) {
.collect::<Vec<_>>()
.join(" ");
eprintln!(
"[aim] helper exec: {}{}{}",
"[upm] helper exec: {}{}{}",
helper.display(),
if rendered_args.is_empty() { "" } else { " " },
rendered_args
@ -80,23 +80,23 @@ fn audit_helper(helper: &Path, args: &[&Path]) {
}
fn audit_helper_status(helper: &Path, code: Option<i32>) {
if env::var("AIM_DEBUG_EXTERNAL_HELPERS").ok().as_deref() != Some("1") {
if env::var("UPM_DEBUG_EXTERNAL_HELPERS").ok().as_deref() != Some("1") {
return;
}
match code {
Some(code) => eprintln!("[aim] helper exit: {} code={code}", helper.display()),
Some(code) => eprintln!("[upm] helper exit: {} code={code}", helper.display()),
None => eprintln!(
"[aim] helper exit: {} terminated by signal",
"[upm] helper exit: {} terminated by signal",
helper.display()
),
}
}
fn audit_helper_failure(helper: &Path, error: &str) {
if env::var("AIM_DEBUG_EXTERNAL_HELPERS").ok().as_deref() != Some("1") {
if env::var("UPM_DEBUG_EXTERNAL_HELPERS").ok().as_deref() != Some("1") {
return;
}
eprintln!("[aim] helper failure: {} error={error}", helper.display());
eprintln!("[upm] helper failure: {} error={error}", helper.display());
}

View file

@ -7,3 +7,6 @@ pub mod platform;
pub mod registry;
pub mod source;
pub mod update;
pub use app::providers::{ExternalAddProvider, ExternalAddResolution, ProviderRegistry};
pub use app::{UpmApp, UpmAppBuilder};

View file

@ -71,7 +71,7 @@ fn is_writable_dir(path: &Path) -> bool {
return false;
}
let probe_path = path.join(".aim-write-test");
let probe_path = path.join(".upm-write-test");
let result = OpenOptions::new()
.create(true)
.write(true)

View file

@ -10,11 +10,11 @@ pub use crate::domain::app::InstallScope;
pub use capabilities::{DesktopHelpers, HostCapabilities, WritableRoots};
pub use distro::{DistroFamily, detect_distro_family};
const OS_RELEASE_PATH_ENV: &str = "AIM_OS_RELEASE_PATH";
const HELPER_PATHS_ENV: &str = "AIM_HELPER_PATHS";
const OS_RELEASE_PATH_ENV: &str = "UPM_OS_RELEASE_PATH";
const HELPER_PATHS_ENV: &str = "UPM_HELPER_PATHS";
pub fn user_managed_appimages_dir(home_dir: &Path) -> PathBuf {
home_dir.join(".local/lib/aim/appimages")
home_dir.join(".local/lib/upm/appimages")
}
pub fn user_applications_dir(home_dir: &Path) -> PathBuf {
@ -26,7 +26,7 @@ pub fn user_icons_dir(home_dir: &Path) -> PathBuf {
}
pub fn system_managed_appimages_dir() -> PathBuf {
PathBuf::from("/opt/aim/appimages")
PathBuf::from("/opt/upm/appimages")
}
pub fn system_applications_dir() -> PathBuf {

View file

@ -4,7 +4,8 @@ use std::time::Duration;
use crate::domain::source::SourceRef;
const DEFAULT_APPIMAGEHUB_API_BASE: &str = "https://api.appimagehub.com/ocs/v1/content";
const FIXTURE_MODE_ENV: &str = "AIM_APPIMAGEHUB_FIXTURE_MODE";
const GLOBAL_FIXTURE_MODE_ENV: &str = "UPM_FIXTURE_MODE";
const FIXTURE_MODE_ENV: &str = "UPM_APPIMAGEHUB_FIXTURE_MODE";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppImageHubDownload {
@ -56,9 +57,8 @@ pub trait AppImageHubTransport {
}
pub fn default_transport() -> Box<dyn AppImageHubTransport> {
if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1")
|| env::var("AIM_GITHUB_FIXTURE_MODE").ok().as_deref() == Some("1")
{
if env::var(GLOBAL_FIXTURE_MODE_ENV).ok().as_deref() == Some("1")
|| env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") {
Box::new(FixtureAppImageHubTransport)
} else {
Box::new(ReqwestAppImageHubTransport::new())
@ -129,7 +129,7 @@ impl ReqwestAppImageHubTransport {
.timeout(Duration::from_secs(30))
.build()
.expect("reqwest client should build"),
api_base: env::var("AIM_APPIMAGEHUB_API_BASE")
api_base: env::var("UPM_APPIMAGEHUB_API_BASE")
.unwrap_or_else(|_| DEFAULT_APPIMAGEHUB_API_BASE.to_owned()),
}
}
@ -424,11 +424,11 @@ fn resolved_version(item: &AppImageHubItem, download: &AppImageHubDownload) -> S
}
fn fixture_item(id: &str) -> Option<AppImageHubItem> {
let insecure_http = env::var("AIM_APPIMAGEHUB_FIXTURE_INSECURE_HTTP")
let insecure_http = env::var("UPM_APPIMAGEHUB_FIXTURE_INSECURE_HTTP")
.ok()
.as_deref()
== Some("1");
let bad_md5 = env::var("AIM_APPIMAGEHUB_FIXTURE_BAD_MD5").ok().as_deref() == Some("1");
let bad_md5 = env::var("UPM_APPIMAGEHUB_FIXTURE_BAD_MD5").ok().as_deref() == Some("1");
match id {
"2338455" => Some(AppImageHubItem {

View file

@ -5,7 +5,8 @@ use crate::domain::source::{ResolvedRelease, SourceRef};
use crate::metadata::MetadataDocument;
const DEFAULT_GITHUB_API_BASE: &str = "https://api.github.com";
const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_FIXTURE_MODE";
const GLOBAL_FIXTURE_MODE_ENV: &str = "UPM_FIXTURE_MODE";
const FIXTURE_MODE_ENV: &str = "UPM_GITHUB_FIXTURE_MODE";
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 30;
const DEFAULT_HTTP_MAX_RETRIES: usize = 3;
@ -176,7 +177,9 @@ pub fn search_github_repositories_with<T: GitHubTransport + ?Sized>(
}
pub fn default_transport() -> Box<dyn GitHubTransport> {
if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") {
if env::var(GLOBAL_FIXTURE_MODE_ENV).ok().as_deref() == Some("1")
|| env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1")
{
Box::new(FixtureGitHubTransport)
} else {
Box::new(ReqwestGitHubTransport::new())
@ -200,13 +203,13 @@ impl ReqwestGitHubTransport {
let mut default_headers = reqwest::header::HeaderMap::new();
default_headers.insert(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_static("aim/0.1"),
reqwest::header::HeaderValue::from_static("upm/0.1"),
);
default_headers.insert(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/vnd.github+json"),
);
if let Some(token) = env::var("AIM_GITHUB_TOKEN")
if let Some(token) = env::var("UPM_GITHUB_TOKEN")
.ok()
.or_else(|| env::var("GITHUB_TOKEN").ok())
&& let Ok(value) = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
@ -220,7 +223,7 @@ impl ReqwestGitHubTransport {
.timeout(policy.timeout)
.build()
.expect("reqwest client should build"),
api_base: env::var("AIM_GITHUB_API_BASE")
api_base: env::var("UPM_GITHUB_API_BASE")
.unwrap_or_else(|_| DEFAULT_GITHUB_API_BASE.to_owned()),
}
}

View file

@ -1,3 +1,2 @@
pub mod appimagehub;
pub mod github;
pub mod input;

View file

@ -1,16 +1,14 @@
use aim_core::adapters::appimagehub::AppImageHubAdapter;
use aim_core::adapters::direct_url::DirectUrlAdapter;
use aim_core::adapters::github::GitHubAdapter;
use aim_core::adapters::gitlab::GitLabAdapter;
use aim_core::adapters::sourceforge::SourceForgeAdapter;
use aim_core::adapters::traits::{
use upm_core::adapters::direct_url::DirectUrlAdapter;
use upm_core::adapters::github::GitHubAdapter;
use upm_core::adapters::gitlab::GitLabAdapter;
use upm_core::adapters::sourceforge::SourceForgeAdapter;
use upm_core::adapters::traits::{
AdapterCapabilities, AdapterError, AdapterResolution, AdapterResolveOutcome, SourceAdapter,
};
use aim_core::app::query::resolve_query;
use aim_core::domain::source::{
use upm_core::app::query::resolve_query;
use upm_core::domain::source::{
NormalizedSourceKind, ResolvedRelease, SourceInputKind, SourceKind, SourceRef,
};
use aim_core::source::appimagehub::FixtureAppImageHubTransport;
struct FileArtifactAdapter;
@ -61,60 +59,6 @@ fn adapter_capabilities_can_report_exact_resolution_only() {
assert!(!capabilities.supports_search);
}
#[test]
fn appimagehub_adapter_reports_search_and_exact_resolution_capabilities() {
let adapter = AppImageHubAdapter;
assert_eq!(adapter.id(), "appimagehub");
assert_eq!(
adapter.repository_source_kind(),
Some(SourceKind::AppImageHub)
);
assert_eq!(adapter.exact_source_kind(), None);
assert_eq!(
adapter.capabilities(),
AdapterCapabilities {
supports_search: true,
supports_exact_resolution: true,
}
);
}
#[test]
fn appimagehub_adapter_resolves_installable_items_through_fixture_transport() {
let adapter = AppImageHubAdapter;
let source = resolve_query("appimagehub/2338455").unwrap();
let resolution = adapter
.resolve_source_with(&source, &FixtureAppImageHubTransport)
.unwrap();
assert!(matches!(
resolution,
AdapterResolveOutcome::Resolved(AdapterResolution {
source,
release: ResolvedRelease { version, .. },
}) if source.kind == SourceKind::AppImageHub
&& source.canonical_locator.as_deref() == Some("2338455")
&& version == "latest"
));
}
#[test]
fn appimagehub_adapter_reports_no_installable_artifact_for_non_appimage_items() {
let adapter = AppImageHubAdapter;
let source = resolve_query("appimagehub/2337998").unwrap();
let resolution = adapter
.resolve_source_with(&source, &FixtureAppImageHubTransport)
.unwrap();
assert_eq!(
resolution,
AdapterResolveOutcome::NoInstallableArtifact { source }
);
}
#[test]
fn repository_backed_resolvers_accept_only_their_own_source_kind() {
let github_source = resolve_query("sharkdp/bat").unwrap();

View file

@ -1,14 +1,14 @@
use aim_core::adapters::all_adapter_kinds;
use upm_core::adapters::all_adapter_kinds;
#[test]
fn all_expected_adapter_kinds_are_registered() {
let kinds = all_adapter_kinds();
assert!(kinds.contains(&"appimagehub"));
assert!(kinds.contains(&"github"));
assert!(kinds.contains(&"gitlab"));
assert!(kinds.contains(&"direct-url"));
assert!(kinds.contains(&"zsync"));
assert!(kinds.contains(&"sourceforge"));
assert!(!kinds.contains(&"appimagehub"));
assert!(!kinds.contains(&"custom-json"));
}

View file

@ -0,0 +1,124 @@
use upm_core::adapters::traits::AdapterResolution;
use upm_core::app::UpmApp;
use upm_core::app::add::AddSecurityPolicy;
use upm_core::app::providers::{ExternalAddProvider, ExternalAddResolution, ProviderRegistry};
use upm_core::app::search::{SearchProvider, SearchProviderError};
use upm_core::domain::search::{SearchInstallStatus, SearchQuery, SearchResult};
use upm_core::domain::source::{
NormalizedSourceKind, ResolvedRelease, SourceInputKind, SourceKind, SourceRef,
};
use upm_core::domain::update::{
ArtifactCandidate, ChannelPreference, UpdateChannelKind, UpdateStrategy,
};
use upm_core::source::github::FixtureGitHubTransport;
struct StubSearchProvider;
impl SearchProvider for StubSearchProvider {
fn search(&self, _query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError> {
Ok(vec![SearchResult {
provider_id: "external-search".to_owned(),
display_name: "Firefox Nightly".to_owned(),
description: Some("Provided by facade-owned providers".to_owned()),
source_locator: "https://example.invalid/firefox-nightly".to_owned(),
install_query: "external/firefox-nightly".to_owned(),
canonical_locator: "external/firefox-nightly".to_owned(),
version: Some("2026.03.21".to_owned()),
install_status: SearchInstallStatus::Available,
}])
}
}
struct StubExternalAddProvider;
impl ExternalAddProvider for StubExternalAddProvider {
fn id(&self) -> &'static str {
"stub-appimage"
}
fn resolve(
&self,
source: &SourceRef,
) -> Result<Option<ExternalAddResolution>, upm_core::adapters::traits::AdapterError> {
Ok(
(source.kind == SourceKind::AppImageHub).then(|| ExternalAddResolution {
resolution: AdapterResolution {
source: SourceRef {
kind: SourceKind::AppImageHub,
locator: source.locator.clone(),
input_kind: SourceInputKind::AppImageHubShorthand,
normalized_kind: NormalizedSourceKind::AppImageHub,
canonical_locator: Some("2338455".to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
},
release: ResolvedRelease {
version: "stable".to_owned(),
prerelease: false,
},
},
selected_artifact: ArtifactCandidate {
url: "https://downloads.example.invalid/firefox.AppImage".to_owned(),
version: "stable".to_owned(),
arch: Some("x86_64".to_owned()),
trusted_checksum: None,
weak_checksum_md5: Some("deadbeef".to_owned()),
selection_reason: "provider-release".to_owned(),
},
update_strategy: UpdateStrategy {
preferred: ChannelPreference {
kind: UpdateChannelKind::DirectAsset,
locator: "https://downloads.example.invalid/firefox.AppImage".to_owned(),
reason: "provider-release".to_owned(),
},
alternates: Vec::new(),
},
display_name_hint: Some(
"Firefox by Mozilla - Official AppImage Edition".to_owned(),
),
}),
)
}
}
#[test]
fn upm_app_can_be_constructed_without_cli_owned_module_composition() {
let _app = UpmApp::new();
}
#[test]
fn upm_app_search_delegates_through_the_application_facade() {
let app = UpmApp::builder()
.with_github_transport(Box::new(FixtureGitHubTransport))
.with_provider_registry(
ProviderRegistry::default().with_search_provider(StubSearchProvider),
)
.build();
let results = app.search(&SearchQuery::new("firefox"), &[]).unwrap();
assert!(results.remote_hits.iter().any(|hit| {
hit.provider_id == "external-search" && hit.install_query == "external/firefox-nightly"
}));
}
#[test]
fn upm_app_add_planning_delegates_through_the_application_facade() {
let app = UpmApp::builder()
.with_github_transport(Box::new(FixtureGitHubTransport))
.with_provider_registry(
ProviderRegistry::default().with_external_add_provider(StubExternalAddProvider),
)
.build();
let plan = app
.build_add_plan("appimagehub/2338455", AddSecurityPolicy::default())
.unwrap();
assert_eq!(plan.resolution.source.kind, SourceKind::AppImageHub);
assert_eq!(
plan.selected_artifact.url,
"https://downloads.example.invalid/firefox.AppImage"
);
}

View file

@ -1,8 +1,8 @@
use std::fs;
use aim_core::integration::install::{InstallRequest, PayloadInstallError, execute_install};
use aim_core::platform::DesktopHelpers;
use tempfile::tempdir;
use upm_core::integration::install::{InstallRequest, PayloadInstallError, execute_install};
use upm_core::platform::DesktopHelpers;
const VALID_FIXTURE_SHA512: &str =
"ZZma4ZD+9XB4GGTHCNZu8I92OY02YrEvIG89ZtRNi99W8SZKwWkmGZz/QyNBxqAt0XeiKtcR80/dMnKlwpcIWw==";

View file

@ -2,15 +2,15 @@ use std::fs;
use std::io::{self, Cursor, Read};
use std::time::Duration;
use aim_core::app::add::{
use tempfile::tempdir;
use upm_core::app::add::{
InstallAppError, download_to_staged_path_with_retries,
stream_payload_to_staged_file_with_reporter,
};
use aim_core::app::progress::{NoopReporter, OperationEvent};
use aim_core::integration::install::{InstallRequest, execute_install};
use aim_core::platform::DesktopHelpers;
use aim_core::source::github::HttpClientPolicy;
use tempfile::tempdir;
use upm_core::app::progress::{NoopReporter, OperationEvent};
use upm_core::integration::install::{InstallRequest, execute_install};
use upm_core::platform::DesktopHelpers;
use upm_core::source::github::HttpClientPolicy;
#[test]
fn payload_streaming_writes_staged_file_and_reports_progress() {

View file

@ -1,6 +1,6 @@
use aim_core::app::add::{build_add_plan_with, materialize_app_record, prefer_latest_tracking};
use aim_core::app::query::resolve_query;
use aim_core::source::github::FixtureGitHubTransport;
use upm_core::app::add::{build_add_plan_with, materialize_app_record, prefer_latest_tracking};
use upm_core::app::query::resolve_query;
use upm_core::source::github::FixtureGitHubTransport;
#[test]
fn github_adapter_can_normalize_owner_repo_source() {

View file

@ -1,8 +1,8 @@
use aim_core::app::query::resolve_query;
use aim_core::source::github::{
use std::time::Duration;
use upm_core::app::query::resolve_query;
use upm_core::source::github::{
FixtureGitHubTransport, discover_github_candidates_with, http_client_policy,
};
use std::time::Duration;
#[test]
fn discovery_reports_appimage_assets_and_latest_linux_yml() {

View file

@ -1,5 +1,5 @@
use aim_core::app::identity::{IdentityFallback, resolve_identity};
use aim_core::domain::app::IdentityConfidence;
use upm_core::app::identity::{IdentityFallback, resolve_identity};
use upm_core::domain::app::IdentityConfidence;
#[test]
fn unresolved_identity_can_fall_back_to_url() {
@ -42,6 +42,6 @@ fn identifiers_containing_dot_dot_are_rejected() {
assert_eq!(
error,
aim_core::app::identity::ResolveIdentityError::InvalidStableId
upm_core::app::identity::ResolveIdentityError::InvalidStableId
);
}

View file

@ -1,15 +1,15 @@
use aim_core::app::add::{BuildAddPlanError, build_add_plan_with};
use aim_core::app::query::ResolveQueryError;
use aim_core::app::update::execute_updates;
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use aim_core::domain::source::SourceKind;
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceRef};
use aim_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install};
use aim_core::platform::DesktopHelpers;
use aim_core::source::github::FixtureGitHubTransport;
use std::fs;
use std::sync::Mutex;
use tempfile::tempdir;
use upm_core::app::add::{BuildAddPlanError, build_add_plan_with};
use upm_core::app::query::ResolveQueryError;
use upm_core::app::update::execute_updates;
use upm_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use upm_core::domain::source::SourceKind;
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceRef};
use upm_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install};
use upm_core::platform::DesktopHelpers;
use upm_core::source::github::FixtureGitHubTransport;
static ENV_LOCK: Mutex<()> = Mutex::new(());
@ -27,7 +27,7 @@ fn integration_failure_removes_new_payload_and_generated_files() {
fs::write(&staged_path, b"\x7fELFAppImage").unwrap();
let final_payload_path = payload_root.join("bat.AppImage");
let desktop_entry_path = blocking_path.join("aim-bat.desktop");
let desktop_entry_path = blocking_path.join("upm-bat.desktop");
let error = execute_install(&InstallRequest {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
@ -85,13 +85,13 @@ fn failed_update_restores_tracked_desktop_and_icon_files() {
let root = tempdir().unwrap();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("DISPLAY", ":99");
std::env::set_var("XDG_CURRENT_DESKTOP", "test");
}
let payload_path = root.path().join("tracked/team-app.AppImage");
let desktop_path = root.path().join("tracked/aim-team-app.desktop");
let desktop_path = root.path().join("tracked/upm-team-app.desktop");
let icon_path = root.path().join("tracked/team-app.png");
fs::create_dir_all(payload_path.parent().unwrap()).unwrap();
fs::write(&payload_path, b"previous-payload").unwrap();

View file

@ -1,13 +1,13 @@
use aim_core::app::add::{build_add_plan_with_reporter, install_app_with_reporter};
use aim_core::app::progress::{OperationEvent, OperationStage};
use aim_core::domain::app::InstallScope;
use aim_core::domain::source::{NormalizedSourceKind, SourceKind};
use aim_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install};
use aim_core::platform::DesktopHelpers;
use aim_core::source::github::FixtureGitHubTransport;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
use upm_core::app::add::{build_add_plan_with_reporter, install_app_with_reporter};
use upm_core::app::progress::{OperationEvent, OperationStage};
use upm_core::domain::app::InstallScope;
use upm_core::domain::source::{NormalizedSourceKind, SourceKind};
use upm_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install};
use upm_core::platform::DesktopHelpers;
use upm_core::source::github::FixtureGitHubTransport;
fn write_staged_payload(root: &std::path::Path, name: &str, bytes: &[u8]) -> std::path::PathBuf {
let staged_path = root.join("staging").join(format!("{name}.download"));
@ -32,7 +32,7 @@ fn install_writes_desktop_entry_and_reports_refresh_warning_only() {
trusted_checksum: None,
weak_checksum_md5: None,
desktop: Some(DesktopIntegrationRequest {
desktop_entry_path: &desktop_root.join("aim-bat.desktop"),
desktop_entry_path: &desktop_root.join("upm-bat.desktop"),
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
icon_path: None,
icon_bytes: None,
@ -86,7 +86,7 @@ fn install_executes_refresh_helpers_when_available() {
trusted_checksum: None,
weak_checksum_md5: None,
desktop: Some(DesktopIntegrationRequest {
desktop_entry_path: &desktop_root.join("aim-bat.desktop"),
desktop_entry_path: &desktop_root.join("upm-bat.desktop"),
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
icon_path: Some(&icon_root.join("bat.png")),
icon_bytes: None,
@ -128,7 +128,7 @@ fn install_extracts_icon_from_appimage_payload_when_icon_path_is_requested() {
trusted_checksum: None,
weak_checksum_md5: None,
desktop: Some(DesktopIntegrationRequest {
desktop_entry_path: &desktop_root.join("aim-bat.desktop"),
desktop_entry_path: &desktop_root.join("upm-bat.desktop"),
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
icon_path: Some(&icon_root.join("bat.png")),
icon_bytes: None,
@ -152,7 +152,7 @@ fn install_app_reports_operation_stages_in_order() {
let mut events: Vec<OperationEvent> = Vec::new();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let mut reporter = |event: &OperationEvent| events.push(event.clone());
@ -249,7 +249,7 @@ fn install_app_sanitizes_desktop_entry_display_names() {
let mut reporter = Vec::new();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let mut capture = |event: &OperationEvent| reporter.push(event.clone());
@ -349,7 +349,7 @@ fn gitlab_install_preserves_truthful_gitlab_origin() {
let root = tempdir().unwrap();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let mut reporter = |_event: &OperationEvent| {};
@ -399,7 +399,7 @@ fn direct_url_install_preserves_truthful_direct_url_origin() {
let root = tempdir().unwrap();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let mut reporter = |_event: &OperationEvent| {};
@ -484,7 +484,7 @@ fn sourceforge_latest_download_install_preserves_truthful_origin() {
let root = tempdir().unwrap();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let mut reporter = |_event: &OperationEvent| {};
@ -514,7 +514,7 @@ fn sourceforge_release_folder_install_preserves_truthful_origin() {
let root = tempdir().unwrap();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let mut reporter = |_event: &OperationEvent| {};
@ -577,7 +577,7 @@ fn sourceforge_file_like_release_download_install_preserves_input_but_stores_rel
let root = tempdir().unwrap();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let mut reporter = |_event: &OperationEvent| {};

View file

@ -0,0 +1,28 @@
use std::path::Path;
use upm_core::domain::app::InstallScope;
use upm_core::integration::paths::{desktop_entry_path, managed_appimage_path};
#[test]
fn user_scope_path_lands_under_home_managed_dir() {
let path = managed_appimage_path(Path::new("/home/test"), InstallScope::User, "bat");
assert_eq!(
path,
Path::new("/home/test/.local/lib/upm/appimages/bat.AppImage")
);
}
#[test]
fn system_scope_path_lands_under_opt_upm_dir() {
let path = managed_appimage_path(Path::new("/home/test"), InstallScope::System, "bat");
assert_eq!(path, Path::new("/opt/upm/appimages/bat.AppImage"));
}
#[test]
fn system_scope_desktop_entry_uses_upm_prefix() {
let path = desktop_entry_path(Path::new("/home/test"), InstallScope::System, "bat");
assert_eq!(path, Path::new("/usr/share/applications/upm-bat.desktop"));
}

View file

@ -1,7 +1,7 @@
use aim_core::integration::install::stage_and_commit_payload;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
use upm_core::integration::install::stage_and_commit_payload;
#[test]
fn payload_commit_moves_staged_appimage_into_final_location() {

View file

@ -1,6 +1,6 @@
use aim_core::integration::policy::{IntegrationMode, resolve_install_policy};
use aim_core::platform::{DistroFamily, HostCapabilities, InstallScope};
use std::path::Path;
use upm_core::integration::policy::{IntegrationMode, resolve_install_policy};
use upm_core::platform::{DistroFamily, HostCapabilities, InstallScope};
#[test]
fn immutable_system_request_downgrades_to_user_when_allowed() {
@ -36,7 +36,7 @@ fn system_policy_uses_managed_payload_and_native_integration_roots() {
.unwrap();
assert_eq!(policy.scope, InstallScope::System);
assert_eq!(policy.payload_root, Path::new("/opt/aim/appimages"));
assert_eq!(policy.payload_root, Path::new("/opt/upm/appimages"));
assert_eq!(
policy.desktop_entry_root,
Path::new("/usr/share/applications")

View file

@ -1,5 +1,5 @@
use aim_core::app::scope::{ScopeOverride, resolve_install_scope};
use aim_core::domain::app::InstallScope;
use upm_core::app::scope::{ScopeOverride, resolve_install_scope};
use upm_core::domain::app::InstallScope;
#[test]
fn explicit_scope_override_beats_effective_user() {

View file

@ -1,5 +1,5 @@
use aim_core::domain::update::ParsedMetadataKind;
use aim_core::metadata::{MetadataDocument, parse_document};
use upm_core::domain::update::ParsedMetadataKind;
use upm_core::metadata::{MetadataDocument, parse_document};
#[test]
fn unknown_document_returns_typed_warning_not_panic() {

View file

@ -1,5 +1,5 @@
use aim_core::domain::update::ParsedMetadataKind;
use aim_core::metadata::{MetadataDocument, parse_document};
use upm_core::domain::update::ParsedMetadataKind;
use upm_core::metadata::{MetadataDocument, parse_document};
#[test]
fn parses_latest_linux_yml_into_download_hints() {

View file

@ -1,5 +1,5 @@
use aim_core::domain::update::ParsedMetadataKind;
use aim_core::metadata::{MetadataDocument, parse_document};
use upm_core::domain::update::ParsedMetadataKind;
use upm_core::metadata::{MetadataDocument, parse_document};
#[test]
fn parses_zsync_document_into_channel_hints() {

View file

@ -1,8 +1,8 @@
use aim_core::platform::capabilities::{probe_desktop_helpers, probe_writable_roots};
use aim_core::platform::distro::{DistroFamily, detect_distro_family};
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
use upm_core::platform::capabilities::{probe_desktop_helpers, probe_writable_roots};
use upm_core::platform::distro::{DistroFamily, detect_distro_family};
#[test]
fn detects_fedora_family_from_os_release() {

View file

@ -0,0 +1,151 @@
use upm_core::app::add::{AddSecurityPolicy, build_add_plan_with_registered_providers};
use upm_core::app::providers::{ExternalAddProvider, ExternalAddResolution, ProviderRegistry};
use upm_core::app::search::{SearchProvider, build_search_results_with_registered_providers};
use upm_core::domain::search::{SearchInstallStatus, SearchQuery, SearchResult};
use upm_core::domain::source::{
NormalizedSourceKind, ResolvedRelease, SourceInputKind, SourceKind, SourceRef,
};
use upm_core::domain::update::{
ArtifactCandidate, ChannelPreference, UpdateChannelKind, UpdateStrategy,
};
use upm_core::source::github::FixtureGitHubTransport;
struct StubSearchProvider;
impl SearchProvider for StubSearchProvider {
fn search(
&self,
_query: &SearchQuery,
) -> Result<Vec<SearchResult>, upm_core::app::search::SearchProviderError> {
Ok(vec![SearchResult {
provider_id: "external-search".to_owned(),
display_name: "Firefox Nightly".to_owned(),
description: Some("Provided by external registry".to_owned()),
source_locator: "https://example.invalid/firefox-nightly".to_owned(),
install_query: "external/firefox-nightly".to_owned(),
canonical_locator: "external/firefox-nightly".to_owned(),
version: Some("2026.03.21".to_owned()),
install_status: SearchInstallStatus::Available,
}])
}
}
struct StubExternalAddProvider;
impl ExternalAddProvider for StubExternalAddProvider {
fn id(&self) -> &'static str {
"stub-appimage"
}
fn resolve(
&self,
source: &SourceRef,
) -> Result<Option<ExternalAddResolution>, upm_core::adapters::traits::AdapterError> {
Ok(
(source.kind == SourceKind::AppImageHub).then(|| ExternalAddResolution {
resolution: upm_core::adapters::traits::AdapterResolution {
source: SourceRef {
kind: SourceKind::AppImageHub,
locator: source.locator.clone(),
input_kind: SourceInputKind::AppImageHubShorthand,
normalized_kind: NormalizedSourceKind::AppImageHub,
canonical_locator: Some("2338455".to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
},
release: ResolvedRelease {
version: "stable".to_owned(),
prerelease: false,
},
},
selected_artifact: ArtifactCandidate {
url: "https://downloads.example.invalid/firefox.AppImage".to_owned(),
version: "stable".to_owned(),
arch: Some("x86_64".to_owned()),
trusted_checksum: None,
weak_checksum_md5: Some("deadbeef".to_owned()),
selection_reason: "provider-release".to_owned(),
},
update_strategy: UpdateStrategy {
preferred: ChannelPreference {
kind: UpdateChannelKind::DirectAsset,
locator: "https://downloads.example.invalid/firefox.AppImage".to_owned(),
reason: "provider-release".to_owned(),
},
alternates: Vec::new(),
},
display_name_hint: Some(
"Firefox by Mozilla - Official AppImage Edition".to_owned(),
),
}),
)
}
}
#[test]
fn build_search_results_with_registered_providers_uses_external_hits() {
let query = SearchQuery::new("firefox");
let providers = ProviderRegistry::default().with_search_provider(StubSearchProvider);
let results = build_search_results_with_registered_providers(&query, &[], &providers).unwrap();
let external_hit = results
.remote_hits
.iter()
.find(|hit| hit.provider_id == "external-search")
.unwrap();
assert_eq!(external_hit.install_query, "external/firefox-nightly");
assert!(
results
.remote_hits
.iter()
.all(|hit| hit.provider_id != "appimagehub")
);
}
#[test]
fn build_add_plan_with_registered_providers_requires_external_provider_for_appimagehub() {
let registry = ProviderRegistry::default();
let error = build_add_plan_with_registered_providers(
"appimagehub/2338455",
&FixtureGitHubTransport,
&registry,
AddSecurityPolicy::default(),
)
.unwrap_err();
assert!(matches!(
error,
upm_core::app::add::BuildAddPlanError::NoInstallableArtifact { .. }
));
}
#[test]
fn build_add_plan_with_registered_providers_delegates_appimagehub_like_sources() {
let registry = ProviderRegistry::default().with_external_add_provider(StubExternalAddProvider);
let plan = build_add_plan_with_registered_providers(
"appimagehub/2338455",
&FixtureGitHubTransport,
&registry,
AddSecurityPolicy::default(),
)
.unwrap();
assert_eq!(plan.resolution.source.kind, SourceKind::AppImageHub);
assert_eq!(
plan.resolution.source.canonical_locator.as_deref(),
Some("2338455")
);
assert_eq!(
plan.selected_artifact.url,
"https://downloads.example.invalid/firefox.AppImage"
);
assert_eq!(
plan.display_name_hint.as_deref(),
Some("Firefox by Mozilla - Official AppImage Edition")
);
}

View file

@ -1,5 +1,5 @@
use aim_core::app::query::resolve_query;
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind};
use upm_core::app::query::resolve_query;
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind};
#[test]
fn owner_repo_defaults_to_github() {
@ -233,21 +233,21 @@ fn classifies_single_segment_sourceforge_release_download_with_query_as_candidat
fn rejects_malformed_gitlab_url() {
let error = resolve_query("https://gitlab.com/example").unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]
fn rejects_unsupported_gitlab_url_shape() {
let error = resolve_query("https://gitlab.com/example/team-app/-/issues").unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]
fn rejects_unsupported_gitlab_nested_resource_url() {
let error = resolve_query("https://gitlab.com/example/team-app/issues").unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]
@ -255,14 +255,14 @@ fn rejects_unsupported_gitlab_release_permalink_url() {
let error = resolve_query("https://gitlab.com/example/team-app/-/releases/permalink/latest")
.unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]
fn rejects_unsupported_gitlab_issue_detail_url() {
let error = resolve_query("https://gitlab.com/example/team-app/issues/1").unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]
@ -270,7 +270,7 @@ fn rejects_unsupported_gitlab_blob_url() {
let error =
resolve_query("https://gitlab.com/example/team-app/blob/main/README.md").unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]
@ -291,28 +291,28 @@ fn classifies_ambiguous_gitlab_deep_reserved_segment_as_candidate() {
fn rejects_unsupported_gitlab_packages_url() {
let error = resolve_query("https://gitlab.com/example/team-app/packages").unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]
fn rejects_malformed_sourceforge_url() {
let error = resolve_query("https://sourceforge.net/projects/").unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]
fn rejects_malformed_appimagehub_shorthand() {
let error = resolve_query("appimagehub/firefox").unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]
fn rejects_unsupported_sourceforge_url_shape() {
let error = resolve_query("https://sourceforge.net/projects/team-app/rss").unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]
@ -335,7 +335,7 @@ fn rejects_unsupported_sourceforge_folder_download_shape() {
let error = resolve_query("https://sourceforge.net/projects/team-app/files/releases/download")
.unwrap_err();
assert_eq!(error, aim_core::app::query::ResolveQueryError::Unsupported);
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]

View file

@ -1,5 +1,5 @@
use aim_core::registry::store::RegistryStore;
use tempfile::tempdir;
use upm_core::registry::store::RegistryStore;
#[test]
fn registry_round_trips_app_records() {
@ -13,28 +13,28 @@ fn registry_round_trips_app_records() {
fn registry_round_trips_update_strategy_and_alternates() {
let dir = tempdir().unwrap();
let store = RegistryStore::new(dir.path().join("registry.toml"));
let registry = aim_core::registry::model::Registry {
let registry = upm_core::registry::model::Registry {
version: 1,
apps: vec![aim_core::domain::app::AppRecord {
apps: vec![upm_core::domain::app::AppRecord {
stable_id: "t3code".to_owned(),
display_name: "T3 Code".to_owned(),
source_input: Some("pingdotgg/t3code".to_owned()),
source: None,
installed_version: Some("0.0.11".to_owned()),
update_strategy: Some(aim_core::domain::update::UpdateStrategy {
preferred: aim_core::domain::update::ChannelPreference {
kind: aim_core::domain::update::UpdateChannelKind::DirectAsset,
update_strategy: Some(upm_core::domain::update::UpdateStrategy {
preferred: upm_core::domain::update::ChannelPreference {
kind: upm_core::domain::update::UpdateChannelKind::DirectAsset,
locator: "https://example.test/app.AppImage".to_owned(),
reason: "install-origin-match".to_owned(),
},
alternates: vec![
aim_core::domain::update::ChannelPreference {
kind: aim_core::domain::update::UpdateChannelKind::GitHubReleases,
upm_core::domain::update::ChannelPreference {
kind: upm_core::domain::update::UpdateChannelKind::GitHubReleases,
locator: "pingdotgg/t3code".to_owned(),
reason: "heuristic-match".to_owned(),
},
aim_core::domain::update::ChannelPreference {
kind: aim_core::domain::update::UpdateChannelKind::ElectronBuilder,
upm_core::domain::update::ChannelPreference {
kind: upm_core::domain::update::UpdateChannelKind::ElectronBuilder,
locator: "https://example.test/latest-linux.yml".to_owned(),
reason: "metadata-guided".to_owned(),
},
@ -57,9 +57,9 @@ fn registry_round_trips_update_strategy_and_alternates() {
fn registry_round_trips_install_metadata() {
let dir = tempdir().unwrap();
let store = RegistryStore::new(dir.path().join("registry.toml"));
let registry = aim_core::registry::model::Registry {
let registry = upm_core::registry::model::Registry {
version: 1,
apps: vec![aim_core::domain::app::AppRecord {
apps: vec![upm_core::domain::app::AppRecord {
stable_id: "t3code".to_owned(),
display_name: "T3 Code".to_owned(),
source_input: Some("pingdotgg/t3code".to_owned()),
@ -67,13 +67,13 @@ fn registry_round_trips_install_metadata() {
installed_version: Some("0.0.11".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: Some(aim_core::domain::app::InstallMetadata {
scope: aim_core::domain::app::InstallScope::User,
install: Some(upm_core::domain::app::InstallMetadata {
scope: upm_core::domain::app::InstallScope::User,
payload_path: Some(
"/tmp/install-home/.local/lib/aim/appimages/t3code.AppImage".to_owned(),
"/tmp/install-home/.local/lib/upm/appimages/t3code.AppImage".to_owned(),
),
desktop_entry_path: Some(
"/tmp/install-home/.local/share/applications/aim-t3code.desktop".to_owned(),
"/tmp/install-home/.local/share/applications/upm-t3code.desktop".to_owned(),
),
icon_path: Some(
"/tmp/install-home/.local/share/icons/hicolor/256x256/apps/t3code.png"
@ -87,14 +87,14 @@ fn registry_round_trips_install_metadata() {
let loaded = store.load().unwrap();
let install = loaded.apps[0].install.as_ref().unwrap();
assert_eq!(install.scope, aim_core::domain::app::InstallScope::User);
assert_eq!(install.scope, upm_core::domain::app::InstallScope::User);
assert_eq!(
install.payload_path.as_deref(),
Some("/tmp/install-home/.local/lib/aim/appimages/t3code.AppImage")
Some("/tmp/install-home/.local/lib/upm/appimages/t3code.AppImage")
);
assert_eq!(
install.desktop_entry_path.as_deref(),
Some("/tmp/install-home/.local/share/applications/aim-t3code.desktop")
Some("/tmp/install-home/.local/share/applications/upm-t3code.desktop")
);
assert_eq!(
install.icon_path.as_deref(),
@ -106,18 +106,18 @@ fn registry_round_trips_install_metadata() {
fn registry_round_trips_source_identity_for_new_provider_kinds() {
let dir = tempdir().unwrap();
let store = RegistryStore::new(dir.path().join("registry.toml"));
let registry = aim_core::registry::model::Registry {
let registry = upm_core::registry::model::Registry {
version: 1,
apps: vec![
aim_core::domain::app::AppRecord {
upm_core::domain::app::AppRecord {
stable_id: "example-team-app".to_owned(),
display_name: "team-app".to_owned(),
source_input: Some("https://gitlab.com/example/team-app".to_owned()),
source: Some(aim_core::domain::source::SourceRef {
kind: aim_core::domain::source::SourceKind::GitLab,
source: Some(upm_core::domain::source::SourceRef {
kind: upm_core::domain::source::SourceKind::GitLab,
locator: "https://gitlab.com/example/team-app".to_owned(),
input_kind: aim_core::domain::source::SourceInputKind::GitLabUrl,
normalized_kind: aim_core::domain::source::NormalizedSourceKind::GitLab,
input_kind: upm_core::domain::source::SourceInputKind::GitLabUrl,
normalized_kind: upm_core::domain::source::NormalizedSourceKind::GitLab,
canonical_locator: Some("example/team-app".to_owned()),
requested_tag: None,
requested_asset_name: None,
@ -128,18 +128,18 @@ fn registry_round_trips_source_identity_for_new_provider_kinds() {
metadata: Vec::new(),
install: None,
},
aim_core::domain::app::AppRecord {
upm_core::domain::app::AppRecord {
stable_id: "team-app".to_owned(),
display_name: "team-app".to_owned(),
source_input: Some(
"https://sourceforge.net/projects/team-app/files/latest/download".to_owned(),
),
source: Some(aim_core::domain::source::SourceRef {
kind: aim_core::domain::source::SourceKind::SourceForge,
source: Some(upm_core::domain::source::SourceRef {
kind: upm_core::domain::source::SourceKind::SourceForge,
locator: "https://sourceforge.net/projects/team-app/files/latest/download"
.to_owned(),
input_kind: aim_core::domain::source::SourceInputKind::SourceForgeUrl,
normalized_kind: aim_core::domain::source::NormalizedSourceKind::SourceForge,
input_kind: upm_core::domain::source::SourceInputKind::SourceForgeUrl,
normalized_kind: upm_core::domain::source::NormalizedSourceKind::SourceForge,
canonical_locator: Some("team-app".to_owned()),
requested_tag: None,
requested_asset_name: None,
@ -150,15 +150,15 @@ fn registry_round_trips_source_identity_for_new_provider_kinds() {
metadata: Vec::new(),
install: None,
},
aim_core::domain::app::AppRecord {
upm_core::domain::app::AppRecord {
stable_id: "url-example.com-downloads-team-app.appimage".to_owned(),
display_name: "https://example.com/downloads/team-app.AppImage".to_owned(),
source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()),
source: Some(aim_core::domain::source::SourceRef {
kind: aim_core::domain::source::SourceKind::DirectUrl,
source: Some(upm_core::domain::source::SourceRef {
kind: upm_core::domain::source::SourceKind::DirectUrl,
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
input_kind: aim_core::domain::source::SourceInputKind::DirectUrl,
normalized_kind: aim_core::domain::source::NormalizedSourceKind::DirectUrl,
input_kind: upm_core::domain::source::SourceInputKind::DirectUrl,
normalized_kind: upm_core::domain::source::NormalizedSourceKind::DirectUrl,
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
@ -213,9 +213,9 @@ fn registry_save_is_atomic_and_cleans_up_temp_file() {
let store = RegistryStore::new(registry_path.clone());
store
.save(&aim_core::registry::model::Registry {
.save(&upm_core::registry::model::Registry {
version: 1,
apps: vec![aim_core::domain::app::AppRecord {
apps: vec![upm_core::domain::app::AppRecord {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
source_input: None,
@ -242,7 +242,7 @@ fn registry_exclusive_lock_rejects_second_mutator() {
assert!(matches!(
error,
aim_core::registry::store::RegistryStoreError::LockUnavailable
upm_core::registry::store::RegistryStoreError::LockUnavailable
));
}
@ -251,9 +251,9 @@ fn registry_mutate_exclusive_reloads_and_writes_latest_state() {
let dir = tempdir().unwrap();
let store = RegistryStore::new(dir.path().join("registry.toml"));
store
.save(&aim_core::registry::model::Registry {
.save(&upm_core::registry::model::Registry {
version: 1,
apps: vec![aim_core::domain::app::AppRecord {
apps: vec![upm_core::domain::app::AppRecord {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
source_input: None,
@ -268,7 +268,7 @@ fn registry_mutate_exclusive_reloads_and_writes_latest_state() {
store
.mutate_exclusive(|registry| {
registry.apps.push(aim_core::domain::app::AppRecord {
registry.apps.push(upm_core::domain::app::AppRecord {
stable_id: "t3code".to_owned(),
display_name: "T3 Code".to_owned(),
source_input: None,

View file

@ -1,13 +1,13 @@
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
use aim_core::app::list::build_list_rows;
use aim_core::app::progress::{OperationEvent, OperationStage};
use aim_core::app::remove::{
build_removal_plan, remove_registered_app_with_reporter, resolve_registered_app,
};
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use std::path::Path;
use tempfile::tempdir;
use upm_core::app::interaction::{InteractionKind, InteractionRequest};
use upm_core::app::list::build_list_rows;
use upm_core::app::progress::{OperationEvent, OperationStage};
use upm_core::app::remove::{
build_removal_plan, remove_registered_app_with_reporter, resolve_registered_app,
};
use upm_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
#[test]
fn remove_flow_rejects_unknown_app_names() {
@ -74,7 +74,7 @@ fn ambiguous_remove_matches_include_stable_ids_for_client_choice() {
assert_eq!(
error,
aim_core::app::remove::ResolveRegisteredAppError::Ambiguous {
upm_core::app::remove::ResolveRegisteredAppError::Ambiguous {
request: InteractionRequest {
key: "select-registered-app".to_owned(),
kind: InteractionKind::SelectRegisteredApp {
@ -98,8 +98,8 @@ fn removal_plan_prefers_persisted_install_metadata_paths() {
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::System,
payload_path: Some("/opt/aim/appimages/bat.AppImage".to_owned()),
desktop_entry_path: Some("/usr/share/applications/aim-bat.desktop".to_owned()),
payload_path: Some("/opt/upm/appimages/bat.AppImage".to_owned()),
desktop_entry_path: Some("/usr/share/applications/upm-bat.desktop".to_owned()),
icon_path: Some("/usr/share/icons/hicolor/256x256/apps/bat.png".to_owned()),
}),
};
@ -110,8 +110,8 @@ fn removal_plan_prefers_persisted_install_metadata_paths() {
assert_eq!(
plan.artifact_paths,
vec![
"/opt/aim/appimages/bat.AppImage".to_owned(),
"/usr/share/applications/aim-bat.desktop".to_owned(),
"/opt/upm/appimages/bat.AppImage".to_owned(),
"/usr/share/applications/upm-bat.desktop".to_owned(),
"/usr/share/icons/hicolor/256x256/apps/bat.png".to_owned(),
]
);
@ -135,8 +135,8 @@ fn removal_plan_falls_back_to_derived_managed_user_paths() {
assert_eq!(
plan.artifact_paths,
vec![
"/home/test/.local/lib/aim/appimages/bat.AppImage".to_owned(),
"/home/test/.local/share/applications/aim-bat.desktop".to_owned(),
"/home/test/.local/lib/upm/appimages/bat.AppImage".to_owned(),
"/home/test/.local/share/applications/upm-bat.desktop".to_owned(),
"/home/test/.local/share/icons/hicolor/256x256/apps/bat.png".to_owned(),
]
);
@ -158,7 +158,7 @@ fn remove_flow_reports_resolution_and_cleanup_events() {
payload_path: Some(
install_home
.path()
.join(".local/lib/aim/appimages/bat.AppImage")
.join(".local/lib/upm/appimages/bat.AppImage")
.display()
.to_string(),
),

View file

@ -1,10 +1,10 @@
use aim_core::app::search::{
use upm_core::app::search::{
GitHubSearchProvider, SearchProvider, SearchProviderError, build_search_results_with,
};
use aim_core::domain::app::AppRecord;
use aim_core::domain::search::{SearchInstallStatus, SearchQuery};
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use aim_core::source::github::{FixtureGitHubTransport, search_github_repositories_with};
use upm_core::domain::app::AppRecord;
use upm_core::domain::search::{SearchInstallStatus, SearchQuery};
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use upm_core::source::github::{FixtureGitHubTransport, search_github_repositories_with};
#[test]
fn github_fixtures_return_normalized_remote_hits() {
@ -206,7 +206,7 @@ impl SearchProvider for FailingProvider {
fn search(
&self,
_query: &SearchQuery,
) -> Result<Vec<aim_core::domain::search::SearchResult>, SearchProviderError> {
) -> Result<Vec<upm_core::domain::search::SearchResult>, SearchProviderError> {
Err(SearchProviderError::new("github", "fixture rate limit"))
}
}

View file

@ -1,12 +1,12 @@
use aim_core::app::show::{build_show_result, build_show_result_with};
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use aim_core::domain::show::{ShowResult, ShowResultError};
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use aim_core::domain::update::{
use upm_core::app::show::{build_show_result, build_show_result_with};
use upm_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use upm_core::domain::show::{ShowResult, ShowResultError};
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use upm_core::domain::update::{
ChannelPreference, MetadataHints, ParsedMetadata, ParsedMetadataKind, UpdateChannelKind,
UpdateStrategy,
};
use aim_core::source::github::FixtureGitHubTransport;
use upm_core::source::github::FixtureGitHubTransport;
#[test]
fn exact_installed_match_returns_installed_details() {
@ -48,8 +48,8 @@ fn exact_installed_match_returns_installed_details() {
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: Some("/tmp/bat.AppImage".to_owned()),
desktop_entry_path: Some("/tmp/aim-bat.desktop".to_owned()),
icon_path: Some("/tmp/aim-bat.png".to_owned()),
desktop_entry_path: Some("/tmp/upm-bat.desktop".to_owned()),
icon_path: Some("/tmp/upm-bat.png".to_owned()),
}),
}];
@ -180,7 +180,7 @@ fn remote_show_projects_tracking_preference_interaction() {
ShowResult::Remote(remote) => {
assert!(remote.interactions.iter().any(|interaction| matches!(
interaction,
aim_core::domain::show::RemoteInteractionSummary::ChooseTrackingPreference { .. }
upm_core::domain::show::RemoteInteractionSummary::ChooseTrackingPreference { .. }
)));
}
other => panic!("expected remote result, got {other:?}"),

View file

@ -1,16 +1,16 @@
use aim_core::app::add::AddSecurityPolicy;
use aim_core::app::progress::{NoopReporter, OperationEvent, OperationStage};
use aim_core::app::update::{
build_update_plan, execute_updates, execute_updates_with_reporter,
execute_updates_with_reporter_and_policy,
};
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use aim_core::domain::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy};
use aim_core::integration::paths::managed_appimage_path;
use std::fs;
use std::sync::Mutex;
use tempfile::tempdir;
use upm_core::app::add::AddSecurityPolicy;
use upm_core::app::progress::{NoopReporter, OperationEvent, OperationStage};
use upm_core::app::update::{
build_update_plan, execute_updates, execute_updates_with_reporter,
execute_updates_with_reporter_and_policy,
};
use upm_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use upm_core::domain::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy};
use upm_core::integration::paths::managed_appimage_path;
static ENV_LOCK: Mutex<()> = Mutex::new(());
@ -189,7 +189,7 @@ fn update_execution_rebuilds_gitlab_source_without_rewriting_origin() {
let install_home = tempdir().unwrap();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let previous = AppRecord {
@ -253,7 +253,7 @@ fn update_execution_rebuilds_sourceforge_release_folder_without_rewriting_origin
let install_home = tempdir().unwrap();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let previous = AppRecord {
@ -323,7 +323,7 @@ fn direct_http_updates_are_rejected_by_default() {
let install_home = tempdir().unwrap();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let previous = AppRecord {
@ -357,7 +357,7 @@ fn direct_http_updates_are_rejected_by_default() {
assert_eq!(result.failed_count(), 1);
assert!(matches!(
&result.items[0].status,
aim_core::domain::update::UpdateExecutionStatus::Failed { reason }
upm_core::domain::update::UpdateExecutionStatus::Failed { reason }
if reason.contains("InsecureHttpSource")
));
}
@ -370,7 +370,7 @@ fn direct_http_updates_can_be_allowed_by_policy() {
let install_home = tempdir().unwrap();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let previous = AppRecord {
@ -417,7 +417,7 @@ fn update_execution_uses_stored_sourceforge_releases_root_for_file_like_inputs()
let install_home = tempdir().unwrap();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let previous = AppRecord {
@ -482,7 +482,7 @@ fn failed_update_restores_previous_payload_contents() {
let install_home = tempdir().unwrap();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("DISPLAY", ":99");
std::env::set_var("XDG_CURRENT_DESKTOP", "test");
}
@ -536,7 +536,7 @@ fn successful_update_removes_rollback_staging_directory() {
let install_home = tempdir().unwrap();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
std::env::remove_var("DISPLAY");
std::env::remove_var("WAYLAND_DISPLAY");
std::env::remove_var("XDG_CURRENT_DESKTOP");
@ -578,7 +578,7 @@ fn successful_update_removes_rollback_staging_directory() {
assert!(
!install_home
.path()
.join(".local/share/aim/rollback")
.join(".local/share/upm/rollback")
.exists()
);
}

View file

@ -0,0 +1,11 @@
[package]
name = "upm-module-api"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
path = "src/lib.rs"
[dependencies]
serde.workspace = true

Some files were not shown because too many files have changed in this diff Show more