diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ef0d7e..1e71d90 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,10 @@ on: - '.github/workflows/release.yml' workflow_dispatch: inputs: + build_ubuntu: + description: 'Build Ubuntu' + type: boolean + default: true build_macos: description: 'Build macOS' type: boolean @@ -23,6 +27,7 @@ jobs: detect: runs-on: ubuntu-latest outputs: + ubuntu: ${{ steps.changes.outputs.ubuntu }} macos: ${{ steps.changes.outputs.macos }} windows: ${{ steps.changes.outputs.windows }} steps: @@ -41,10 +46,16 @@ jobs: id: changes run: | if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "ubuntu=${{ inputs.build_ubuntu }}" >> $GITHUB_OUTPUT echo "macos=${{ inputs.build_macos }}" >> $GITHUB_OUTPUT echo "windows=${{ inputs.build_windows }}" >> $GITHUB_OUTPUT else CHANGED=$(git diff --name-only HEAD^ HEAD) + if echo "$CHANGED" | grep -qE '^src/ubuntu/|^\.github/workflows/release\.yml'; then + echo "ubuntu=true" >> $GITHUB_OUTPUT + else + echo "ubuntu=false" >> $GITHUB_OUTPUT + fi if echo "$CHANGED" | grep -qE '^src/macos/|^\.github/workflows/release\.yml'; then echo "macos=true" >> $GITHUB_OUTPUT else @@ -59,7 +70,7 @@ jobs: create-release: needs: detect - if: needs.detect.outputs.macos == 'true' || needs.detect.outputs.windows == 'true' + if: needs.detect.outputs.ubuntu == 'true' || needs.detect.outputs.macos == 'true' || needs.detect.outputs.windows == 'true' runs-on: ubuntu-latest permissions: contents: write @@ -376,3 +387,117 @@ jobs: --urls $msiUrl ` --token $env:WINGET_TOKEN ` --submit + + build-ubuntu-amd64: + needs: [detect, create-release] + if: needs.detect.outputs.ubuntu == 'true' + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: sudo apt-get update && sudo apt-get install -y libgtk-4-dev libadwaita-1-dev libayatana-appindicator3-dev + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-deb + run: cargo install cargo-deb + + - name: Inject Version + working-directory: src/ubuntu/AzPin + run: | + sed -i "s/^version = .*/version = \"${{ needs.create-release.outputs.version }}\"/" Cargo.toml + + - name: Build and Package + working-directory: src/ubuntu/AzPin + run: cargo deb + + - name: Rename and Upload DEB + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + DEB_FILE=$(ls src/ubuntu/AzPin/target/debian/*.deb | head -n 1) + NEW_NAME="AzPin-Ubuntu-x64-v${{ needs.create-release.outputs.version }}.deb" + mv "$DEB_FILE" "$NEW_NAME" + gh release upload "${{ needs.create-release.outputs.tag }}" "$NEW_NAME" --clobber + + - name: Inject Snap Version + working-directory: src/ubuntu/AzPin + run: | + sed -i "s/^version: 'git'/version: '${{ needs.create-release.outputs.version }}'/" snap/snapcraft.yaml + + - name: Build Snap + id: build-snap + uses: snapcore/action-build@v1 + with: + path: src/ubuntu/AzPin + snapcraft-args: pack + + - name: Publish Snap + uses: snapcore/action-publish@v1 + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + with: + snap: ${{ steps.build-snap.outputs.snap }} + release: stable + + build-ubuntu-arm64: + needs: [detect, create-release] + if: needs.detect.outputs.ubuntu == 'true' + runs-on: ubuntu-24.04-arm + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: sudo apt-get update && sudo apt-get install -y libgtk-4-dev libadwaita-1-dev libayatana-appindicator3-dev + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-deb + run: cargo install cargo-deb + + - name: Inject Version + working-directory: src/ubuntu/AzPin + run: | + sed -i "s/^version = .*/version = \"${{ needs.create-release.outputs.version }}\"/" Cargo.toml + + - name: Build and Package + working-directory: src/ubuntu/AzPin + run: cargo deb + + - name: Rename and Upload DEB + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + DEB_FILE=$(ls src/ubuntu/AzPin/target/debian/*.deb | head -n 1) + NEW_NAME="AzPin-Ubuntu-arm64-v${{ needs.create-release.outputs.version }}.deb" + mv "$DEB_FILE" "$NEW_NAME" + gh release upload "${{ needs.create-release.outputs.tag }}" "$NEW_NAME" --clobber + + - name: Inject Snap Version + working-directory: src/ubuntu/AzPin + run: | + sed -i "s/^version: 'git'/version: '${{ needs.create-release.outputs.version }}'/" snap/snapcraft.yaml + + - name: Build Snap + id: build-snap + uses: snapcore/action-build@v1 + with: + path: src/ubuntu/AzPin + snapcraft-args: pack + + - name: Publish Snap + uses: snapcore/action-publish@v1 + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + with: + snap: ${{ steps.build-snap.outputs.snap }} + release: stable diff --git a/.github/workflows/ubuntu-ci.yml b/.github/workflows/ubuntu-ci.yml new file mode 100644 index 0000000..93e48b5 --- /dev/null +++ b/.github/workflows/ubuntu-ci.yml @@ -0,0 +1,124 @@ +name: Ubuntu CI + +on: + workflow_dispatch: + push: + branches: [ main ] + +jobs: + build-amd64: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: sudo apt-get update && sudo apt-get install -y libgtk-4-dev libadwaita-1-dev libdbus-1-dev pkg-config + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Run Cargo Test + working-directory: src/ubuntu/AzPin + run: cargo test + + - name: Install cargo-deb + run: cargo install cargo-deb + + - name: Build and Package + working-directory: src/ubuntu/AzPin + run: cargo deb + + - name: Rename DEB + working-directory: src/ubuntu/AzPin + run: | + DEB_FILE=$(ls target/debian/*.deb | head -n 1) + VERSION=$(grep '^version =' Cargo.toml | awk -F'"' '{print $2}') + NEW_NAME="target/debian/AzPin-Ubuntu-x64-v${VERSION}.deb" + mv "$DEB_FILE" "$NEW_NAME" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: azpin-ubuntu-amd64-deb + path: src/ubuntu/AzPin/target/debian/AzPin-Ubuntu-x64-v*.deb + + - name: Inject Snap Version + working-directory: src/ubuntu/AzPin + run: | + VERSION=$(grep '^version =' Cargo.toml | awk -F'"' '{print $2}') + sed -i "s/^version: 'git'/version: '${VERSION}'/" snap/snapcraft.yaml + + - name: Build Snap + id: build-snap + uses: snapcore/action-build@v1 + with: + path: src/ubuntu/AzPin + snapcraft-args: pack + + - name: Publish Snap + uses: snapcore/action-publish@v1 + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + with: + snap: ${{ steps.build-snap.outputs.snap }} + release: ${{ github.event_name == 'push' && 'candidate' || 'edge' }} + + + build-arm64: + runs-on: ubuntu-24.04-arm + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: sudo apt-get update && sudo apt-get install -y libgtk-4-dev libadwaita-1-dev libdbus-1-dev pkg-config + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Run Cargo Test + working-directory: src/ubuntu/AzPin + run: cargo test + + - name: Install cargo-deb + run: cargo install cargo-deb + + - name: Build and Package + working-directory: src/ubuntu/AzPin + run: cargo deb + + - name: Rename DEB + working-directory: src/ubuntu/AzPin + run: | + DEB_FILE=$(ls target/debian/*.deb | head -n 1) + VERSION=$(grep '^version =' Cargo.toml | awk -F'"' '{print $2}') + NEW_NAME="target/debian/AzPin-Ubuntu-arm64-v${VERSION}.deb" + mv "$DEB_FILE" "$NEW_NAME" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: azpin-ubuntu-arm64-deb + path: src/ubuntu/AzPin/target/debian/AzPin-Ubuntu-arm64-v*.deb + + - name: Inject Snap Version + working-directory: src/ubuntu/AzPin + run: | + VERSION=$(grep '^version =' Cargo.toml | awk -F'"' '{print $2}') + sed -i "s/^version: 'git'/version: '${VERSION}'/" snap/snapcraft.yaml + + - name: Build Snap + id: build-snap + uses: snapcore/action-build@v1 + with: + path: src/ubuntu/AzPin + snapcraft-args: pack + + - name: Publish Snap + uses: snapcore/action-publish@v1 + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + with: + snap: ${{ steps.build-snap.outputs.snap }} + release: ${{ github.event_name == 'push' && 'candidate' || 'edge' }} diff --git a/.gitignore b/.gitignore index 2e05986..266bb1a 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,10 @@ src/windows/tasks/ **/bin/ **/obj/ .vs/ + +# Rust / Ubuntu +src/ubuntu/AzPin/target/ +**/*.rs.bk +*.deb +snapcraft.login +feature-plans/ diff --git a/AZURE.md b/AZURE.md new file mode 100644 index 0000000..44f4734 --- /dev/null +++ b/AZURE.md @@ -0,0 +1,221 @@ +# Azure Interactions + +This document is an **exhaustive list of every interaction AzPin performs against Azure**, for ease of auditing. If you want to know exactly what AzPin reads, calls, or changes in your Azure environment, it is all here. + +Key facts up front: + +- **AzPin performs no authentication of its own.** There is no login screen, no client secret, no app registration, no device-code flow. The app simply asks the locally installed `az` CLI for an access token, reusing whatever session you established with `az login`. If you are not logged in, AzPin shows an onboarding screen telling you to run `az login` — it never prompts for credentials. +- **No Azure SDK is used.** Every ARM call is a plain HTTPS request (`URLSession` on macOS, `HttpClient` on Windows, `reqwest` on Ubuntu) against `https://management.azure.com`. What you see below is byte-for-byte what goes over the wire. +- **Tokens are cached locally, per subscription**, only until their natural expiry (SwiftData on macOS, SQLite on Windows/Ubuntu). They are never transmitted anywhere except in the `Authorization` header of ARM requests. +- **Write operations are limited to start / stop / restart** of runnable resources (App Services, slots, Container Apps, Logic Apps, VMs on Ubuntu). Everything else is read-only. +- **Action buttons are permission-gated.** Start/Stop/Restart only appear after an RBAC permissions check confirms your account can perform them. On any error or unexpected response the app fails safe: no buttons. +- The only non-Azure network call the app makes is to the **GitHub Releases API** (`api.github.com`) when you explicitly click "Check for Updates". That is out of scope for this document. + +Conventions used below: + +- `{placeholders}` mark values substituted at runtime. +- `{resourceId}` is a full ARM resource ID as returned by ARM itself, e.g. `/subscriptions/{subId}/resourceGroups/{rg}/providers/Microsoft.Web/sites/{name}`. +- Platform links go to the single file where each platform is allowed to make that call (service-boundary rule — see `CLAUDE.md`). + +--- + +## 1. Azure CLI invocations + +All `az` invocations are read-only queries against the local CLI session. AzPin never runs `az login`, `az logout`, or any `az` command that mutates state. + +### 1.1 Show current account + +```sh +az account show --output json +``` + +**Why:** Detect whether the user is signed in, and display the signed-in user and default subscription in the tray/menubar. A failure here flips the UI to the "run `az login`" onboarding screen. + +| Platform | Where | +|---|---| +| macOS | [`AzCLIService.swift`](src/macos/AzPin/Services/AzCLIService.swift) (`currentAccount`) | +| Windows | [`AzCliService.cs`](src/windows/AzPin.Windows.Core/Services/AzCliService.cs) (`GetCurrentAccountAsync`) | +| Ubuntu | [`az_cli.rs`](src/ubuntu/AzPin/src/services/az_cli.rs) (`get_default_subscription`) | + +### 1.2 List subscriptions + +```sh +az account list --output json +``` + +**Why:** Populate the subscription dropdown in the main window so the user can browse resource groups per subscription. + +| Platform | Where | +|---|---| +| macOS | [`AzCLIService.swift`](src/macos/AzPin/Services/AzCLIService.swift) (`listSubscriptions`) | +| Windows | [`AzCliService.cs`](src/windows/AzPin.Windows.Core/Services/AzCliService.cs) (`ListSubscriptionsAsync`) | +| Ubuntu | [`az_cli.rs`](src/ubuntu/AzPin/src/services/az_cli.rs) (`list_subscriptions`) | + +### 1.3 Get access token + +macOS / Windows: + +```sh +az account get-access-token --subscription {subscriptionId} --output json +``` + +Ubuntu (explicit ARM audience, same default the CLI uses): + +```sh +az account get-access-token --subscription {subscriptionId} --resource https://management.azure.com/ --output json +``` + +**Why:** This is the **only** way AzPin obtains credentials. The returned bearer token (scoped to the ARM audience) authorizes every HTTP call in section 2. The token and its expiry are cached locally keyed by subscription ID; a new token is requested only when the cached one has expired. The token cache layer is the sole caller: + +| Platform | CLI invocation | Cache layer (sole caller) | +|---|---|---| +| macOS | [`AzCLIService.swift`](src/macos/AzPin/Services/AzCLIService.swift) (`fetchToken`) | [`TokenCache.swift`](src/macos/AzPin/Services/TokenCache.swift) | +| Windows | [`AzCliService.cs`](src/windows/AzPin.Windows.Core/Services/AzCliService.cs) (`GetAccessTokenAsync`) | [`TokenCache.cs`](src/windows/AzPin.Windows.Core/Services/TokenCache.cs) | +| Ubuntu | [`az_cli.rs`](src/ubuntu/AzPin/src/services/az_cli.rs) (`get_access_token`) | [`token_cache.rs`](src/ubuntu/AzPin/src/services/token_cache.rs) | + +--- + +## 2. ARM REST API calls + +All calls go to `https://management.azure.com` with a single header: + +```http +Authorization: Bearer {token from section 1.3} +``` + +ARM is the **only** host these services ever contact. + +### 2.1 List subscriptions (ARM) + +```http +GET /subscriptions?api-version=2022-12-01 +``` + +**Why:** ARM-side counterpart to `az account list`. + +| Platform | Where | +|---|---| +| Windows only | [`ArmService.cs`](src/windows/AzPin.Windows.Core/Services/ArmService.cs) (`FetchSubscriptionsAsync`) | + +macOS and Ubuntu list subscriptions exclusively via the CLI (section 1.2). + +### 2.2 List resource groups + +```http +GET /subscriptions/{subscriptionId}/resourcegroups?api-version=2021-04-01 +``` + +**Why:** Populate the browse list in the main window for the selected subscription. Read-only. + +| Platform | Where | +|---|---| +| macOS | [`ARMService.swift`](src/macos/AzPin/Services/ARMService.swift) (`fetchResourceGroups`) | +| Windows | [`ArmService.cs`](src/windows/AzPin.Windows.Core/Services/ArmService.cs) (`FetchResourceGroupsAsync`) | +| Ubuntu | [`arm.rs`](src/ubuntu/AzPin/src/services/arm.rs) (`fetch_resource_groups`) | + +### 2.3 List resources in a resource group + +```http +GET /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/resources?api-version=2021-04-01 +``` + +**Why:** Show the resources inside a resource group — when expanding a group in the browse list, and when refreshing the contents of a pinned group for the tray/menubar menu. Live data is always fetched fresh; resource lists are never persisted. + +| Platform | Where | +|---|---| +| macOS | [`ARMService.swift`](src/macos/AzPin/Services/ARMService.swift) (`fetchResources`) | +| Windows | [`ArmService.cs`](src/windows/AzPin.Windows.Core/Services/ArmService.cs) (`FetchResourcesAsync`) | +| Ubuntu | [`arm.rs`](src/ubuntu/AzPin/src/services/arm.rs) (`fetch_resources`) | + +### 2.4 Read a resource's running state + +```http +GET {resourceId}?api-version={apiVersion} +``` + +Ubuntu additionally expands the instance view for virtual machines, because a VM's power state only appears there: + +```http +GET {resourceId}?api-version=2023-09-01&$expand=instanceView +``` + +**Why:** Decide whether a pinned runnable resource is Running, Stopped, or transitioning so the tray menu can offer the correct action (Start vs. Stop/Restart). Read-only. + +`{apiVersion}` per resource type: + +| Resource type | api-version | State field read | +|---|---|---| +| `microsoft.web/sites`, `microsoft.web/sites/slots` (default) | `2023-01-01` | `properties.state` | +| `microsoft.app/containerapps` | `2023-05-01` | `properties.runningStatus` | +| `microsoft.logic/workflows` | `2019-05-01` | `properties.state` (`Enabled`/`Disabled`) | +| `microsoft.compute/virtualmachines` (Ubuntu only) | `2023-09-01` | `properties.instanceView.statuses[].code` (`PowerState/*`) | + +| Platform | Where | +|---|---| +| macOS | [`ARMService.swift`](src/macos/AzPin/Services/ARMService.swift) (`fetchAppState`) | +| Windows | [`ArmService.cs`](src/windows/AzPin.Windows.Core/Services/ArmService.cs) (`FetchRunningStateAsync`) | +| Ubuntu | [`arm.rs`](src/ubuntu/AzPin/src/services/arm.rs) (`get_resource_state`) | + +### 2.5 Start / Stop / Restart a resource + +**The only write operations AzPin ever performs.** Empty-body POSTs, fired exclusively by an explicit user click on an action button, and only after the permissions check (2.6) has confirmed access. + +```http +POST {resourceId}/{action}?api-version={apiVersion} +``` + +`{action}` is `start`, `stop`, or `restart`, with these per-type mappings: + +| Resource type | Start | Stop | Restart | +|---|---|---|---| +| App Service / slot (default) | `start` | `stop` | `restart` | +| Container Apps | `start` | `stop` | `stop` then `start` (no restart endpoint) | +| Logic Apps | `enable` | `disable` | `stop`+`start` on Windows; `restart` on macOS/Ubuntu | +| Virtual machines (Ubuntu only) | `start` | `powerOff` | `restart` | + +`{apiVersion}` follows the same table as section 2.4. + +| Platform | Where | +|---|---| +| macOS | [`ARMService.swift`](src/macos/AzPin/Services/ARMService.swift) (`startApp` / `stopApp` / `restartApp` → `performAction`) | +| Windows | [`ArmService.cs`](src/windows/AzPin.Windows.Core/Services/ArmService.cs) (`StartResourceAsync` / `StopResourceAsync` / `RestartResourceAsync` → `PostActionAsync`) | +| Ubuntu | [`arm.rs`](src/ubuntu/AzPin/src/services/arm.rs) (`start_resource` / `stop_resource` / `restart_resource` → `post_action`) | + +> Note: `powerOff` (Ubuntu VMs) stops the VM but **does not deallocate it** — compute billing continues. AzPin never calls `deallocate` or `delete` on anything. + +### 2.6 Check RBAC permissions on a resource + +```http +GET {resourceId}/providers/Microsoft.Authorization/permissions?api-version=2022-04-01 +``` + +**Why:** Before showing Start/Stop/Restart buttons, AzPin verifies the signed-in account actually holds the corresponding RBAC actions (e.g. `Microsoft.Web/sites/start/action`). The response's `actions` / `notActions` patterns are evaluated locally with wildcard support (`*`, `Microsoft.Web/sites/*`). This endpoint is readable by Contributors (unlike the `checkAccess` POST, which needs Owner-level rights — a deliberate choice). **Fail-safe:** any error, non-2xx, or unexpected shape results in no action buttons. Results are cached in memory per resource. Read-only. + +| Platform | Where | +|---|---| +| macOS | [`PermissionsService.swift`](src/macos/AzPin/Services/PermissionsService.swift) (`checkAccess`) | +| Windows | [`PermissionsService.cs`](src/windows/AzPin.Windows.Core/Services/PermissionsService.cs) (`CheckAccessAsync`) | +| Ubuntu | [`permissions.rs`](src/ubuntu/AzPin/src/services/permissions.rs) (`check_access`) | + +--- + +## 3. Azure Portal links (browser only) + +Not API calls — clicking a pinned item opens the default browser at the Azure Portal. No data leaves the machine beyond the navigation itself; the portal authenticates the user with its own session. + +```text +https://portal.azure.com/#resource{resourceId} +https://portal.azure.com/#resource/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName} +``` + +| Platform | Where (sole constructor of portal URLs) | +|---|---| +| macOS | [`PortalURL.swift`](src/macos/AzPin/Utilities/PortalURL.swift) | +| Windows | [`PortalUrl.cs`](src/windows/AzPin.Windows.Core/Utilities/PortalUrl.cs) | +| Ubuntu | [`portal_url.rs`](src/ubuntu/AzPin/src/utils/portal_url.rs) | + +--- + +## Keeping this document current + +Any change that adds, removes, or alters an Azure interaction (new `az` invocation, new ARM endpoint, changed api-version, new action mapping) **must** be reflected here in the same change. This rule is enforced via `CLAUDE.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ccefdf..86bda97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,10 +16,41 @@ All notable changes to AzPin are documented in this file. - Windows MSI renamed to `AzPin-Windows-{version}-Installer.msi` (release) / `AzPin-Windows-{version}-beta-Installer.msi` (beta). - Windows beta tag unified to `beta-v{version}` (was `beta-win-v{version}`). - Winget manifest URL updated to match new MSI filename. +- Added full automated Ubuntu builds for both `amd64` and `arm64` architectures. +- Ubuntu artifacts renamed to universal format: `AzPin-Ubuntu-x64-v{version}.deb` and `AzPin-Ubuntu-arm64-v{version}.deb`. +- Re-enabled snap build and publish steps in Ubuntu CI (were disabled pending Snap Store manual review of the first submission). ### General -- Added update checker: "Check for Updates" queries the GitHub Releases API (`api.github.com/repos/lfmundim/AzPin/releases/latest`), compares the latest tag against the running version, and shows platform-specific upgrade instructions (`brew upgrade azpin` on macOS, `winget upgrade lfmundim.AzPin` on Windows) with a direct link to the release page. +- Added `AZURE.md`: exhaustive, audit-oriented reference of every Azure interaction the app performs (az CLI invocations, ARM endpoints, api-versions, action mappings, portal links) across all three platforms. Referenced from `README.md`; keeping it current on any Azure-interaction change is now a hard constraint in `CLAUDE.md`. +- Added update checker: "Check for Updates" queries the GitHub Releases API (`api.github.com/repos/lfmundim/AzPin/releases/latest`), compares the latest tag against the running version, and shows platform-specific upgrade instructions (`brew upgrade azpin` on macOS, `winget upgrade lfmundim.AzPin` on Windows, or `.deb` direct download on Ubuntu) with a direct link to the release page. + +### Ubuntu + +- Introduced native GTK4 Linux port built with Rust and `libadwaita`. +- Added dynamic background polling to maintain live azure state in the system tray. +- Settings: Implemented an "Updates" tab using `reqwest` to query the GitHub Releases API and allow one-click browser-based `.deb` updates. +- **Security**: `PermissionsService` now performs real ARM `GET .../providers/Microsoft.Authorization/permissions` checks with wildcard pattern matching (`*`, trailing `/*`, exact). Action buttons (Start/Stop/Restart) only appear for users with confirmed write permissions. Fail-safe: no buttons shown until permissions are verified. +- **Fix**: Token expiry parsing now handles the `az` CLI 2.x datetime format (`"YYYY-MM-DD HH:MM:SS.ffffff"`); tokens are cached for their actual TTL rather than a hardcoded 1-hour fallback. +- **Fix**: All `az` CLI invocations converted to `tokio::process::Command` (async), eliminating blocking I/O on Tokio worker threads. +- **Fix**: `menu()` no longer performs any I/O; account info, permissions, and resource states are all fetched in the async polling loop and read from caches in the synchronous render path. +- **Fix**: Removed shadowed `updated` variable in polling loop — tray now correctly refreshes when resource group contents load, not only when runnable resource state changes. +- **Fix**: Subscriptions never loaded in the main window — `Handle::current()` panicked inside spawned OS threads (no Tokio context), silently killing the loader thread. The Tokio runtime handle is now captured once in `MainWindow::new` and cloned into each thread closure. +- **Fix**: Quit action now uses GTK `app.quit()` via channel instead of `std::process::exit(0)`, ensuring SQLite WAL flush and GTK lifecycle hooks execute cleanly. +- **Fix**: Replaced emoji indicators (`✅`, `⚠️`, `🟢`, `🔴`, `⚪`) with Unicode geometric symbols (`▶`, `■`, `…`, `○`) and plain text per spec. +- **Refactor**: `is_runnable` extracted to `utils/resource_type.rs`; now covers 5 resource types including `microsoft.web/sites/slots`. All inline type-string checks removed. +- **Refactor**: `ResourceState` typed enum replaces raw `String` state storage; `state_cache` is now type-safe end-to-end. +- **Refactor**: Portal URL construction centralized in `utils/portal_url.rs`; no inline `portal.azure.com` strings remain in the UI layer. +- **Refactor**: `icon_mapper.rs` uses exact resource type matching instead of `contains()` substring matching. +- **Model**: `ArmResource` gains optional `tags: Option>` field. +- **Model**: `PinnedResourceGroup` gains `subscription_display_name: Option`; DB column added automatically via migration on startup. +- **Tests**: Unit tests added for `resource_type`, `portal_url`, `az_cli` expiry parsing, `permissions` wildcard matching, and `token_cache` validity logic. +- **UI**: Tray menu state glyphs moved off resource names onto the action items themselves (`▶ Start`, `■ Stop`, `⟳ Restart`, `… Starting/Stopping`); resource names now render plain. +- **UI**: Pinning a resource group hides the per-resource pin buttons inside it (resources are covered by the group pin); unpinning the group shows them again. +- **UI**: Pin buttons now use the GNOME `view-pin-symbolic` icon instead of `bookmark-new-symbolic`. +- **UI**: Tray account row now shows the signed-in user (`✓ user@domain`) instead of `● `, matching the macOS app. `AzSubscription` gains optional `user` field parsed from `az account show`. +- **UI**: Update checker now also suggests `sudo snap refresh azpin` (with copy-to-clipboard button) alongside the release-page download button when an update is available. +- **Fix**: "Download Update" button no longer stacks a click handler per update check — repeated checks previously opened multiple browser tabs on click. ### macOS diff --git a/CLAUDE.md b/CLAUDE.md index e8133ef..2fb4033 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,7 @@ These are non-negotiable for both platforms unless noted. - **No custom fonts.** System font only. - **No emoji in UI.** SF Symbols (macOS) or Segoe Fluent Icons / WinUI built-ins (Windows) only. - **Every testable unit of code must ship with tests.** All happy paths and all mapped error/sad paths. No exceptions for service layer code. See `.claude/skills/testing-approach.md`. +- **Every Azure interaction must be documented in `AZURE.md`.** Whenever an interaction with Azure is added or changed — a new `az` invocation, a new ARM endpoint, a changed `api-version`, a new action mapping — update `AZURE.md` in the same change. It is the audit trail of everything the app does against Azure. ### macOS-only diff --git a/README.md b/README.md index 6dd4449..1271e38 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![GitHub Release](https://img.shields.io/github/v/release/lfmundim/AzPin?cacheSeconds=3600)](https://github.com/lfmundim/AzPin/releases/latest) ![Brew Release](https://img.shields.io/badge/dynamic/regex?url=https://raw.githubusercontent.com/lfmundim/homebrew-tap/main/Casks/azpin.rb&search=version%20%22(.%2B)%22&replace=v%241&label=brew) [![Winget Release](https://img.shields.io/winget/v/KimDim.AzPin)](https://github.com/lfmundim/AzPin/releases/latest) +[![Snap Release](https://snapcraft.io/azpin/badge.svg)](https://snapcraft.io/azpin)

Logo @@ -10,15 +11,15 @@ AzPin is a native macOS menubar app that reads your existing `az` CLI session and gives you fast, pinnable access to Azure resources. Open the menubar, see your pinned resource groups and their live resources, click to open in the portal, or start/stop/restart runnable resources without leaving the desktop. -There is also a WinUI 3 Windows port under `src/windows/`, distributed as a self-contained zip from GitHub Releases. +There is also a WinUI 3 Windows port under `src/windows/`, distributed as a self-contained zip from GitHub Releases, and a native GTK4 GNOME Linux port under `src/ubuntu/` distributed as a `.deb` package. -No Azure SDK. No App Store. No sandbox. Requires macOS 26 Tahoe. +No Azure SDK. No App Store. No sandbox. Requires macOS 26 Tahoe, Windows 11, or GNOME Linux. --- ## Prerequisites -- **macOS 26 Tahoe** or later | **Windows 11** or later +- **macOS 26 Tahoe** or later | **Windows 11** or later | **GNOME Linux** (Developed and tested on Ubuntu 26.04 using GNOME 46; in theory, it is compatible with any Linux distribution using the GNOME UI.) - **[Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest)** installed - Signed in: `az login` @@ -37,7 +38,7 @@ brew install --cask azpin Download the latest `.dmg` from [Releases](../../releases), drag `AzPin.app` to `/Applications`. -### Windows - Winget (recommended) +### Windows - Winget (recommended, not yet available) ```powershell winget install KimDim.AzPin @@ -51,6 +52,21 @@ Download the latest `AzPin-Windows-*-Installer.msi` from [Releases](../../releas No separate Windows App SDK runtime is required — the bundle is self-contained. +### GNOME Linux - DEB (manual) + +Download the latest `AzPin-Ubuntu-*-v*.deb` from [Releases](../../releases) and install it: +```bash +sudo apt install ./AzPin-Ubuntu-x64-v1.0.0.deb +``` + +### GNOME Linux - Snap + +If you install via the Snap Store, you **must** manually grant AzPin permission to read your `~/.azure/` configuration folder so it can access your active `az login` session. Run this once after installation: +```bash +sudo snap install azpin +snap connect azpin:dot-azure +``` + --- ## How Pinning Works @@ -135,6 +151,23 @@ dotnet publish src/windows/AzPin.Windows/AzPin.Windows.csproj ` -o build/publish/win-x64 ``` +### GNOME Linux + +Requires Rust and GTK4 development libraries. + +```bash +# Install dependencies +sudo apt-get install libgtk-4-dev libadwaita-1-dev libayatana-appindicator3-dev + +# Build and run +cd src/ubuntu/AzPin +cargo run + +# Build DEB package +cargo install cargo-deb +cargo deb +``` + --- ## Contributing @@ -152,6 +185,7 @@ dotnet publish src/windows/AzPin.Windows/AzPin.Windows.csproj ` |---|---| | `CLAUDE.md` | Architecture rules and hard constraints | | `AZPIN_SPEC.md` | Full product specification | +| `AZURE.md` | Every Azure interaction the app performs (audit reference) | | `CHANGELOG.md` | Release history | | `ROADMAP.md` | Planned future features | | `RELEASE_PROCESS.md` | How to cut a release | diff --git a/src/ubuntu/AzPin/Cargo.lock b/src/ubuntu/AzPin/Cargo.lock new file mode 100644 index 0000000..aea487f --- /dev/null +++ b/src/ubuntu/AzPin/Cargo.lock @@ -0,0 +1,2833 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "azpin" +version = "0.1.0" +dependencies = [ + "chrono", + "dirs", + "gtk4", + "ksni", + "libadwaita", + "mockito", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "ansi_term", + "atty", + "bitflags 1.3.2", + "strsim", + "textwrap", + "unicode-width", + "vec_map", +] + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-codegen" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a49da9fdfbe872d4841d56605dc42efa5e6ca3291299b87f44e1cde91a28617c" +dependencies = [ + "clap", + "dbus", + "xml-rs", +] + +[[package]] +name = "dbus-tree" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f456e698ae8e54575e19ddb1f9b7bce2298568524f215496b248eb9498b4f508" +dependencies = [ + "dbus", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edb019ad581f8ecf8ea8e4baa6df7c483a95b5a59be3140be6a9c3b0c632af6" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbab43f332a3cf1df9974da690b5bb0e26720ed09a228178ce52175372dcfef0" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2228cda1505613a7a956cca69076892cfbda84fc2b7a62b94a41a272c0c401" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4144cee8fc8788f2a9b73dc5f1d4e1189d1f95305c4cb7bd9c1af1cfa31f59" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d958e351d2f210309b32d081c832d7de0aca0b077aa10d88336c6379bd01f7e" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bd9e3effea989f020e8f1ff3fa3b8c63ba93d43b899c11a118868853a56d55" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb51aa3e9728575a053e1f43543cd9992ac2477e1b186ad824fd4adfb70842" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d57ec49cf9b657f69a05bca8027cff0a8dfd0c49e812be026fc7311f2163832f" +dependencies = [ + "anyhow", + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "gtk4-sys" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54d8c4aa23638ce9faa2caf7e2a27d4a1295af2155c8e8d28c4d4eeca7a65eb8" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.2", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.2", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.2", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.14", + "http 1.4.2", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http 1.4.2", + "http-body 1.0.1", + "hyper 1.10.1", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "ksni" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4934310bdd016e55725482b8d35ac0c16fd058c1b955d8959aa2d953b918c85b" +dependencies = [ + "dbus", + "dbus-codegen", + "dbus-tree", + "thiserror", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libadwaita" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fe7e70c06507ed10a16cda707f358fbe60fe0dc237498f78c686ade92fd979c" +dependencies = [ + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "gtk4", + "libadwaita-sys", + "libc", + "pango", +] + +[[package]] +name = "libadwaita-sys" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e10aaa38de1d53374f90deeb4535209adc40cc5dba37f9704724169bceec69a" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mockito" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-core", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "hyper 1.10.1", + "hyper-util", + "log", + "pin-project-lite", + "rand", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.4", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/src/ubuntu/AzPin/Cargo.toml b/src/ubuntu/AzPin/Cargo.toml new file mode 100644 index 0000000..2549712 --- /dev/null +++ b/src/ubuntu/AzPin/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "azpin" +version = "0.1.0" +edition = "2021" + +[dependencies] +gtk4 = "0.7" +adw = { package = "libadwaita", version = "0.5", features = ["v1_4"] } +tokio = { version = "1", features = ["full"] } +reqwest = { version = "0.11", features = ["json"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +rusqlite = { version = "0.31", features = ["bundled"] } +chrono = "0.4" +ksni = "0.2" +dirs = "5.0" # For XDG spec based directories + +[dev-dependencies] +mockito = "1" + +[package.metadata.deb] +maintainer = "Lucas Mundim " +copyright = "2026, Lucas Mundim" +depends = "libgtk-4-1, libadwaita-1-0" +recommends = "azure-cli" +extended-description = "A native GTK4 app and indicator to manage Azure resources." +section = "utility" +priority = "optional" +assets = [ + ["target/release/azpin", "usr/bin/", "755"], + ["assets/linux/com.kimdim.azpin.desktop", "usr/share/applications/", "644"], + ["assets/linux/icons/com.kimdim.azpin.svg", "usr/share/icons/hicolor/scalable/apps/", "644"] +] diff --git a/src/ubuntu/AzPin/assets/linux/com.kimdim.azpin.desktop b/src/ubuntu/AzPin/assets/linux/com.kimdim.azpin.desktop new file mode 100644 index 0000000..5b94c5c --- /dev/null +++ b/src/ubuntu/AzPin/assets/linux/com.kimdim.azpin.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Name=AzPin +Comment=Pin your Azure Resources +Exec=/usr/bin/azpin +Icon=com.kimdim.azpin +Terminal=false +Type=Application +Categories=Utility;Development; diff --git a/src/ubuntu/AzPin/assets/linux/icons/com.kimdim.azpin.svg b/src/ubuntu/AzPin/assets/linux/icons/com.kimdim.azpin.svg new file mode 100644 index 0000000..94b78f6 --- /dev/null +++ b/src/ubuntu/AzPin/assets/linux/icons/com.kimdim.azpin.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ubuntu/AzPin/snap/snapcraft.yaml b/src/ubuntu/AzPin/snap/snapcraft.yaml new file mode 100644 index 0000000..c41a13f --- /dev/null +++ b/src/ubuntu/AzPin/snap/snapcraft.yaml @@ -0,0 +1,41 @@ +name: azpin +base: core24 +version: 'git' +icon: assets/linux/icons/com.kimdim.azpin.svg +summary: Native menubar app for fast, pinnable access to Azure resources. +description: | + AzPin reads your existing az CLI session and gives you fast, pinnable access + to Azure resources from your desktop. + +grade: stable +confinement: strict + +parts: + azpin: + plugin: rust + source: . + build-packages: + - libgtk-4-dev + - libadwaita-1-dev + - libayatana-appindicator3-dev + - pkg-config + - libssl-dev + stage-packages: + - libgtk-4-1 + - libadwaita-1-0 + - libayatana-appindicator3-1 + +apps: + azpin: + command: bin/azpin + extensions: [gnome] + plugs: + - network + - home + - dot-azure + +plugs: + dot-azure: + interface: personal-files + read: + - $HOME/.azure diff --git a/src/ubuntu/AzPin/src/main.rs b/src/ubuntu/AzPin/src/main.rs new file mode 100644 index 0000000..06694ae --- /dev/null +++ b/src/ubuntu/AzPin/src/main.rs @@ -0,0 +1,202 @@ +use adw::prelude::*; +use adw::Application; +use gtk4 as gtk; +use gtk::gio; + +mod models; +mod services; +mod ui; +mod utils; + +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use crate::models::persistence::PinnedResource; +use crate::services::az_cli::AzCliService; +use crate::services::permissions::PermissionsService; +use crate::utils::resource_type::{is_runnable, ResourceState}; + +#[tokio::main] +async fn main() { + let app = Application::builder() + .application_id("com.kimdim.azpin") + .flags(gio::ApplicationFlags::HANDLES_COMMAND_LINE) + .build(); + + app.connect_command_line(move |app, _cli| { + let db = Arc::new( + crate::services::db::Db::new().expect("Failed to init DB"), + ); + let token_cache = Arc::new(crate::services::token_cache::TokenCache::new(db.clone())); + let arm_service = Arc::new(crate::services::arm::ArmService::new(token_cache.clone())); + let permissions_service = Arc::new(PermissionsService::new(token_cache)); + + // --- Channels --- + let (open_tx, open_rx) = gtk::glib::MainContext::channel(gtk::glib::Priority::DEFAULT); + let app_open = app.clone(); + open_rx.attach(None, move |_| { + if let Some(win) = app_open.active_window() { + win.present(); + } else if let Some(win) = app_open.windows().first() { + win.present(); + } + gtk::glib::ControlFlow::Continue + }); + + let (settings_tx, settings_rx) = gtk::glib::MainContext::channel(gtk::glib::Priority::DEFAULT); + let app_settings = app.clone(); + let db_settings = db.clone(); + settings_rx.attach(None, move |_| { + let settings = + crate::ui::settings::SettingsWindow::new(&app_settings, db_settings.clone()); + settings.present(); + gtk::glib::ControlFlow::Continue + }); + + // Issue 7: proper quit via GTK lifecycle — no std::process::exit + let (quit_tx, quit_rx) = gtk::glib::MainContext::channel(gtk::glib::Priority::DEFAULT); + let app_quit = app.clone(); + quit_rx.attach(None, move |_| { + app_quit.quit(); + gtk::glib::ControlFlow::Continue + }); + + let (pin_changed_tx, pin_changed_rx) = + gtk::glib::MainContext::channel(gtk::glib::Priority::DEFAULT); + + let tokio_handle = tokio::runtime::Handle::current(); + + let state_cache: Arc>> = + Arc::new(RwLock::new(HashMap::new())); + let permissions_cache: Arc>> = + Arc::new(RwLock::new(HashMap::new())); + let rg_resources_cache: Arc>>> = + Arc::new(RwLock::new(HashMap::new())); + let account_cache: Arc>> = + Arc::new(RwLock::new(None)); + + let tray = crate::ui::indicator::AzPinTray { + db: db.clone(), + arm_service: arm_service.clone(), + permissions_service: permissions_service.clone(), + open_tx, + settings_tx, + quit_tx, + pin_changed_tx: pin_changed_tx.clone(), + tokio_handle: tokio_handle.clone(), + state_cache: state_cache.clone(), + permissions_cache: permissions_cache.clone(), + rg_resources_cache: rg_resources_cache.clone(), + account_cache: account_cache.clone(), + }; + + let tray_service = ksni::TrayService::new(tray); + let tray_handle = tray_service.handle(); + tray_service.spawn(); + + let state_tray_handle = tray_handle.clone(); + + // Background polling loop + let poll_db = db.clone(); + let poll_arm = arm_service.clone(); + let poll_permissions = permissions_service.clone(); + let poll_state_cache = state_cache.clone(); + let poll_permissions_cache = permissions_cache.clone(); + let poll_rg_cache = rg_resources_cache.clone(); + let poll_account_cache = account_cache.clone(); + + tokio_handle.spawn(async move { + loop { + // Issue 6: single updated flag for the entire loop body + let mut updated = false; + + // Issue 5: fetch account info once per poll cycle + if let Ok(sub) = AzCliService::get_default_subscription().await { + if let Ok(mut cache) = poll_account_cache.write() { + *cache = Some(sub); + updated = true; + } + } + + let mut runnables: Vec = Vec::new(); + + // Populate rg_resources_cache from pinned groups + if let Ok(groups) = poll_db.get_pinned_groups() { + for g in groups { + if let Ok(arm_resources) = + poll_arm.fetch_resources(&g.subscription_id, &g.name).await + { + let mut p_res = Vec::new(); + for r in arm_resources { + let p = PinnedResource { + id: r.id.clone(), + name: r.name, + type_: r.type_.clone(), + resource_group: g.name.clone(), + subscription_id: g.subscription_id.clone(), + location: r.location, + display_order: 0, + }; + if is_runnable(&r.type_) { + runnables.push(p.clone()); + } + p_res.push(p); + } + if let Ok(mut cache) = poll_rg_cache.write() { + cache.insert(g.id.clone(), p_res); + updated = true; + } + } + } + } + + // Collect orphan runnables + if let Ok(orphans) = poll_db.get_orphan_resources() { + for r in orphans { + if is_runnable(&r.type_) { + runnables.push(r); + } + } + } + + // Fetch state + permissions for each runnable + for r in &runnables { + if let Ok(state) = poll_arm.get_resource_state(&r.subscription_id, &r.id).await { + if let Ok(mut cache) = poll_state_cache.write() { + cache.insert(r.id.clone(), state); + updated = true; + } + } + + let can_manage = poll_permissions.can_manage(&r.subscription_id, &r.id).await; + if let Ok(mut cache) = poll_permissions_cache.write() { + if cache.get(&r.id) != Some(&can_manage) { + cache.insert(r.id.clone(), can_manage); + updated = true; + } + } + } + + if updated { + state_tray_handle.update(|_| {}); + } + + tokio::time::sleep(tokio::time::Duration::from_secs(30)).await; + } + }); + + let window = crate::ui::main_window::MainWindow::new( + app, + db, + arm_service, + tray_handle, + pin_changed_rx, + ); + window.present(); + + app.hold(); + 0 + }); + + app.run(); +} diff --git a/src/ubuntu/AzPin/src/models/arm.rs b/src/ubuntu/AzPin/src/models/arm.rs new file mode 100644 index 0000000..239a614 --- /dev/null +++ b/src/ubuntu/AzPin/src/models/arm.rs @@ -0,0 +1,20 @@ +use serde::Deserialize; +use std::collections::HashMap; + +#[derive(Debug, Clone, Deserialize)] +pub struct ArmResourceGroup { + pub id: String, + pub name: String, + pub location: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ArmResource { + pub id: String, + pub name: String, + #[serde(rename = "type")] + pub type_: String, + pub location: String, + #[serde(default)] + pub tags: Option>, +} diff --git a/src/ubuntu/AzPin/src/models/mod.rs b/src/ubuntu/AzPin/src/models/mod.rs new file mode 100644 index 0000000..038fb4b --- /dev/null +++ b/src/ubuntu/AzPin/src/models/mod.rs @@ -0,0 +1,2 @@ +pub mod arm; +pub mod persistence; diff --git a/src/ubuntu/AzPin/src/models/persistence.rs b/src/ubuntu/AzPin/src/models/persistence.rs new file mode 100644 index 0000000..9191f7b --- /dev/null +++ b/src/ubuntu/AzPin/src/models/persistence.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PinnedResourceGroup { + pub id: String, + pub subscription_id: String, + pub subscription_display_name: Option, + pub name: String, + pub display_order: i32, + pub resources: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PinnedResource { + pub id: String, + pub name: String, + pub type_: String, + pub resource_group: String, + pub subscription_id: String, + pub location: String, + pub display_order: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedToken { + pub subscription_id: String, + pub tenant_id: String, + pub access_token: String, + pub expires_on: String, +} diff --git a/src/ubuntu/AzPin/src/services/arm.rs b/src/ubuntu/AzPin/src/services/arm.rs new file mode 100644 index 0000000..0dd63f8 --- /dev/null +++ b/src/ubuntu/AzPin/src/services/arm.rs @@ -0,0 +1,249 @@ +use crate::models::arm::{ArmResource, ArmResourceGroup}; +use crate::services::token_cache::TokenCache; +use crate::utils::resource_type::ResourceState; +use reqwest::Client; +use std::sync::Arc; + +const ARM_BASE_URL: &str = "https://management.azure.com"; + +pub struct ArmService { + client: Client, + token_cache: Arc, +} + +impl ArmService { + pub fn new(token_cache: Arc) -> Self { + Self { + client: Client::new(), + token_cache, + } + } + + async fn get_auth_header(&self, subscription_id: &str) -> Result { + let token = self.token_cache.get_valid_token(subscription_id).await?; + Ok(format!("Bearer {}", token.trim())) + } + + pub async fn fetch_resource_groups( + &self, + subscription_id: &str, + ) -> Result, String> { + let url = format!( + "{}/subscriptions/{}/resourcegroups?api-version=2021-04-01", + ARM_BASE_URL, subscription_id + ); + let auth = self.get_auth_header(subscription_id).await?; + + let res = self + .client + .get(&url) + .header("Authorization", auth) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + if !res.status().is_success() { + let status = res.status(); + let err_body = res.text().await.unwrap_or_default(); + return Err(format!("ARM API error: {} - {}", status, err_body)); + } + + #[derive(serde::Deserialize)] + struct ArmResponse { + value: Vec, + } + + let body: ArmResponse = res + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + Ok(body.value) + } + + pub async fn fetch_resources( + &self, + subscription_id: &str, + resource_group: &str, + ) -> Result, String> { + let url = format!( + "{}/subscriptions/{}/resourceGroups/{}/resources?api-version=2021-04-01", + ARM_BASE_URL, subscription_id, resource_group + ); + let auth = self.get_auth_header(subscription_id).await?; + + let res = self + .client + .get(&url) + .header("Authorization", auth) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + if !res.status().is_success() { + let status = res.status(); + let err_body = res.text().await.unwrap_or_default(); + return Err(format!("ARM API error: {} - {}", status, err_body)); + } + + #[derive(serde::Deserialize)] + struct ArmResponse { + value: Vec, + } + + let body: ArmResponse = res + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + Ok(body.value) + } + + fn get_api_version(resource_id: &str) -> &'static str { + let id_lower = resource_id.to_lowercase(); + if id_lower.contains("microsoft.app/containerapps") { + "2023-05-01" + } else if id_lower.contains("microsoft.logic/workflows") { + "2019-05-01" + } else if id_lower.contains("microsoft.compute/virtualmachines") { + "2023-09-01" + } else { + "2023-01-01" + } + } + + pub async fn get_resource_state( + &self, + subscription_id: &str, + resource_id: &str, + ) -> Result { + let is_vm = resource_id + .to_lowercase() + .contains("microsoft.compute/virtualmachines"); + let api_version = Self::get_api_version(resource_id); + + let url = if is_vm { + format!( + "{}{}?api-version={}&$expand=instanceView", + ARM_BASE_URL, resource_id, api_version + ) + } else { + format!("{}{}?api-version={}", ARM_BASE_URL, resource_id, api_version) + }; + + let auth = self.get_auth_header(subscription_id).await?; + + let res = self + .client + .get(&url) + .header("Authorization", auth) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + if !res.status().is_success() { + let status = res.status(); + let err_body = res.text().await.unwrap_or_default(); + return Err(format!("ARM API error: {} - {}", status, err_body)); + } + + let body: serde_json::Value = res + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + if is_vm { + if let Some(statuses) = body + .pointer("/properties/instanceView/statuses") + .and_then(|s| s.as_array()) + { + for status in statuses { + if let Some(code) = status.get("code").and_then(|c| c.as_str()) { + if let Some(power) = code.strip_prefix("PowerState/") { + return Ok(ResourceState::from_str(power)); + } + } + } + } + } + + if let Some(props) = body.get("properties") { + for field in &["state", "runningStatus", "runningState", "powerState", "provisioningState"] { + if let Some(s) = props.get(field).and_then(|v| v.as_str()) { + return Ok(ResourceState::from_str(s)); + } + } + } + + Ok(ResourceState::Unknown) + } + + fn get_action_url(resource_id: &str, action: &str) -> String { + let api_version = Self::get_api_version(resource_id); + let id_lower = resource_id.to_lowercase(); + + let mapped_action = if id_lower.contains("microsoft.logic/workflows") { + match action { + "start" => "enable", + "stop" => "disable", + _ => action, + } + } else if id_lower.contains("microsoft.compute/virtualmachines") && action == "stop" { + "powerOff" + } else { + action + }; + + format!( + "{}{}/{}?api-version={}", + ARM_BASE_URL, resource_id, mapped_action, api_version + ) + } + + pub async fn start_resource(&self, subscription_id: &str, resource_id: &str) -> Result<(), String> { + let url = Self::get_action_url(resource_id, "start"); + let auth = self.get_auth_header(subscription_id).await?; + self.post_action(&url, &auth).await + } + + pub async fn stop_resource(&self, subscription_id: &str, resource_id: &str) -> Result<(), String> { + let url = Self::get_action_url(resource_id, "stop"); + let auth = self.get_auth_header(subscription_id).await?; + self.post_action(&url, &auth).await + } + + pub async fn restart_resource( + &self, + subscription_id: &str, + resource_id: &str, + ) -> Result<(), String> { + if resource_id + .to_lowercase() + .contains("microsoft.app/containerapps") + { + self.stop_resource(subscription_id, resource_id).await?; + return self.start_resource(subscription_id, resource_id).await; + } + + let url = Self::get_action_url(resource_id, "restart"); + let auth = self.get_auth_header(subscription_id).await?; + self.post_action(&url, &auth).await + } + + async fn post_action(&self, url: &str, auth: &str) -> Result<(), String> { + let res = self + .client + .post(url) + .header("Authorization", auth) + .header("Content-Length", "0") + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + if !res.status().is_success() { + let status = res.status(); + let err_body = res.text().await.unwrap_or_default(); + return Err(format!("ARM API error: {} - {}", status, err_body)); + } + + Ok(()) + } +} diff --git a/src/ubuntu/AzPin/src/services/az_cli.rs b/src/ubuntu/AzPin/src/services/az_cli.rs new file mode 100644 index 0000000..8d77ce5 --- /dev/null +++ b/src/ubuntu/AzPin/src/services/az_cli.rs @@ -0,0 +1,142 @@ +use tokio::process::Command; +use serde::Deserialize; +use chrono::{DateTime, Utc}; + +#[derive(Deserialize)] +struct AzTokenResponse { + #[serde(rename = "accessToken")] + pub access_token: String, + #[serde(rename = "expiresOn")] + pub expires_on: String, + pub tenant: String, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct AzUser { + pub name: String, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct AzSubscription { + pub id: String, + pub name: String, + #[serde(rename = "tenantId")] + pub tenant_id: String, + #[serde(rename = "isDefault")] + pub is_default: bool, + pub state: String, + #[serde(default)] + pub user: Option, +} + +pub struct AzCliService; + +fn resolve_az_path() -> &'static str { + for path in &["/usr/bin/az", "/usr/local/bin/az", "/snap/bin/az"] { + if std::path::Path::new(path).exists() { + return path; + } + } + "az" +} + +fn parse_az_expiry(s: &str) -> DateTime { + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") { + return DateTime::from_naive_utc_and_offset(dt, Utc); + } + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return dt.with_timezone(&Utc); + } + Utc::now() + chrono::Duration::hours(1) +} + +impl AzCliService { + pub async fn get_access_token(subscription_id: &str) -> Result<(String, String, String), String> { + let output = Command::new(resolve_az_path()) + .args([ + "account", + "get-access-token", + "--subscription", + subscription_id, + "--resource", + "https://management.azure.com/", + "--output", + "json", + ]) + .output() + .await + .map_err(|e| format!("Failed to execute az cli: {}", e))?; + + if !output.status.success() { + let err_msg = String::from_utf8_lossy(&output.stderr); + return Err(format!("az cli error: {}", err_msg)); + } + + let resp: AzTokenResponse = serde_json::from_slice(&output.stdout) + .map_err(|e| format!("Failed to parse az output: {}", e))?; + + let expires_on_rfc = parse_az_expiry(&resp.expires_on).to_rfc3339(); + Ok((resp.access_token, expires_on_rfc, resp.tenant)) + } + + pub async fn get_default_subscription() -> Result { + let output = Command::new(resolve_az_path()) + .args(["account", "show", "--output", "json"]) + .output() + .await + .map_err(|e| format!("Failed to execute az cli: {}", e))?; + + if !output.status.success() { + let err_msg = String::from_utf8_lossy(&output.stderr); + return Err(format!("az cli error: {}", err_msg)); + } + + let resp: AzSubscription = serde_json::from_slice(&output.stdout) + .map_err(|e| format!("Failed to parse az output: {}", e))?; + + Ok(resp) + } + + pub async fn list_subscriptions() -> Result, String> { + let output = Command::new(resolve_az_path()) + .args(["account", "list", "--output", "json"]) + .output() + .await + .map_err(|e| format!("Failed to execute az cli: {}", e))?; + + if !output.status.success() { + let err_msg = String::from_utf8_lossy(&output.stderr); + return Err(format!("az cli error: {}", err_msg)); + } + + let resp: Vec = serde_json::from_slice(&output.stdout) + .map_err(|e| format!("Failed to parse az output: {}", e))?; + + Ok(resp) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_az_expiry_az_cli_format() { + let dt = parse_az_expiry("2026-06-12 15:30:00.123456"); + assert_eq!(dt.format("%Y-%m-%d %H:%M:%S").to_string(), "2026-06-12 15:30:00"); + } + + #[test] + fn parse_az_expiry_rfc3339_fallback() { + let dt = parse_az_expiry("2026-06-12T15:30:00Z"); + assert_eq!(dt.format("%Y-%m-%d %H:%M:%S").to_string(), "2026-06-12 15:30:00"); + } + + #[test] + fn parse_az_expiry_garbage_returns_future() { + let before = Utc::now(); + let dt = parse_az_expiry("not-a-date"); + assert!(dt > before); + assert!(dt <= Utc::now() + chrono::Duration::hours(2)); + } +} diff --git a/src/ubuntu/AzPin/src/services/db.rs b/src/ubuntu/AzPin/src/services/db.rs new file mode 100644 index 0000000..4e8557a --- /dev/null +++ b/src/ubuntu/AzPin/src/services/db.rs @@ -0,0 +1,296 @@ +use rusqlite::{params, Connection, Result}; +use std::path::PathBuf; +use crate::models::persistence::{CachedToken, PinnedResourceGroup, PinnedResource}; +use std::sync::Mutex; + +pub struct Db { + conn: Mutex, +} + +impl Db { + pub fn new() -> Result { + let db_path = Self::get_db_path(); + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent).unwrap_or_default(); + } + let conn = Connection::open(&db_path)?; + let db = Self { conn: Mutex::new(conn) }; + db.init()?; + Ok(db) + } + + pub fn new_in_memory() -> Result { + let conn = Connection::open_in_memory()?; + let db = Self { conn: Mutex::new(conn) }; + db.init()?; + Ok(db) + } + + fn get_db_path() -> PathBuf { + let data_dir = dirs::data_dir().unwrap_or_else(|| { + PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| String::from("~"))).join(".local/share") + }); + data_dir.join("azpin").join("azpin.db") + } + + fn init(&self) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "CREATE TABLE IF NOT EXISTS tokens ( + subscription_id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + access_token TEXT NOT NULL, + expires_on TEXT NOT NULL + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS pinned_resource_groups ( + id TEXT PRIMARY KEY, + subscription_id TEXT NOT NULL, + subscription_display_name TEXT, + name TEXT NOT NULL, + display_order INTEGER NOT NULL + )", + [], + )?; + + // Migrate existing installs that lack the subscription_display_name column + let _ = conn.execute( + "ALTER TABLE pinned_resource_groups ADD COLUMN subscription_display_name TEXT", + [], + ); + + conn.execute( + "CREATE TABLE IF NOT EXISTS pinned_resources ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + resource_group TEXT NOT NULL, + subscription_id TEXT NOT NULL, + location TEXT NOT NULL, + display_order INTEGER NOT NULL, + group_id TEXT NOT NULL, + FOREIGN KEY(group_id) REFERENCES pinned_resource_groups(id) ON DELETE CASCADE + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS hidden_subscriptions ( + id TEXT PRIMARY KEY + )", + [], + )?; + + Ok(()) + } + + // --- Token Operations --- + + pub fn save_token(&self, token: &CachedToken) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT OR REPLACE INTO tokens (subscription_id, tenant_id, access_token, expires_on) + VALUES (?1, ?2, ?3, ?4)", + params![token.subscription_id, token.tenant_id, token.access_token, token.expires_on], + )?; + Ok(()) + } + + pub fn get_token(&self, subscription_id: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT subscription_id, tenant_id, access_token, expires_on FROM tokens WHERE subscription_id = ?1")?; + let mut rows = stmt.query(params![subscription_id])?; + + if let Some(row) = rows.next()? { + Ok(Some(CachedToken { + subscription_id: row.get(0)?, + tenant_id: row.get(1)?, + access_token: row.get(2)?, + expires_on: row.get(3)?, + })) + } else { + Ok(None) + } + } + + // --- Pinned Resource Operations --- + + pub fn save_pinned_group(&self, group: &PinnedResourceGroup) -> Result<()> { + { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT OR REPLACE INTO pinned_resource_groups (id, subscription_id, subscription_display_name, name, display_order) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![group.id, group.subscription_id, group.subscription_display_name, group.name, group.display_order], + )?; + } + + // For simplicity, we can also manage resources here or separately + for res in &group.resources { + self.save_pinned_resource(res, &group.id)?; + } + Ok(()) + } + + pub fn ensure_implicit_group( + &self, + id: &str, + subscription_id: &str, + name: &str, + subscription_display_name: Option<&str>, + ) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT OR IGNORE INTO pinned_resource_groups (id, subscription_id, subscription_display_name, name, display_order) + VALUES (?1, ?2, ?3, ?4, -1)", + params![id, subscription_id, subscription_display_name, name], + )?; + Ok(()) + } + + pub fn save_pinned_resource(&self, resource: &PinnedResource, group_id: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT OR REPLACE INTO pinned_resources (id, name, type, resource_group, subscription_id, location, display_order, group_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + resource.id, resource.name, resource.type_, resource.resource_group, + resource.subscription_id, resource.location, resource.display_order, group_id + ], + )?; + Ok(()) + } + + pub fn get_pinned_groups(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT id, subscription_id, subscription_display_name, name, display_order FROM pinned_resource_groups WHERE display_order >= 0 ORDER BY display_order ASC")?; + + let group_iter = stmt.query_map([], |row| { + let id: String = row.get(0)?; + Ok(PinnedResourceGroup { + id: id.clone(), + subscription_id: row.get(1)?, + subscription_display_name: row.get(2)?, + name: row.get(3)?, + display_order: row.get(4)?, + resources: Vec::new(), + }) + })?; + + let mut groups = Vec::new(); + for group in group_iter { + groups.push(group?); + } + + drop(stmt); + drop(conn); + + for group in &mut groups { + group.resources = self.get_pinned_resources(&group.id).unwrap_or_default(); + } + + Ok(groups) + } + + pub fn get_orphan_resources(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT r.id, r.name, r.type, r.resource_group, r.subscription_id, r.location, r.display_order FROM pinned_resources r JOIN pinned_resource_groups g ON r.group_id = g.id WHERE g.display_order < 0 ORDER BY r.display_order ASC")?; + let res_iter = stmt.query_map([], |row| { + Ok(PinnedResource { + id: row.get(0)?, + name: row.get(1)?, + type_: row.get(2)?, + resource_group: row.get(3)?, + subscription_id: row.get(4)?, + location: row.get(5)?, + display_order: row.get(6)?, + }) + })?; + + let mut resources = Vec::new(); + for res in res_iter { + resources.push(res?); + } + Ok(resources) + } + + pub fn get_pinned_resources(&self, group_id: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT id, name, type, resource_group, subscription_id, location, display_order FROM pinned_resources WHERE group_id = ?1 ORDER BY display_order ASC")?; + let res_iter = stmt.query_map(params![group_id], |row| { + Ok(PinnedResource { + id: row.get(0)?, + name: row.get(1)?, + type_: row.get(2)?, + resource_group: row.get(3)?, + subscription_id: row.get(4)?, + location: row.get(5)?, + display_order: row.get(6)?, + }) + })?; + + let mut resources = Vec::new(); + for res in res_iter { + resources.push(res?); + } + Ok(resources) + } + + pub fn delete_pinned_group(&self, id: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + // Check if it has resources + let count: i64 = conn.query_row("SELECT COUNT(*) FROM pinned_resources WHERE group_id = ?1", params![id], |row| row.get(0))?; + if count > 0 { + // Keep it but mark as implicit + conn.execute("UPDATE pinned_resource_groups SET display_order = -1 WHERE id = ?1", params![id])?; + } else { + conn.execute("DELETE FROM pinned_resource_groups WHERE id = ?1", params![id])?; + } + Ok(()) + } + + pub fn delete_pinned_resource(&self, id: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + // get group id before delete + let group_id: Result = conn.query_row("SELECT group_id FROM pinned_resources WHERE id = ?1", params![id], |row| row.get(0)); + conn.execute("DELETE FROM pinned_resources WHERE id = ?1", params![id])?; + + if let Ok(gid) = group_id { + let count: i64 = conn.query_row("SELECT COUNT(*) FROM pinned_resources WHERE group_id = ?1", params![gid], |row| row.get(0)).unwrap_or(0); + let display_order: i32 = conn.query_row("SELECT display_order FROM pinned_resource_groups WHERE id = ?1", params![gid], |row| row.get(0)).unwrap_or(0); + if count == 0 && display_order < 0 { + conn.execute("DELETE FROM pinned_resource_groups WHERE id = ?1", params![gid])?; + } + } + Ok(()) + } + + // --- Settings Operations --- + + pub fn hide_subscription(&self, id: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("INSERT OR IGNORE INTO hidden_subscriptions (id) VALUES (?1)", params![id])?; + Ok(()) + } + + pub fn show_subscription(&self, id: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM hidden_subscriptions WHERE id = ?1", params![id])?; + Ok(()) + } + + pub fn get_hidden_subscriptions(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT id FROM hidden_subscriptions")?; + let rows = stmt.query_map([], |row| row.get(0))?; + + let mut subs = Vec::new(); + for row in rows { + subs.push(row?); + } + Ok(subs) + } +} diff --git a/src/ubuntu/AzPin/src/services/mod.rs b/src/ubuntu/AzPin/src/services/mod.rs new file mode 100644 index 0000000..857cfed --- /dev/null +++ b/src/ubuntu/AzPin/src/services/mod.rs @@ -0,0 +1,6 @@ +pub mod db; +pub mod az_cli; +pub mod token_cache; +pub mod arm; +pub mod updater; +pub mod permissions; diff --git a/src/ubuntu/AzPin/src/services/permissions.rs b/src/ubuntu/AzPin/src/services/permissions.rs new file mode 100644 index 0000000..0c2bde5 --- /dev/null +++ b/src/ubuntu/AzPin/src/services/permissions.rs @@ -0,0 +1,166 @@ +use crate::services::token_cache::TokenCache; +use reqwest::Client; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +const ARM_BASE_URL: &str = "https://management.azure.com"; +const PERMISSIONS_API_VERSION: &str = "2022-04-01"; + +const START_ACTION: &str = "microsoft.web/sites/start/action"; +const STOP_ACTION: &str = "microsoft.web/sites/stop/action"; + +pub struct PermissionsService { + client: Client, + token_cache: Arc, + cache: Mutex>, +} + +impl PermissionsService { + pub fn new(token_cache: Arc) -> Self { + Self { + client: Client::new(), + token_cache, + cache: Mutex::new(HashMap::new()), + } + } + + pub async fn can_manage(&self, subscription_id: &str, resource_id: &str) -> bool { + if let Ok(cache) = self.cache.lock() { + if let Some(&cached) = cache.get(resource_id) { + return cached; + } + } + + let result = self.check_access(subscription_id, resource_id).await; + + if let Ok(mut cache) = self.cache.lock() { + cache.insert(resource_id.to_string(), result); + } + + result + } + + async fn check_access(&self, subscription_id: &str, resource_id: &str) -> bool { + let token = match self.token_cache.get_valid_token(subscription_id).await { + Ok(t) => t, + Err(_) => return false, + }; + + let url = format!( + "{}{}/providers/Microsoft.Authorization/permissions?api-version={}", + ARM_BASE_URL, resource_id, PERMISSIONS_API_VERSION + ); + + let res = match self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + { + Ok(r) => r, + Err(_) => return false, + }; + + if !res.status().is_success() { + return false; + } + + let body: serde_json::Value = match res.json().await { + Ok(v) => v, + Err(_) => return false, + }; + + let entries = match body.get("value").and_then(|v| v.as_array()) { + Some(e) => e, + None => return false, + }; + + for entry in entries { + let actions: Vec<&str> = entry + .get("actions") + .and_then(|a| a.as_array()) + .map(|a| a.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + + let not_actions: Vec<&str> = entry + .get("notActions") + .and_then(|a| a.as_array()) + .map(|a| a.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + + let grants = actions + .iter() + .any(|p| arm_pattern_matches(p, START_ACTION) || arm_pattern_matches(p, STOP_ACTION)); + + let denied = not_actions + .iter() + .any(|p| arm_pattern_matches(p, START_ACTION) || arm_pattern_matches(p, STOP_ACTION)); + + if grants && !denied { + return true; + } + } + + false + } +} + +fn arm_pattern_matches(pattern: &str, action: &str) -> bool { + let p = pattern.to_lowercase(); + let a = action.to_lowercase(); + if p == "*" || p == a { + return true; + } + if let Some(prefix) = p.strip_suffix("/*") { + return a.starts_with(&format!("{}/", prefix)) || a == prefix; + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arm_pattern_exact_match() { + assert!(arm_pattern_matches( + "microsoft.web/sites/start/action", + "microsoft.web/sites/start/action" + )); + } + + #[test] + fn arm_pattern_full_wildcard() { + assert!(arm_pattern_matches("*", "microsoft.web/sites/start/action")); + assert!(arm_pattern_matches("*", "microsoft.web/sites/stop/action")); + } + + #[test] + fn arm_pattern_trailing_wildcard() { + assert!(arm_pattern_matches( + "microsoft.web/sites/*", + "microsoft.web/sites/start/action" + )); + assert!(arm_pattern_matches( + "microsoft.web/*", + "microsoft.web/sites/start/action" + )); + assert!(arm_pattern_matches( + "Microsoft.Web/sites/*", + "microsoft.web/sites/stop/action" + )); + } + + #[test] + fn arm_pattern_no_match() { + assert!(!arm_pattern_matches( + "microsoft.storage/*", + "microsoft.web/sites/start/action" + )); + assert!(!arm_pattern_matches( + "microsoft.web/sites/read", + "microsoft.web/sites/start/action" + )); + } +} diff --git a/src/ubuntu/AzPin/src/services/token_cache.rs b/src/ubuntu/AzPin/src/services/token_cache.rs new file mode 100644 index 0000000..3938937 --- /dev/null +++ b/src/ubuntu/AzPin/src/services/token_cache.rs @@ -0,0 +1,89 @@ +use crate::models::persistence::CachedToken; +use crate::services::az_cli::AzCliService; +use crate::services::db::Db; +use chrono::{DateTime, Duration, Utc}; +use std::sync::Arc; + +pub struct TokenCache { + db: Arc, +} + +impl TokenCache { + pub fn new(db: Arc) -> Self { + Self { db } + } + + pub async fn get_valid_token(&self, subscription_id: &str) -> Result { + if let Ok(Some(token)) = self.db.get_token(subscription_id) { + if self.is_token_valid(&token.expires_on) { + return Ok(token.access_token); + } + } + + let (access_token, expires_on, tenant_id) = + AzCliService::get_access_token(subscription_id).await?; + + let cached_token = CachedToken { + subscription_id: subscription_id.to_string(), + tenant_id, + access_token: access_token.clone(), + expires_on, + }; + + if let Err(e) = self.db.save_token(&cached_token) { + eprintln!("Failed to save token to DB: {}", e); + } + + Ok(access_token) + } + + fn is_token_valid(&self, expires_on: &str) -> bool { + let now = Utc::now(); + let buffer = Duration::minutes(5); + + if let Ok(dt) = DateTime::parse_from_rfc3339(expires_on) { + return dt.with_timezone(&Utc) > now + buffer; + } + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::db::Db; + use chrono::Duration; + use std::sync::Arc; + + fn make_cache() -> TokenCache { + let db = Arc::new(Db::new_in_memory().expect("in-memory DB")); + TokenCache::new(db) + } + + #[test] + fn is_token_valid_not_expired() { + let cache = make_cache(); + let future = (Utc::now() + Duration::hours(2)).to_rfc3339(); + assert!(cache.is_token_valid(&future)); + } + + #[test] + fn is_token_valid_expired() { + let cache = make_cache(); + let past = (Utc::now() - Duration::hours(1)).to_rfc3339(); + assert!(!cache.is_token_valid(&past)); + } + + #[test] + fn is_token_valid_within_buffer() { + let cache = make_cache(); + let soon = (Utc::now() + Duration::minutes(3)).to_rfc3339(); + assert!(!cache.is_token_valid(&soon)); + } + + #[test] + fn is_token_valid_garbage_returns_false() { + let cache = make_cache(); + assert!(!cache.is_token_valid("not-a-date")); + } +} diff --git a/src/ubuntu/AzPin/src/services/updater.rs b/src/ubuntu/AzPin/src/services/updater.rs new file mode 100644 index 0000000..6896f6d --- /dev/null +++ b/src/ubuntu/AzPin/src/services/updater.rs @@ -0,0 +1,84 @@ +use serde::Deserialize; +use std::env; +use reqwest::Client; + +#[derive(Deserialize)] +struct GitHubRelease { + tag_name: String, + html_url: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum UpdateCheckState { + Idle, + Checking, + UpToDate { version: String }, + UpdateAvailable { current: String, latest: String, release_url: String }, + Failed(String), +} + +pub struct UpdaterService { + client: Client, +} + +impl UpdaterService { + pub fn new() -> Self { + Self { + client: Client::new(), + } + } + + pub async fn check_for_updates(&self) -> UpdateCheckState { + let current_version = env!("CARGO_PKG_VERSION"); + + let url = "https://api.github.com/repos/lfmundim/AzPin/releases/latest"; + let res = match self.client.get(url) + .header("User-Agent", "AzPin") + .header("Accept", "application/vnd.github+json") + .send() + .await + { + Ok(r) => r, + Err(e) => return UpdateCheckState::Failed(e.to_string()), + }; + + if !res.status().is_success() { + return UpdateCheckState::Failed(format!("GitHub API error: {}", res.status())); + } + + let release: GitHubRelease = match res.json().await { + Ok(r) => r, + Err(e) => return UpdateCheckState::Failed(format!("Failed to parse release: {}", e)), + }; + + let latest_version = release.tag_name.trim_start_matches('v').trim_start_matches('V'); + + if Self::is_newer(latest_version, current_version) { + UpdateCheckState::UpdateAvailable { + current: current_version.to_string(), + latest: latest_version.to_string(), + release_url: release.html_url, + } + } else { + UpdateCheckState::UpToDate { version: current_version.to_string() } + } + } + + fn is_newer(latest: &str, current: &str) -> bool { + let parse = |v: &str| -> Vec { + v.split('.').filter_map(|s| s.parse().ok()).collect() + }; + let l = parse(latest); + let c = parse(current); + + let max_len = std::cmp::max(l.len(), c.len()); + for i in 0..max_len { + let lv = l.get(i).copied().unwrap_or(0); + let cv = c.get(i).copied().unwrap_or(0); + if lv != cv { + return lv > cv; + } + } + false + } +} diff --git a/src/ubuntu/AzPin/src/ui/indicator.rs b/src/ubuntu/AzPin/src/ui/indicator.rs new file mode 100644 index 0000000..705bd24 --- /dev/null +++ b/src/ubuntu/AzPin/src/ui/indicator.rs @@ -0,0 +1,467 @@ +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use gtk4 as gtk; +use ksni::{menu, MenuItem, Tray}; + +use crate::models::persistence::PinnedResource; +use crate::services::arm::ArmService; +use crate::services::az_cli::AzSubscription; +use crate::services::db::Db; +use crate::services::permissions::PermissionsService; +use crate::utils::portal_url; +use crate::utils::resource_type::{is_runnable, ResourceState}; + +pub struct AzPinTray { + pub db: Arc, + pub arm_service: Arc, + pub permissions_service: Arc, + pub open_tx: gtk::glib::Sender<()>, + pub settings_tx: gtk::glib::Sender<()>, + pub quit_tx: gtk::glib::Sender<()>, + pub pin_changed_tx: gtk::glib::Sender<()>, + pub tokio_handle: tokio::runtime::Handle, + pub state_cache: Arc>>, + pub permissions_cache: Arc>>, + pub rg_resources_cache: Arc>>>, + pub account_cache: Arc>>, +} + +impl Tray for AzPinTray { + fn icon_name(&self) -> String { + "weather-overcast".into() + } + + fn title(&self) -> String { + "AzPin".into() + } + + fn id(&self) -> String { + "com.lfmundim.azpin".into() + } + + fn menu(&self) -> Vec> { + let mut items = Vec::new(); + + // 1. Account info — read from cache, never call AzCliService here + let account_label = if let Ok(cache) = self.account_cache.read() { + match cache.as_ref() { + Some(sub) => { + let who = sub + .user + .as_ref() + .map(|u| u.name.as_str()) + .unwrap_or(sub.name.as_str()); + format!("✓ {}", who) + } + None => "Not signed in".to_string(), + } + } else { + "Not signed in".to_string() + }; + + items.push( + menu::StandardItem { + label: account_label, + enabled: false, + ..Default::default() + } + .into(), + ); + + items.push(menu::MenuItem::Separator); + + // 2. Pinned Groups + if let Ok(groups) = self.db.get_pinned_groups() { + let arm_svc = self.arm_service.clone(); + + for group in groups { + let mut group_submenu = Vec::new(); + + let resources = if let Ok(cache) = self.rg_resources_cache.read() { + cache.get(&group.id).cloned().unwrap_or_default() + } else { + Vec::new() + }; + + if !resources.is_empty() { + for res in resources { + let runnable = is_runnable(&res.type_); + + let state = if runnable { + if let Ok(cache) = self.state_cache.read() { + cache.get(&res.id).cloned().unwrap_or(ResourceState::Unknown) + } else { + ResourceState::Unknown + } + } else { + ResourceState::Unknown + }; + + let can_manage = if runnable { + if let Ok(cache) = self.permissions_cache.read() { + cache.get(&res.id).copied() + } else { + None + } + } else { + None + }; + + let res_id_portal = res.id.clone(); + let mut submenu: Vec> = vec![menu::StandardItem { + label: "Open in Portal".into(), + activate: Box::new(move |_| { + let uri = portal_url::resource_url(&res_id_portal); + let _ = gtk::gio::AppInfo::launch_default_for_uri( + &uri, + None::<>k::gio::AppLaunchContext>, + ); + }), + ..Default::default() + } + .into()]; + + // Only show action buttons when: runnable + permissions confirmed + if runnable && can_manage == Some(true) { + submenu.push(menu::MenuItem::Separator); + build_action_items( + &mut submenu, + &res, + &state, + &arm_svc, + &self.tokio_handle, + &self.state_cache, + ); + } else if runnable && can_manage.is_none() { + // Permissions not yet loaded — fail safe, show nothing + } else if runnable { + // can_manage == Some(false) — no buttons + } + + let label = res.name.clone(); + + if runnable { + group_submenu.push( + menu::SubMenu { + label, + submenu, + ..Default::default() + } + .into(), + ); + } else { + let r_id = res.id.clone(); + group_submenu.push( + menu::StandardItem { + label: res.name.clone(), + activate: Box::new(move |_| { + let uri = portal_url::resource_url(&r_id); + let _ = gtk::gio::AppInfo::launch_default_for_uri( + &uri, + None::<>k::gio::AppLaunchContext>, + ); + }), + ..Default::default() + } + .into(), + ); + } + } + } else { + group_submenu.push( + menu::StandardItem { + label: "Loading resources...".into(), + enabled: false, + ..Default::default() + } + .into(), + ); + } + + if !group_submenu.is_empty() { + group_submenu.push(menu::MenuItem::Separator); + } + + let g_sub_id = group.subscription_id.clone(); + let g_name = group.name.clone(); + group_submenu.push( + menu::StandardItem { + label: "Open Resource Group in Portal".into(), + activate: Box::new(move |_| { + let uri = portal_url::resource_group_url(&g_sub_id, &g_name); + let _ = gtk::gio::AppInfo::launch_default_for_uri( + &uri, + None::<>k::gio::AppLaunchContext>, + ); + }), + ..Default::default() + } + .into(), + ); + + let g_id_unpin = group.id.clone(); + let db_unpin = self.db.clone(); + group_submenu.push( + menu::StandardItem { + label: "Unpin".into(), + activate: Box::new(move |tray: &mut AzPinTray| { + let _ = db_unpin.delete_pinned_group(&g_id_unpin); + let _ = tray.pin_changed_tx.send(()); + }), + ..Default::default() + } + .into(), + ); + + items.push( + menu::SubMenu { + label: group.name, + submenu: group_submenu, + ..Default::default() + } + .into(), + ); + } + } + + // 3. Pinned Individual Resources (orphans not part of a pinned group) + if let Ok(orphans) = self.db.get_orphan_resources() { + if !orphans.is_empty() { + items.push(menu::MenuItem::Separator); + + for res in orphans { + let runnable = is_runnable(&res.type_); + + if runnable { + let state = if let Ok(cache) = self.state_cache.read() { + cache.get(&res.id).cloned().unwrap_or(ResourceState::Unknown) + } else { + ResourceState::Unknown + }; + + let can_manage = if let Ok(cache) = self.permissions_cache.read() { + cache.get(&res.id).copied() + } else { + None + }; + + let res_id_portal = res.id.clone(); + let mut submenu: Vec> = vec![ + menu::StandardItem { + label: "Open in Portal".into(), + activate: Box::new(move |_| { + let uri = portal_url::resource_url(&res_id_portal); + let _ = gtk::gio::AppInfo::launch_default_for_uri( + &uri, + None::<>k::gio::AppLaunchContext>, + ); + }), + ..Default::default() + } + .into(), + ]; + + if can_manage == Some(true) { + submenu.push(menu::MenuItem::Separator); + build_action_items( + &mut submenu, + &res, + &state, + &self.arm_service, + &self.tokio_handle, + &self.state_cache, + ); + } + + submenu.push(menu::MenuItem::Separator); + + let r_id_unpin = res.id.clone(); + let db_unpin = self.db.clone(); + submenu.push( + menu::StandardItem { + label: "Unpin".into(), + activate: Box::new(move |tray: &mut AzPinTray| { + let _ = db_unpin.delete_pinned_resource(&r_id_unpin); + let _ = tray.pin_changed_tx.send(()); + }), + ..Default::default() + } + .into(), + ); + + items.push( + menu::SubMenu { + label: res.name.clone(), + submenu, + ..Default::default() + } + .into(), + ); + } else { + let r_id = res.id.clone(); + items.push( + menu::StandardItem { + label: res.name.clone(), + activate: Box::new(move |_| { + let uri = portal_url::resource_url(&r_id); + let _ = gtk::gio::AppInfo::launch_default_for_uri( + &uri, + None::<>k::gio::AppLaunchContext>, + ); + }), + ..Default::default() + } + .into(), + ); + } + } + } + } + + items.push(menu::MenuItem::Separator); + + let tx = self.open_tx.clone(); + items.push( + menu::StandardItem { + label: "Open AzPin...".into(), + activate: Box::new(move |_| { + let _ = tx.send(()); + }), + ..Default::default() + } + .into(), + ); + + let settings_tx = self.settings_tx.clone(); + items.push( + menu::StandardItem { + label: "Settings...".into(), + activate: Box::new(move |_| { + let _ = settings_tx.send(()); + }), + ..Default::default() + } + .into(), + ); + + let quit_tx = self.quit_tx.clone(); + items.push( + menu::StandardItem { + label: "Quit AzPin".into(), + activate: Box::new(move |_| { + let _ = quit_tx.send(()); + }), + ..Default::default() + } + .into(), + ); + + items + } +} + +fn build_action_items( + submenu: &mut Vec>, + res: &PinnedResource, + state: &ResourceState, + arm_svc: &Arc, + tokio_handle: &tokio::runtime::Handle, + state_cache: &Arc>>, +) { + if state.is_transitioning() { + let label = format!("… {}", state.display_label()); + submenu.push( + menu::StandardItem { + label, + enabled: false, + ..Default::default() + } + .into(), + ); + return; + } + + if state.is_stopped() { + let r_id = res.id.clone(); + let sub = res.subscription_id.clone(); + let svc = arm_svc.clone(); + let handle = tokio_handle.clone(); + let cache = state_cache.clone(); + submenu.push( + menu::StandardItem { + label: "▶ Start".into(), + activate: Box::new(move |_| { + if let Ok(mut c) = cache.write() { + c.insert(r_id.clone(), ResourceState::Starting); + } + let s = svc.clone(); + let sid = sub.clone(); + let rid = r_id.clone(); + handle.spawn(async move { + let _ = s.start_resource(&sid, &rid).await; + }); + }), + ..Default::default() + } + .into(), + ); + } else if state.is_running() { + let r_id_stop = res.id.clone(); + let sub_stop = res.subscription_id.clone(); + let svc_stop = arm_svc.clone(); + let handle_stop = tokio_handle.clone(); + let cache_stop = state_cache.clone(); + submenu.push( + menu::StandardItem { + label: "■ Stop".into(), + activate: Box::new(move |_| { + if let Ok(mut c) = cache_stop.write() { + c.insert(r_id_stop.clone(), ResourceState::Stopping); + } + let s = svc_stop.clone(); + let sid = sub_stop.clone(); + let rid = r_id_stop.clone(); + handle_stop.spawn(async move { + let _ = s.stop_resource(&sid, &rid).await; + }); + }), + ..Default::default() + } + .into(), + ); + + let r_id_restart = res.id.clone(); + let sub_restart = res.subscription_id.clone(); + let svc_restart = arm_svc.clone(); + let handle_restart = tokio_handle.clone(); + let cache_restart = state_cache.clone(); + submenu.push( + menu::StandardItem { + label: "⟳ Restart".into(), + activate: Box::new(move |_| { + if let Ok(mut c) = cache_restart.write() { + c.insert(r_id_restart.clone(), ResourceState::Restarting); + } + let s = svc_restart.clone(); + let sid = sub_restart.clone(); + let rid = r_id_restart.clone(); + handle_restart.spawn(async move { + let _ = s.restart_resource(&sid, &rid).await; + }); + }), + ..Default::default() + } + .into(), + ); + } else { + submenu.push( + menu::StandardItem { + label: "Loading...".into(), + enabled: false, + ..Default::default() + } + .into(), + ); + } +} diff --git a/src/ubuntu/AzPin/src/ui/main_window.rs b/src/ubuntu/AzPin/src/ui/main_window.rs new file mode 100644 index 0000000..14bcc0c --- /dev/null +++ b/src/ubuntu/AzPin/src/ui/main_window.rs @@ -0,0 +1,540 @@ +use adw::prelude::*; +use gtk4 as gtk; +use std::sync::Arc; +use std::cell::RefCell; +use std::rc::Rc; +use crate::services::db::Db; +use crate::services::arm::ArmService; +use crate::services::az_cli::{AzCliService, AzSubscription}; +use crate::ui::settings::SettingsWindow; +use crate::utils::portal_url; + +pub struct MainWindow { + window: adw::ApplicationWindow, +} + +fn get_icon_for_type(res_type: &str) -> &'static str { + match res_type.to_lowercase().as_str() { + "microsoft.web/sites" | "microsoft.web/serverfarms" => "applications-internet-symbolic", + "microsoft.app/containerapps" => "applications-development-symbolic", + "microsoft.dbforpostgresql/flexibleservers" => "server-database-symbolic", + "microsoft.keyvault/vaults" => "dialog-password-symbolic", + "microsoft.insights/components" => "help-about-symbolic", + "microsoft.storage/storageaccounts" => "drive-harddisk-symbolic", + "microsoft.compute/virtualmachines" => "computer-symbolic", + _ => "text-x-generic-symbolic" + } +} + +impl MainWindow { + pub fn new(app: &adw::Application, db: Arc, arm_service: Arc, tray_handle: ksni::Handle, pin_changed_rx: gtk::glib::Receiver<()>) -> Self { + // Capture handle here (GTK callback runs inside Tokio runtime). Cloned into + // std::thread::spawn closures below — Handle::current() would panic there. + let tokio_handle = tokio::runtime::Handle::current(); + + let root_stack = gtk::Stack::new(); + root_stack.set_transition_type(gtk::StackTransitionType::Crossfade); + + let split_view = adw::OverlaySplitView::new(); + + // --- Sidebar (Pinned Items) --- + let sidebar_box = gtk::Box::new(gtk::Orientation::Vertical, 0); + let sidebar_header = adw::HeaderBar::new(); + sidebar_header.set_title_widget(Some(>k::Label::new(Some("AzPin")))); + + let settings_btn = gtk::Button::builder().icon_name("emblem-system-symbolic").build(); + let app_clone = app.clone(); + let db_clone = db.clone(); + settings_btn.connect_clicked(move |_| { + let settings = SettingsWindow::new(&app_clone, db_clone.clone()); + settings.present(); + }); + sidebar_header.pack_end(&settings_btn); + sidebar_box.append(&sidebar_header); + + let pinned_list = gtk::ListBox::new(); + pinned_list.add_css_class("navigation-sidebar"); + + let scrolled_sidebar = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .child(&pinned_list) + .vexpand(true) + .build(); + sidebar_box.append(&scrolled_sidebar); + split_view.set_sidebar(Some(&sidebar_box)); + + // --- Detail View (Browse) --- + let detail_box = gtk::Box::new(gtk::Orientation::Vertical, 0); + let detail_header = adw::HeaderBar::new(); + detail_header.set_title_widget(Some(>k::Label::new(Some("Browse Azure")))); + detail_box.append(&detail_header); + + // Header controls: Subscription Dropdown & Search + let controls_box = gtk::Box::new(gtk::Orientation::Horizontal, 12); + controls_box.set_margin_top(12); controls_box.set_margin_bottom(12); + controls_box.set_margin_start(16); controls_box.set_margin_end(16); + + let sub_label = gtk::Label::new(Some("Subscription")); + controls_box.append(&sub_label); + + let sub_model = gtk::StringList::new(&["Loading Subscriptions..."]); + let sub_dropdown = gtk::DropDown::new(Some(sub_model.clone()), gtk::Expression::NONE); + sub_dropdown.set_valign(gtk::Align::Center); + controls_box.append(&sub_dropdown); + + let search_entry = gtk::SearchEntry::new(); + search_entry.set_hexpand(true); + search_entry.set_placeholder_text(Some("Search resource groups")); + search_entry.set_valign(gtk::Align::Center); + controls_box.append(&search_entry); + + detail_box.append(&controls_box); + + // Resource Groups List + let rg_list = gtk::ListBox::new(); + rg_list.add_css_class("boxed-list"); + rg_list.set_margin_start(16); rg_list.set_margin_end(16); + rg_list.set_margin_bottom(16); + + let scrolled_rgs = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .child(&rg_list) + .vexpand(true) + .build(); + detail_box.append(&scrolled_rgs); + + split_view.set_content(Some(&detail_box)); + root_stack.add_named(&split_view, Some("main")); + + // --- Onboarding View --- + let status_page = adw::StatusPage::builder() + .title("Welcome to AzPin") + .description("Not signed in — run 'az login' in your terminal.") + .icon_name("network-server-symbolic") + .build(); + + let refresh_btn = gtk::Button::builder() + .label("Refresh Auth Status") + .css_classes(vec!["suggested-action".to_string(), "pill".to_string()]) + .halign(gtk::Align::Center) + .margin_bottom(32) + .build(); + + status_page.set_child(Some(&refresh_btn)); + root_stack.add_named(&status_page, Some("onboarding")); + + // --- Logic: Load Pinned Items to Sidebar --- + let load_pinned_items = { + let pinned_list = pinned_list.clone(); + let db_ref = db.clone(); + Rc::new(move || { + while let Some(child) = pinned_list.first_child() { + pinned_list.remove(&child); + } + + // Add Pinned RGs + let mut title_added = false; + if let Ok(groups) = db_ref.get_pinned_groups() { + for group in groups { + if !title_added { + let label = gtk::Label::new(Some("Pinned Resource Groups")); + label.set_use_markup(true); label.set_halign(gtk::Align::Start); + label.set_margin_start(12); label.set_margin_top(12); label.set_margin_bottom(6); + pinned_list.append(&label); + title_added = true; + } + let row = gtk::ListBoxRow::new(); + let box_ = gtk::Box::new(gtk::Orientation::Horizontal, 8); + box_.set_margin_start(12); box_.set_margin_end(12); box_.set_margin_top(8); box_.set_margin_bottom(8); + box_.append(>k::Image::from_icon_name("folder-symbolic")); + let label = gtk::Label::new(Some(&group.name)); + label.set_hexpand(true); label.set_halign(gtk::Align::Start); + box_.append(&label); + row.set_child(Some(&box_)); + + // Open portal on click + let g_id = group.id.clone(); + let tap = gtk::GestureClick::new(); + tap.connect_pressed(move |_, _, _, _| { + let uri = portal_url::resource_url(&g_id); + let _ = gtk::gio::AppInfo::launch_default_for_uri(&uri, None::<>k::gio::AppLaunchContext>); + }); + row.add_controller(tap); + pinned_list.append(&row); + } + } + + // Add Pinned Resources + let mut title_added2 = false; + // Wait, db.rs doesn't have a direct get_all_pinned_resources method, we have to iterate groups or create a new method. + // For simplicity, we can fetch them via a raw query if needed, or get them from db. + }) + }; + // Wait, db.rs only has `get_pinned_resources(&self, group_id: &str)`. + // We'll need to fetch all pinned groups, then fetch resources for each. + + let load_pinned_items_clone = load_pinned_items.clone(); + let db_for_sidebar = db.clone(); + let pinned_list_for_sidebar = pinned_list.clone(); + + let load_sidebar = Rc::new(move || { + while let Some(child) = pinned_list_for_sidebar.first_child() { + pinned_list_for_sidebar.remove(&child); + } + let label = gtk::Label::new(Some("Pinned")); + label.set_use_markup(true); label.set_halign(gtk::Align::Start); + label.set_margin_start(12); label.set_margin_top(12); label.set_margin_bottom(6); + pinned_list_for_sidebar.append(&label); + + if let Ok(groups) = db_for_sidebar.get_pinned_groups() { + for group in groups { + // Pinned Group row + let row = gtk::ListBoxRow::new(); + let box_ = gtk::Box::new(gtk::Orientation::Horizontal, 8); + box_.set_margin_start(12); box_.set_margin_end(12); box_.set_margin_top(8); box_.set_margin_bottom(8); + box_.append(>k::Image::from_icon_name("folder-symbolic")); + let label = gtk::Label::new(Some(&group.name)); + label.set_hexpand(true); label.set_halign(gtk::Align::Start); + box_.append(&label); + row.set_child(Some(&box_)); + let g_id = group.id.clone(); + let tap = gtk::GestureClick::new(); + tap.connect_pressed(move |_, _, _, _| { + let uri = portal_url::resource_url(&g_id); + let _ = gtk::gio::AppInfo::launch_default_for_uri(&uri, None::<>k::gio::AppLaunchContext>); + }); + row.add_controller(tap); + pinned_list_for_sidebar.append(&row); + + // Fetch pinned resources for this group + if let Ok(resources) = db_for_sidebar.get_pinned_resources(&group.id) { + for res in resources { + let row = gtk::ListBoxRow::new(); + let box_ = gtk::Box::new(gtk::Orientation::Horizontal, 8); + box_.set_margin_start(24); box_.set_margin_end(12); box_.set_margin_top(8); box_.set_margin_bottom(8); // indented + box_.append(>k::Image::from_icon_name(get_icon_for_type(&res.type_))); + let label = gtk::Label::new(Some(&res.name)); + label.set_hexpand(true); label.set_halign(gtk::Align::Start); + box_.append(&label); + row.set_child(Some(&box_)); + let r_id = res.id.clone(); + let tap = gtk::GestureClick::new(); + tap.connect_pressed(move |_, _, _, _| { + let uri = portal_url::resource_url(&r_id); + let _ = gtk::gio::AppInfo::launch_default_for_uri(&uri, None::<>k::gio::AppLaunchContext>); + }); + row.add_controller(tap); + pinned_list_for_sidebar.append(&row); + } + } + } + } + }); + let ls_for_rx = load_sidebar.clone(); + pin_changed_rx.attach(None, move |_| { + ls_for_rx(); + gtk::glib::ControlFlow::Continue + }); + load_sidebar(); + + // --- Logic: Load Subscriptions --- + let subs_cache: Rc>> = Rc::new(RefCell::new(Vec::new())); + let (sub_tx, sub_rx) = gtk::glib::MainContext::channel(gtk::glib::Priority::DEFAULT); + let sub_model_clone = sub_model.clone(); + + let load_rgs = { + let sub_dropdown_ref = sub_dropdown.clone(); + let subs_cache = subs_cache.clone(); + let arm_svc_rg = arm_service.clone(); + let live_rg_list_clone = rg_list.clone(); + let db_c = db.clone(); + let tray_handle_rg = tray_handle.clone(); + let load_sidebar_rg = load_sidebar.clone(); + + Rc::new(move || { + let mut idx = sub_dropdown_ref.selected() as usize; + let subs = subs_cache.borrow(); + if subs.is_empty() { return; } + if idx >= subs.len() { + idx = 0; + } + + let sub_id = subs[idx].id.clone(); + let a_svc = arm_svc_rg.clone(); + let list_c = live_rg_list_clone.clone(); + let db_c = db_c.clone(); + let tray_c = tray_handle_rg.clone(); + let ls_c = load_sidebar_rg.clone(); + + while let Some(child) = list_c.first_child() { + list_c.remove(&child); + } + + let loading_row = adw::ActionRow::new(); + loading_row.set_title("Loading Resource Groups..."); + list_c.append(&loading_row); + + let list_c_for_future = list_c.clone(); + gtk::glib::spawn_future_local(async move { + list_c_for_future.remove(&loading_row); + match a_svc.fetch_resource_groups(&sub_id).await { + Ok(groups) => { + for group in groups { + let exp_row = adw::ExpanderRow::new(); + exp_row.set_title(&group.name); + exp_row.add_prefix(>k::Image::from_icon_name("folder-symbolic")); + + // Check if pinned + let is_pinned = db_c.get_pinned_groups().unwrap_or_default().iter().any(|g| g.id == group.id); + + // Pinning the whole RG hides the per-resource pin buttons — + // resources are already covered by the group pin. + let rg_pinned = Rc::new(RefCell::new(is_pinned)); + let res_pin_btns: Rc>> = Rc::new(RefCell::new(Vec::new())); + + let pin_btn = gtk::ToggleButton::builder() + .icon_name("view-pin-symbolic") + .css_classes(vec!["flat".to_string()]) + .valign(gtk::Align::Center) + .active(is_pinned) + .build(); + + let g_id = group.id.clone(); + let s_id = sub_id.clone(); + let g_name = group.name.clone(); + let db_pin = db_c.clone(); + let tr_pin = tray_c.clone(); + let ls_pin = ls_c.clone(); + let rg_pinned_toggle = rg_pinned.clone(); + let res_pin_btns_toggle = res_pin_btns.clone(); + + pin_btn.connect_toggled(move |btn| { + use crate::models::persistence::PinnedResourceGroup; + if btn.is_active() { + let _ = db_pin.save_pinned_group(&PinnedResourceGroup { + id: g_id.clone(), subscription_id: s_id.clone(), subscription_display_name: None, name: g_name.clone(), display_order: 0, resources: vec![] + }); + } else { + let _ = db_pin.delete_pinned_group(&g_id); + } + *rg_pinned_toggle.borrow_mut() = btn.is_active(); + for res_btn in res_pin_btns_toggle.borrow().iter() { + res_btn.set_visible(!btn.is_active()); + } + let _ = tr_pin.update(|_| {}); + ls_pin(); + }); + exp_row.add_suffix(&pin_btn); + + // Load resources on expand + let loaded = Rc::new(RefCell::new(false)); + let a_svc_res = a_svc.clone(); + let sub_res = sub_id.clone(); + let grp_res = group.name.clone(); + let g_id_res = group.id.clone(); + let db_res = db_c.clone(); + let tray_res = tray_c.clone(); + let ls_res = ls_c.clone(); + let exp_row_clone = exp_row.clone(); + let rg_pinned_expand = rg_pinned.clone(); + let res_pin_btns_expand = res_pin_btns.clone(); + + exp_row.connect_expanded_notify(move |exp| { + if exp.is_expanded() && !*loaded.borrow() { + *loaded.borrow_mut() = true; + let asr = a_svc_res.clone(); + let sr = sub_res.clone(); + let gr = grp_res.clone(); + let dbr = db_res.clone(); + let tr = tray_res.clone(); + let lsr = ls_res.clone(); + let er = exp_row_clone.clone(); + let g_id_r = g_id_res.clone(); + let rg_pinned_load = rg_pinned_expand.clone(); + let res_pin_btns_load = res_pin_btns_expand.clone(); + + gtk::glib::spawn_future_local(async move { + match asr.fetch_resources(&sr, &gr).await { + Ok(resources) => { + for res in resources { + let act_row = adw::ActionRow::new(); + act_row.set_title(&res.name); + act_row.add_prefix(>k::Image::from_icon_name(crate::utils::icon_mapper::get_icon_for_type(&res.type_))); + + // Check if pinned + let is_res_pinned = dbr.get_pinned_resources(&g_id_r).unwrap_or_default().iter().any(|r| r.id == res.id); + + let res_pin_btn = gtk::ToggleButton::builder() + .icon_name("view-pin-symbolic") + .css_classes(vec!["flat".to_string()]) + .valign(gtk::Align::Center) + .active(is_res_pinned) + .visible(!*rg_pinned_load.borrow()) + .build(); + res_pin_btns_load.borrow_mut().push(res_pin_btn.clone()); + + let r_id = res.id.clone(); + let r_name = res.name.clone(); + let r_type = res.type_.clone(); + let r_loc = res.location.clone(); + let g_r = gr.clone(); + let s_r = sr.clone(); + let db_r_pin = dbr.clone(); + let tr_r_pin = tr.clone(); + let ls_r_pin = lsr.clone(); + let group_id_r = g_id_r.clone(); + + // Save group first to satisfy foreign key if it's not pinned + let group_id_for_fk = g_id_r.clone(); + let sub_for_fk = sr.clone(); + let group_name_for_fk = gr.clone(); + + res_pin_btn.connect_toggled(move |btn| { + use crate::models::persistence::{PinnedResource, PinnedResourceGroup}; + if btn.is_active() { + // Ensure group exists in DB implicitly + let _ = db_r_pin.ensure_implicit_group(&group_id_for_fk, &sub_for_fk, &group_name_for_fk, None); + let _ = db_r_pin.save_pinned_resource(&PinnedResource { + id: r_id.clone(), name: r_name.clone(), type_: r_type.clone(), + resource_group: g_r.clone(), subscription_id: s_r.clone(), location: r_loc.clone(), display_order: 0, + }, &group_id_r); + } else { + let _ = db_r_pin.delete_pinned_resource(&r_id); + } + let _ = tr_r_pin.update(|_| {}); + ls_r_pin(); + }); + act_row.add_suffix(&res_pin_btn); + er.add_row(&act_row); + } + } + Err(e) => { + let err_row = adw::ActionRow::new(); + err_row.set_title(&format!("Error: {}", e)); + er.add_row(&err_row); + } + } + }); + } + }); + + list_c.append(&exp_row); + } + } + Err(e) => { + let err_row = adw::ActionRow::new(); + err_row.set_title(&format!("Failed to fetch Resource Groups: {}", e)); + list_c.append(&err_row); + } + } + }); + }) + }; + + let subs_cache_clone = subs_cache.clone(); + let sub_dropdown_clone = sub_dropdown.clone(); + let load_rgs_clone = load_rgs.clone(); + sub_rx.attach(None, move |result: Result, String>| { + match result { + Ok(subs) => { + sub_dropdown_clone.set_selected(gtk::INVALID_LIST_POSITION); + sub_model_clone.splice(0, sub_model_clone.n_items(), &subs.iter().map(|s| s.name.as_str()).collect::>()); + *subs_cache_clone.borrow_mut() = subs; + sub_dropdown_clone.set_selected(0); + load_rgs_clone(); + } + Err(e) => { + sub_dropdown_clone.set_selected(gtk::INVALID_LIST_POSITION); + sub_model_clone.splice(0, sub_model_clone.n_items(), &[&format!("Error: {}", e)]); + } + } + gtk::glib::ControlFlow::Continue + }); + let sub_tx_err = sub_tx.clone(); + let handle = tokio_handle.clone(); + std::thread::spawn(move || { + match handle.block_on(crate::services::az_cli::AzCliService::list_subscriptions()) { + Ok(subs) => { + let _ = sub_tx.send(Ok(subs)); + } + Err(e) => { + let _ = sub_tx_err.send(Err(e)); + } + } + }); + + // Search logic + let rg_list_search = rg_list.clone(); + search_entry.connect_search_changed(move |entry| { + let query = entry.text().to_string().to_lowercase(); + let mut i = 0; + while let Some(row) = rg_list_search.row_at_index(i) { + if let Some(exp) = row.downcast_ref::() { + let title = exp.title().to_string().to_lowercase(); + row.set_visible(query.is_empty() || title.contains(&query)); + } + i += 1; + } + }); + + // Subscription Selected Logic + sub_dropdown.connect_selected_item_notify(move |_| { + load_rgs(); + }); + + // --- Logic: Auth Status --- + let (chk_tx, chk_rx) = gtk::glib::MainContext::channel(gtk::glib::Priority::DEFAULT); + let root_stack_init = root_stack.clone(); + chk_rx.attach(None, move |is_logged_in| { + if is_logged_in { + root_stack_init.set_visible_child_name("main"); + } else { + root_stack_init.set_visible_child_name("onboarding"); + } + gtk::glib::ControlFlow::Continue + }); + let handle = tokio_handle.clone(); + std::thread::spawn(move || { + let is_logged_in = handle.block_on(AzCliService::get_default_subscription()).is_ok(); + let _ = chk_tx.send(is_logged_in); + }); + + let (sender, receiver) = gtk::glib::MainContext::channel(gtk::glib::Priority::DEFAULT); + let root_stack_clone = root_stack.clone(); + receiver.attach(None, move |is_logged_in| { + if is_logged_in { + root_stack_clone.set_visible_child_name("main"); + } + gtk::glib::ControlFlow::Continue + }); + + refresh_btn.connect_clicked(move |_| { + let sender = sender.clone(); + let handle = tokio_handle.clone(); + std::thread::spawn(move || { + let is_logged_in = handle.block_on(AzCliService::get_default_subscription()).is_ok(); + let _ = sender.send(is_logged_in); + }); + }); + + // --- Create ApplicationWindow --- + let window = adw::ApplicationWindow::builder() + .application(app) + .title("AzPin") + .default_width(900) + .default_height(600) + .content(&root_stack) + .build(); + + window.connect_close_request(move |win| { + win.hide(); + gtk::glib::Propagation::Stop + }); + + Self { window } + } + + pub fn present(&self) { + self.window.present(); + } +} diff --git a/src/ubuntu/AzPin/src/ui/mod.rs b/src/ubuntu/AzPin/src/ui/mod.rs new file mode 100644 index 0000000..aa9e0aa --- /dev/null +++ b/src/ubuntu/AzPin/src/ui/mod.rs @@ -0,0 +1,3 @@ +pub mod indicator; +pub mod settings; +pub mod main_window; diff --git a/src/ubuntu/AzPin/src/ui/settings.rs b/src/ubuntu/AzPin/src/ui/settings.rs new file mode 100644 index 0000000..cfa22f4 --- /dev/null +++ b/src/ubuntu/AzPin/src/ui/settings.rs @@ -0,0 +1,238 @@ +use gtk4::prelude::*; +use adw::prelude::*; +use gtk4 as gtk; +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; +use crate::services::db::Db; +use crate::services::az_cli::AzCliService; +use crate::services::updater::{UpdaterService, UpdateCheckState}; + +pub struct SettingsWindow { + window: adw::PreferencesWindow, +} + +impl SettingsWindow { + pub fn new(app: &adw::Application, db: Arc) -> Self { + let window = adw::PreferencesWindow::builder() + .application(app) + .title("AzPin Settings") + .build(); + + // Account Page + let account_page = adw::PreferencesPage::builder() + .title("Account") + .icon_name("avatar-default-symbolic") + .build(); + + let account_group = adw::PreferencesGroup::builder() + .title("Identity") + .build(); + + let identity_row = adw::ActionRow::builder() + .title("Current Tenant") + .subtitle("Loading...") + .build(); + + let id_row_clone = identity_row.clone(); + gtk::glib::spawn_future_local(async move { + if let Ok(sub) = AzCliService::get_default_subscription().await { + id_row_clone.set_subtitle(&sub.tenant_id); + } else { + id_row_clone.set_subtitle("Not signed in"); + } + }); + + account_group.add(&identity_row); + account_page.add(&account_group); + + // Subscriptions Page + let subs_page = adw::PreferencesPage::builder() + .title("Subscriptions") + .icon_name("view-list-symbolic") + .build(); + + let subs_group = adw::PreferencesGroup::builder() + .title("Active Subscriptions") + .build(); + + let subs_group_clone = subs_group.clone(); + let db_clone = db.clone(); + gtk::glib::spawn_future_local(async move { + let hidden_subs = db_clone.get_hidden_subscriptions().unwrap_or_default(); + + if let Ok(subs) = AzCliService::list_subscriptions().await { + for sub in subs { + let is_hidden = hidden_subs.contains(&sub.id); + + let sub_toggle = gtk::Switch::new(); + sub_toggle.set_active(!is_hidden); + + let sub_row = adw::ActionRow::builder() + .title(&sub.name) + .subtitle(&sub.id) + .build(); + sub_row.add_suffix(&sub_toggle); + + let db_ref = db_clone.clone(); + let sub_id = sub.id.clone(); + sub_toggle.connect_active_notify(move |switch| { + if switch.is_active() { + let _ = db_ref.show_subscription(&sub_id); + } else { + let _ = db_ref.hide_subscription(&sub_id); + } + }); + + subs_group_clone.add(&sub_row); + } + } + }); + + subs_page.add(&subs_group); + + window.add(&account_page); + window.add(&subs_page); + + // Updates Page + let updates_page = adw::PreferencesPage::builder() + .title("Updates") + .icon_name("software-update-available-symbolic") + .build(); + + let updates_group = adw::PreferencesGroup::builder() + .title("Application Updates") + .build(); + + let current_version = env!("CARGO_PKG_VERSION"); + let version_row = adw::ActionRow::builder() + .title("Current Version") + .subtitle(current_version) + .build(); + + let check_btn = gtk::Button::builder() + .label("Check for Updates") + .margin_top(10) + .margin_bottom(10) + .css_classes(["suggested-action"]) + .build(); + + let status_label = gtk::Label::builder() + .label("") + .wrap(true) + .margin_top(10) + .build(); + + let download_btn = gtk::Button::builder() + .label("Download Update") + .margin_top(10) + .margin_bottom(10) + .visible(false) + .css_classes(["suggested-action"]) + .build(); + + // Snap update suggestion — shown alongside the download button when + // an update is available (snap package name: azpin). + let snap_cmd = "sudo snap refresh azpin"; + let snap_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(6) + .halign(gtk::Align::Center) + .visible(false) + .build(); + let snap_hint = gtk::Label::new(Some("Or update via Snap:")); + snap_hint.add_css_class("dim-label"); + let snap_cmd_label = gtk::Label::new(Some(snap_cmd)); + snap_cmd_label.add_css_class("monospace"); + snap_cmd_label.set_selectable(true); + let snap_copy_btn = gtk::Button::builder() + .icon_name("edit-copy-symbolic") + .tooltip_text("Copy command") + .css_classes(["flat"]) + .valign(gtk::Align::Center) + .build(); + snap_copy_btn.connect_clicked(move |btn| { + btn.clipboard().set_text(snap_cmd); + }); + snap_box.append(&snap_hint); + snap_box.append(&snap_cmd_label); + snap_box.append(&snap_copy_btn); + + let updates_vbox = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(6) + .margin_top(12) + .margin_bottom(12) + .margin_start(12) + .margin_end(12) + .build(); + + updates_vbox.append(&check_btn); + updates_vbox.append(&status_label); + updates_vbox.append(&download_btn); + updates_vbox.append(&snap_box); + + let updates_container_row = adw::ActionRow::new(); + updates_container_row.set_child(Some(&updates_vbox)); + + updates_group.add(&version_row); + updates_group.add(&updates_container_row); + updates_page.add(&updates_group); + window.add(&updates_page); + + // Single click handler reading the latest URL — connecting inside the + // check callback would stack one handler per check. + let release_url_cell: Rc> = Rc::new(RefCell::new(String::new())); + let url_for_click = release_url_cell.clone(); + download_btn.connect_clicked(move |_| { + let url = url_for_click.borrow().clone(); + if !url.is_empty() { + let _ = gtk::gio::AppInfo::launch_default_for_uri(&url, None::<>k::gio::AppLaunchContext>); + } + }); + + let status_label_clone = status_label.clone(); + let download_btn_clone = download_btn.clone(); + let snap_box_clone = snap_box.clone(); + let check_btn_clone = check_btn.clone(); + + check_btn.connect_clicked(move |_| { + let status = status_label_clone.clone(); + let download = download_btn_clone.clone(); + let snap = snap_box_clone.clone(); + let btn = check_btn_clone.clone(); + let url_cell = release_url_cell.clone(); + + btn.set_sensitive(false); + status.set_label("Checking for updates..."); + download.set_visible(false); + snap.set_visible(false); + + gtk::glib::spawn_future_local(async move { + let updater = UpdaterService::new(); + match updater.check_for_updates().await { + UpdateCheckState::UpToDate { version } => { + status.set_label(&format!("You are up to date! (v{})", version)); + } + UpdateCheckState::UpdateAvailable { latest, release_url, .. } => { + status.set_label(&format!("Update available! v{}", latest)); + *url_cell.borrow_mut() = release_url.clone(); + download.set_visible(true); + snap.set_visible(true); + } + UpdateCheckState::Failed(err) => { + status.set_label(&format!("Failed to check for updates: {}", err)); + } + _ => {} + } + btn.set_sensitive(true); + }); + }); + + Self { window } + } + + pub fn present(&self) { + self.window.present(); + } +} diff --git a/src/ubuntu/AzPin/src/utils/icon_mapper.rs b/src/ubuntu/AzPin/src/utils/icon_mapper.rs new file mode 100644 index 0000000..edc1b26 --- /dev/null +++ b/src/ubuntu/AzPin/src/utils/icon_mapper.rs @@ -0,0 +1,18 @@ +pub fn get_icon_for_type(resource_type: &str) -> &'static str { + match resource_type.to_lowercase().as_str() { + "microsoft.compute/virtualmachines" => "computer-symbolic", + "microsoft.sql/servers" + | "microsoft.documentdb/databaseaccounts" + | "microsoft.sql/managedinstances" => "drive-harddisk-symbolic", + "microsoft.web/sites" | "microsoft.web/sites/slots" => "applications-internet-symbolic", + "microsoft.storage/storageaccounts" => "folder-symbolic", + "microsoft.network/virtualnetworks" | "microsoft.network/loadbalancers" => { + "network-workgroup-symbolic" + } + "microsoft.app/containerapps" => "package-x-generic-symbolic", + "microsoft.keyvault/vaults" => "dialog-password-symbolic", + "microsoft.servicebus/namespaces" => "mail-send-symbolic", + "microsoft.logic/workflows" => "system-run-symbolic", + _ => "emblem-system-symbolic", + } +} diff --git a/src/ubuntu/AzPin/src/utils/mod.rs b/src/ubuntu/AzPin/src/utils/mod.rs new file mode 100644 index 0000000..eaddac8 --- /dev/null +++ b/src/ubuntu/AzPin/src/utils/mod.rs @@ -0,0 +1,3 @@ +pub mod icon_mapper; +pub mod portal_url; +pub mod resource_type; diff --git a/src/ubuntu/AzPin/src/utils/portal_url.rs b/src/ubuntu/AzPin/src/utils/portal_url.rs new file mode 100644 index 0000000..e738e90 --- /dev/null +++ b/src/ubuntu/AzPin/src/utils/portal_url.rs @@ -0,0 +1,36 @@ +/// resource_id must start with /subscriptions/... (ARM format). Do not double-prefix. +pub fn resource_url(resource_id: &str) -> String { + format!("https://portal.azure.com/#resource{}", resource_id) +} + +pub fn resource_group_url(subscription_id: &str, name: &str) -> String { + format!( + "https://portal.azure.com/#resource/subscriptions/{}/resourceGroups/{}", + subscription_id, name + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resource_url_no_double_prefix() { + let id = "/subscriptions/abc/resourceGroups/rg/providers/Microsoft.Web/sites/mysite"; + let url = resource_url(id); + assert_eq!( + url, + "https://portal.azure.com/#resource/subscriptions/abc/resourceGroups/rg/providers/Microsoft.Web/sites/mysite" + ); + assert!(!url.contains("#resource/subscriptions") || url.matches("#resource").count() == 1); + } + + #[test] + fn resource_group_url_correct_format() { + let url = resource_group_url("sub-123", "my-rg"); + assert_eq!( + url, + "https://portal.azure.com/#resource/subscriptions/sub-123/resourceGroups/my-rg" + ); + } +} diff --git a/src/ubuntu/AzPin/src/utils/resource_type.rs b/src/ubuntu/AzPin/src/utils/resource_type.rs new file mode 100644 index 0000000..bdf0639 --- /dev/null +++ b/src/ubuntu/AzPin/src/utils/resource_type.rs @@ -0,0 +1,117 @@ +pub const RUNNABLE_TYPES: &[&str] = &[ + "microsoft.web/sites", + "microsoft.web/sites/slots", + "microsoft.app/containerapps", + "microsoft.logic/workflows", + "microsoft.compute/virtualmachines", +]; + +pub fn is_runnable(resource_type: &str) -> bool { + let lower = resource_type.to_lowercase(); + RUNNABLE_TYPES.iter().any(|&t| t == lower) +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ResourceState { + Running, + Stopped, + Starting, + Stopping, + Restarting, + Unknown, +} + +impl ResourceState { + pub fn from_str(s: &str) -> Self { + match s.to_lowercase().as_str() { + "running" | "succeeded" | "enabled" => Self::Running, + "stopped" | "deallocated" | "disabled" | "stopped (deallocated)" => Self::Stopped, + "starting" => Self::Starting, + "stopping" => Self::Stopping, + "restarting" => Self::Restarting, + _ => Self::Unknown, + } + } + + pub fn is_running(&self) -> bool { + matches!(self, Self::Running) + } + + pub fn is_stopped(&self) -> bool { + matches!(self, Self::Stopped) + } + + pub fn is_transitioning(&self) -> bool { + matches!(self, Self::Starting | Self::Stopping | Self::Restarting) + } + + pub fn display_label(&self) -> &'static str { + match self { + Self::Running => "Running", + Self::Stopped => "Stopped", + Self::Starting => "Starting", + Self::Stopping => "Stopping", + Self::Restarting => "Restarting", + Self::Unknown => "Unknown", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_runnable_all_five_types() { + assert!(is_runnable("microsoft.web/sites")); + assert!(is_runnable("microsoft.web/sites/slots")); + assert!(is_runnable("microsoft.app/containerapps")); + assert!(is_runnable("microsoft.logic/workflows")); + assert!(is_runnable("microsoft.compute/virtualmachines")); + } + + #[test] + fn is_runnable_case_insensitive() { + assert!(is_runnable("Microsoft.Web/Sites")); + assert!(is_runnable("MICROSOFT.COMPUTE/VIRTUALMACHINES")); + assert!(is_runnable("Microsoft.App/ContainerApps")); + } + + #[test] + fn is_runnable_false_for_non_runnable() { + assert!(!is_runnable("microsoft.storage/storageaccounts")); + assert!(!is_runnable("microsoft.network/virtualnetworks")); + assert!(!is_runnable("microsoft.compute/disks")); + assert!(!is_runnable("microsoft.compute/snapshots")); + } + + #[test] + fn resource_state_from_str_running_variants() { + assert_eq!(ResourceState::from_str("Running"), ResourceState::Running); + assert_eq!(ResourceState::from_str("Succeeded"), ResourceState::Running); + assert_eq!(ResourceState::from_str("Enabled"), ResourceState::Running); + assert_eq!(ResourceState::from_str("running"), ResourceState::Running); + } + + #[test] + fn resource_state_from_str_stopped_variants() { + assert_eq!(ResourceState::from_str("Stopped"), ResourceState::Stopped); + assert_eq!(ResourceState::from_str("Deallocated"), ResourceState::Stopped); + assert_eq!(ResourceState::from_str("Disabled"), ResourceState::Stopped); + assert_eq!(ResourceState::from_str("Stopped (Deallocated)"), ResourceState::Stopped); + } + + #[test] + fn resource_state_from_str_transitioning() { + assert_eq!(ResourceState::from_str("Starting"), ResourceState::Starting); + assert_eq!(ResourceState::from_str("Stopping"), ResourceState::Stopping); + assert_eq!(ResourceState::from_str("Restarting"), ResourceState::Restarting); + } + + #[test] + fn resource_state_from_str_unknown() { + assert_eq!(ResourceState::from_str("SomeRandomState"), ResourceState::Unknown); + assert_eq!(ResourceState::from_str(""), ResourceState::Unknown); + assert_eq!(ResourceState::from_str("Provisioning"), ResourceState::Unknown); + } +} diff --git a/src/ubuntu/AzPin/target/.rustc_info.json b/src/ubuntu/AzPin/target/.rustc_info.json new file mode 100644 index 0000000..609291f --- /dev/null +++ b/src/ubuntu/AzPin/target/.rustc_info.json @@ -0,0 +1 @@ +{"rustc_fingerprint":2707221323350008625,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.dylib\nlib___.dylib\nlib___.a\nlib___.dylib\n/Users/lucasmundim/.rustup/toolchains/stable-aarch64-apple-darwin\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"aarch64\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"unix\"\ntarget_feature=\"aes\"\ntarget_feature=\"crc\"\ntarget_feature=\"dit\"\ntarget_feature=\"dotprod\"\ntarget_feature=\"dpb\"\ntarget_feature=\"dpb2\"\ntarget_feature=\"fcma\"\ntarget_feature=\"fhm\"\ntarget_feature=\"flagm\"\ntarget_feature=\"fp16\"\ntarget_feature=\"frintts\"\ntarget_feature=\"jsconv\"\ntarget_feature=\"lor\"\ntarget_feature=\"lse\"\ntarget_feature=\"neon\"\ntarget_feature=\"paca\"\ntarget_feature=\"pacg\"\ntarget_feature=\"pan\"\ntarget_feature=\"pmuv3\"\ntarget_feature=\"ras\"\ntarget_feature=\"rcpc\"\ntarget_feature=\"rcpc2\"\ntarget_feature=\"rdm\"\ntarget_feature=\"sb\"\ntarget_feature=\"sha2\"\ntarget_feature=\"sha3\"\ntarget_feature=\"ssbs\"\ntarget_feature=\"vh\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"macos\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"apple\"\nunix\n","stderr":""},"17507481453182080447":{"success":true,"status":"","code":0,"stdout":"rustc 1.96.0 (ac68faa20 2026-05-25)\nbinary: rustc\ncommit-hash: ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96\ncommit-date: 2026-05-25\nhost: aarch64-apple-darwin\nrelease: 1.96.0\nLLVM version: 22.1.2\n","stderr":""}},"successes":{}} \ No newline at end of file