diff --git a/.clinerules b/.clinerules
new file mode 100644
index 0000000..9832b18
--- /dev/null
+++ b/.clinerules
@@ -0,0 +1,23 @@
+# Cline Rules
+
+## ⚠️ PRIORITY ORDER — NON-NEGOTIABLE ⚠️
+
+**Speed is never more important than following workspace procedures.**
+
+Before doing ANY work on ANY user request, you MUST complete these steps in order:
+
+1. **Read `AGENTS-README-FIRST.yaml`** in the repo root for the current API key and endpoints
+2. **GET `/health`** to verify the MCP server is running
+3. **POST `/mcp/sessionlog`** with your session entry — do NOT proceed until this succeeds
+4. **GET `/mcp/sessionlog?limit=5`** to review recent session history for context
+5. **GET `/mcp/todo`** to check current tasks
+6. **THEN** begin working on the user's request
+
+On EVERY subsequent user message:
+1. POST an updated session log entry BEFORE starting work
+2. Complete the user's request
+3. POST the final session log with results, actions taken, and files modified
+
+**If you skip any of these steps, STOP and go back and do them before continuing.**
+Session logging is not optional, not deferred, and not secondary to the task.
+Failure to maintain the session log is a compliance violation.
\ No newline at end of file
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index fec0f3e..ed8c60b 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -1,5 +1,7 @@
# GitHub Copilot Instructions for Remote Agent
+For specific operational instructions (session bootstrap, session log turn workflow, and helper command sequence), follow `AGENTS-README-FIRST.yaml`.
+
## Development Branch Strategy
**IMPORTANT: Always work in the `develop` branch for all development tasks.**
@@ -22,6 +24,17 @@
## Code Quality Standards
+### UI Buttons — CQRS Required
+
+**Every button added to the Desktop UI must be backed by a CQRS request/handler pair with complete tests.**
+
+- Create `*Request` in `src/RemoteAgent.Desktop/Requests/` and `*Handler` in `src/RemoteAgent.Desktop/Handlers/`
+- Register the handler as a transient in `App.axaml.cs` `ConfigureServices`
+- The ViewModel command **must dispatch via `IRequestDispatcher`** — no handler logic in the ViewModel
+- Add `*HandlerTests.cs` in `tests/RemoteAgent.Desktop.UiTests/Handlers/` covering success, failure, and edge cases
+- Add a `Null*` stub for any new infrastructure interface to `SharedHandlerTestStubs.cs`
+- See `docs/REPOSITORY_RULES.md` for full details and the `CopyStatusLogHandler` as a reference implementation
+
### Warnings as Errors
- All warnings are treated as errors (see `Directory.Build.props`)
@@ -101,3 +114,5 @@ Before completing any task:
✅ **Sign commits**
✅ **Run tests before completing tasks**
✅ **Use bash only in workflows (no Python)**
+✅ **Every UI button → CQRS request/handler + complete tests**
+
diff --git a/.github/workflows/build-deploy.yml b/.github/workflows/build-deploy.yml
index 05aa33e..b84a53a 100644
--- a/.github/workflows/build-deploy.yml
+++ b/.github/workflows/build-deploy.yml
@@ -197,6 +197,79 @@ jobs:
dotnet restore RemoteAgent.Desktop.csproj --configfile ../../NuGet.Config -v minimal -p:TargetFramework=net9.0 -p:TargetFrameworks=net9.0
dotnet build RemoteAgent.Desktop.csproj -c Release --no-restore -v minimal -p:TargetFramework=net9.0 -p:TargetFrameworks=net9.0
+ build-msix:
+ name: Build MSIX (Windows)
+ runs-on: windows-latest
+ needs: [detect, build]
+ if: needs.detect.outputs.code == 'true'
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ submodules: recursive
+
+ - name: Setup .NET 10
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Setup .NET 9 (desktop)
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '9.0.x'
+
+ - name: Build MSIX package
+ shell: pwsh
+ env:
+ VERSION: ${{ needs.build.outputs.semver }}
+ run: |
+ .\scripts\package-msix.ps1 -Version $env:VERSION -Force
+
+ - name: Upload MSIX artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: msix-${{ needs.build.outputs.major_minor_patch }}
+ path: |
+ artifacts/*.msix
+ artifacts/*.cer
+ if-no-files-found: error
+ retention-days: 30
+
+ build-deb:
+ name: Build .deb (Linux)
+ runs-on: ubuntu-latest
+ needs: [detect, build]
+ if: needs.detect.outputs.code == 'true'
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup .NET 10
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Setup .NET 9 (desktop)
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '9.0.x'
+
+ - name: Build .deb packages
+ env:
+ VERSION: ${{ needs.build.outputs.semver }}
+ run: |
+ chmod +x scripts/package-deb.sh
+ ./scripts/package-deb.sh --version "$VERSION"
+
+ - name: Upload .deb artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: deb-${{ needs.build.outputs.major_minor_patch }}
+ path: artifacts/*.deb
+ if-no-files-found: error
+ retention-days: 30
+
android:
name: Build Android APK
runs-on: ubuntu-latest
@@ -273,7 +346,7 @@ jobs:
release-beta:
name: Create beta release
runs-on: ubuntu-latest
- needs: [build, desktop-build, android, docker]
+ needs: [build, desktop-build, android, docker, build-msix, build-deb]
permissions:
contents: write
steps:
@@ -313,6 +386,18 @@ jobs:
with:
name: android-apk
+ - name: Download MSIX artifacts
+ uses: actions/download-artifact@v4
+ with:
+ name: msix-${{ needs.build.outputs.major_minor_patch }}
+ path: release-assets/msix
+
+ - name: Download .deb artifacts
+ uses: actions/download-artifact@v4
+ with:
+ name: deb-${{ needs.build.outputs.major_minor_patch }}
+ path: release-assets/deb
+
- name: Create beta release
uses: softprops/action-gh-release@v2
with:
@@ -320,7 +405,11 @@ jobs:
name: Beta v${{ steps.version.outputs.version }} (build ${{ github.run_number }})
prerelease: true
body_path: release-notes.md
- files: com.companyname.remoteagent.app-Signed.apk
+ files: |
+ com.companyname.remoteagent.app-Signed.apk
+ release-assets/msix/*.msix
+ release-assets/msix/*.cer
+ release-assets/deb/*.deb
docker:
name: Build and push Docker image
diff --git a/.github/workflows/build-msix.yml b/.github/workflows/build-msix.yml
deleted file mode 100644
index d74d129..0000000
--- a/.github/workflows/build-msix.yml
+++ /dev/null
@@ -1,117 +0,0 @@
-name: Build MSIX
-
-# Builds the combined Remote Agent MSIX package (service + desktop) on Windows.
-# Triggers on pushes/PRs that touch the relevant source and on manual dispatch.
-
-on:
- push:
- branches: [main, develop]
- paths:
- - 'src/RemoteAgent.Service/**'
- - 'src/RemoteAgent.Desktop/**'
- - 'src/RemoteAgent.Plugins.Ollama/**'
- - 'src/RemoteAgent.Proto/**'
- - 'src/RemoteAgent.App.Logic/**'
- - 'scripts/package-msix.ps1'
- - 'scripts/install-remote-agent.ps1'
- - '.github/workflows/build-msix.yml'
- pull_request:
- branches: [main, develop]
- paths:
- - 'src/RemoteAgent.Service/**'
- - 'src/RemoteAgent.Desktop/**'
- - 'src/RemoteAgent.Plugins.Ollama/**'
- - 'src/RemoteAgent.Proto/**'
- - 'src/RemoteAgent.App.Logic/**'
- - 'scripts/package-msix.ps1'
- - 'scripts/install-remote-agent.ps1'
- - '.github/workflows/build-msix.yml'
- workflow_dispatch:
- inputs:
- configuration:
- description: Build configuration
- required: false
- default: Release
- type: choice
- options: [Release, Debug]
- self_contained:
- description: Publish self-contained (bundles .NET runtime)
- required: false
- default: false
- type: boolean
- sign:
- description: Sign with dev certificate
- required: false
- default: false
- type: boolean
-
-jobs:
- build-msix:
- name: Build MSIX (Windows)
- runs-on: windows-latest
-
- permissions:
- contents: read
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- fetch-depth: 0 # full history for GitVersion
-
- - name: Setup .NET 10
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: '10.0.x'
-
- - name: Setup .NET 9 (desktop)
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: '9.0.x'
-
- - name: Install GitVersion
- uses: gittools/actions/gitversion/setup@v1
- with:
- versionSpec: '6.x'
-
- - name: Get version
- id: version
- uses: gittools/actions/gitversion/execute@v1
- with:
- useConfigFile: true
-
- - name: Build MSIX package
- shell: pwsh
- env:
- CONFIGURATION: ${{ github.event.inputs.configuration || 'Release' }}
- SELF_CONTAINED: ${{ github.event.inputs.self_contained || 'false' }}
- SIGN: ${{ github.event.inputs.sign || 'false' }}
- VERSION: ${{ steps.version.outputs.MajorMinorPatch }}
- run: |
- $params = @{
- Configuration = $env:CONFIGURATION
- Version = $env:VERSION
- OutDir = "artifacts"
- }
- if ($env:SELF_CONTAINED -eq 'true') { $params['SelfContained'] = $true }
- if ($env:SIGN -eq 'true') { $params['DevCert'] = $true }
- .\scripts\package-msix.ps1 @params
-
- - name: Upload MSIX artifact
- uses: actions/upload-artifact@v4
- with:
- name: remote-agent-msix-${{ steps.version.outputs.MajorMinorPatch }}
- path: |
- artifacts/*.msix
- artifacts/*.cer
- if-no-files-found: error
- retention-days: 30
-
- - name: Upload install script
- uses: actions/upload-artifact@v4
- with:
- name: remote-agent-install-scripts
- path: |
- scripts/install-remote-agent.ps1
- scripts/package-msix.ps1
- retention-days: 30
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..bd1dfdd
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "scripts/MsixTools"]
+ path = scripts/MsixTools
+ url = https://github.com/sharpninja/MsixTools.git
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 70ca5cc..86ecaf1 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -5,5 +5,6 @@
"path": "pwsh.exe",
"args": ["-NoProfile"]
}
- }
+ },
+ "fusion-360-helper.enabled": false
}
diff --git a/AGENTS.md b/AGENTS.md
index fa68646..7d3dd4c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,5 +1,27 @@
# Repository Guidelines
+## ⚠️ PRIORITY ORDER — NON-NEGOTIABLE ⚠️
+
+**Speed is never more important than following workspace procedures.**
+
+Before doing ANY work on ANY user request, you MUST complete these steps in order:
+
+1. **Read `AGENTS-README-FIRST.yaml`** in the repo root for the current API key and endpoints
+2. **GET `/health`** to verify the MCP server is running
+3. **POST `/mcp/sessionlog`** with your session turn — do NOT proceed until this succeeds
+4. **GET `/mcp/sessionlog?limit=5`** to review recent session history for context
+5. **GET `/mcp/todo`** to check current tasks
+6. **THEN** begin working on the user's request
+
+On EVERY subsequent user message:
+1. Post a new session log turn (`Add-McpSessionTurn`) before starting work.
+2. Complete the user's request.
+3. Update the turn with results (`Response`) and actions (`Add-McpAction`) when done.
+
+**If you skip any of these steps, STOP and go back and do them before continuing.**
+Session logging is not optional, not deferred, and not secondary to the task.
+Failure to maintain the session log is a compliance violation.
+
## Project Structure & Module Organization
- `src/RemoteAgent.App`: .NET MAUI Android client UI and platform services.
- `src/RemoteAgent.Service`: ASP.NET Core gRPC service that runs and streams agent sessions.
@@ -43,3 +65,4 @@ Use default/minimal verbosity. Do not pass `-q` to `dotnet build` or `dotnet res
- Use concise imperative commit messages, optionally with issue references (example: `Fix Android CI job (#8)`).
- Sign commits (verified signature required by branch protection).
- PRs should include: purpose, summary of changes, test evidence (`dotnet test`), and docs updates when behavior/config changes.
+
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9207169..4b8544d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -76,6 +76,34 @@ All warnings are treated as errors. See [REPOSITORY_RULES.md](docs/REPOSITORY_RU
- Ensure all tests pass before submitting a PR.
- Integration tests should go in `RemoteAgent.Service.IntegrationTests`.
+### Requirements Traceability
+
+All test classes and methods should be annotated with the functional (FR) and technical (TR) requirements they cover:
+
+```csharp
+/// Tests for authentication. FR-13.5; TR-18.1, TR-18.2.
+[Trait("Category", "Requirements")]
+[Trait("Requirement", "FR-13.5")]
+[Trait("Requirement", "TR-18.1")]
+[Trait("Requirement", "TR-18.2")]
+public class AuthUserServiceTests
+{
+ [Fact]
+ public void UpsertListDelete_ShouldPersistUser()
+ {
+ // Test implementation
+ }
+}
+```
+
+After adding or updating requirement annotations, regenerate the traceability matrix:
+
+```bash
+./scripts/generate-requirements-matrix.sh
+```
+
+This updates `docs/requirements-test-coverage.md`, which maps all requirements to their test coverage.
+
## Building and Testing
### Prerequisites
diff --git a/GitVersion.yml b/GitVersion.yml
index 296ef4d..24e2672 100644
--- a/GitVersion.yml
+++ b/GitVersion.yml
@@ -2,4 +2,26 @@
# See https://gitversion.net/docs/reference/configuration
workflow: GitHubFlow/v1
# Start at 1.0.0 when no version tag exists yet
-next-version: 1.0.0
+next-version: 0.1.23
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/RECOVERED_INPUT.md b/RECOVERED_INPUT.md
deleted file mode 100644
index 8f13d4b..0000000
--- a/RECOVERED_INPUT.md
+++ /dev/null
@@ -1,29 +0,0 @@
-# Recovered input from crashed session
-
-Recovered from Cursor chat **Project Initializer**
-(`~/.cursor/chats/.../7ab2a9f8-0aea-49dc-8018-b06edc0195f4/store.db`).
-
-This was the **last user request** in that session (likely the one that was in progress or queued when the session crashed):
-
----
-
-**User request:**
-
-The server should support pushing chat messages with a priority (normal, high, notify). When a chat message with `notify` priority arrives, a system notification should be registered, and if the user taps it, the chat should make the message visible to the user. The user can also swipe a message left or right to archive it.
-
----
-
-## Other user messages from same chat (for context)
-
-- Create remote-agent folder, git init with VS + Cursor gitignore, blank .NET 10 solution
-- Android app (MAUI) talking to Linux service; service spawns Cursor agent, gRPC bidirectional streaming, chat UI, real-time output, logging
-- Docker for service; pipeline → GitHub Pages (F-Droid-style), APK + container to GHCR
-- Markdown parser for Cursor output in chat
-- Thorough unit and integration tests
-- Font Awesome icons; light and dark mode
-- Material Design UI norms
-- **→ Priority + notify + swipe to archive** (above)
-
----
-
-*Recovered by reading Cursor chat SQLite store. Cursor does not persist an unsent “input queue”; only messages that were already sent are stored. If you had typed something and not yet sent it, it would not be recoverable.*
diff --git a/SESSION_HANDOFF_2026-02-18T12-26-48-0600.md b/SESSION_HANDOFF_2026-02-18T12-26-48-0600.md
deleted file mode 100644
index 37accad..0000000
--- a/SESSION_HANDOFF_2026-02-18T12-26-48-0600.md
+++ /dev/null
@@ -1,50 +0,0 @@
-# Session Handoff
-
-## Timestamp
-Generated: 2026-02-18T12:26:48-06:00
-
-## Completed In This Session
-- **Exhaustive CQRS test matrix:** Expanded Phase 3 from ~100 estimated tests to ~237 individually specified tests. Every CQRS handler now has an explicit list of all known happy paths and all known failure paths (Section 6 of the implementation plan, plus per-test checkboxes in the TODO).
-- **Handler entry/exit logging:** Added requirement and design for Debug-level log messages on every CQRS command/query entry and exit. Logging is a cross-cutting concern in the dispatcher — handlers do not log their own entry/exit. Log format includes request type, parameters, result (or exception), and CorrelationId. Sensitive-data redaction policy for `ToString()` overrides on request records with `ApiKey`/`Password`/`Token`/`Secret` fields (Section 2.6).
-- **CorrelationId tracing:** Added required `Guid CorrelationId` property to `IRequest`. Every request record carries it as its first positional parameter. VMs generate `Guid.NewGuid()` at the UI interaction boundary; handlers propagate `request.CorrelationId` to any child dispatches. Dispatcher validates non-empty and includes `[{CorrelationId}]` in all log messages for end-to-end tracing of individual UI interactions (Section 2.8). Six multi-step handlers identified that must propagate CorrelationId.
-- **Updated implementation plan:** `docs/implementation-plan-mvvm-cqrs-refactor.md` — Sections 2.1, 2.2, 2.5, 2.6 updated; new Sections 2.8 (CorrelationId design) and 6.5.31 (redaction tests), 6.5.32 (CorrelationId contract + propagation tests) added; test count tables, file checklists, and phase tasks updated throughout.
-- **Updated TODO checklist:** `docs/implementation-plan-todo.md` — expanded to ~180 checkboxes with per-test-case granularity, CorrelationId items in Phase 0/1/2, and new Phase 3 sub-sections (3e redaction, 3f CorrelationId propagation, 3g mocks, 3h UI tests).
-
-## Current User Directives (Highest Priority)
-1. No code-behind logic in desktop or mobile UI.
-2. Strict MVVM adherence.
-3. Strict CQRS adherence for all UI commands/events.
-4. All CQRS commands and queries must have exhaustive unit tests with known happy paths and known failure paths.
-5. All CQRS commands and queries must write Debug-level log messages upon entering and leaving, including parameters and results.
-6. Every command/request must have a required `CorrelationId` parameter tracked through to subsequent commands/queries for tracing individual UI interactions.
-7. Complete migration across both apps and ensure all unit, integration, and UI tests pass.
-
-## Current State
-- The repo is in a broad in-progress refactor state with many modified files across desktop/mobile/service/docs.
-- UI still contains existing code-behind paths that must be removed per directive.
-- CQRS migration is fully designed at the plan level; implementation has not started.
-- Implementation plan and TODO list are comprehensive and ready for execution; Phase 0 (shared CQRS foundation) is the next starting point.
-
-## Key Architecture Decisions
-- **`IRequest`** requires `Guid CorrelationId { get; }` — not a bare marker interface.
-- **`ServiceProviderRequestDispatcher`** handles all cross-cutting concerns: CorrelationId validation (`Guid.Empty` → `ArgumentException`), Debug entry/exit logging with `[{CorrelationId}]`, and reflection-based handler resolution.
-- **Logging is dispatcher-only** — handlers do not log their own entry/exit.
-- **CorrelationId generation** happens at the VM command boundary (`Guid.NewGuid()`); handlers propagate `request.CorrelationId` to child dispatches.
-- **Sensitive-data redaction** — request records with `ApiKey`/`Password`/`Token`/`Secret` override `ToString()` and must still include `CorrelationId`.
-- **~237 tests** specified: 9 infrastructure, 9 dialog VM, 133 desktop handlers, 65 mobile handlers, 5 redaction, 8 CorrelationId contract/propagation, 8 UI with mock dispatcher.
-- **CommunityToolkit.Mvvm** adoption deferred to a follow-up refactor.
-
-## Next Steps
-1. **Phase 0:** Add CQRS interfaces (`IRequest` with `CorrelationId`, `IRequestHandler`, `IRequestDispatcher`), `Unit`, `CommandResult` in `App.Logic/Cqrs/`; implement `ServiceProviderRequestDispatcher` with logging + CorrelationId validation; wire one Desktop flow (`SetManagementSectionRequest`); add attached behaviors; add dispatcher + handler unit tests (including logging and CorrelationId tests).
-2. **Phase 1:** Remove desktop code-behind; add `ConnectionSettingsDialogViewModel`; `OpenNewSessionRequest`/handler with CorrelationId propagation to `CreateDesktopSessionRequest`; decompose `ServerWorkspaceViewModel` into 8 sub-VMs; implement all 24 desktop CQRS handlers.
-3. **Phase 2:** Mobile platform abstractions (7 interfaces + implementations); replace `MainPage` delegates with request/handlers; `AppShellViewModel` + `McpRegistryPageViewModel`; implement all 17 mobile CQRS handlers.
-4. **Phase 3:** ~237 unit tests per the exhaustive test matrix; CorrelationId propagation tests for 6 multi-step handlers; redaction tests; UI tests with mock dispatcher.
-5. **Phase 4:** Full build/test; logging + redaction audit; code-behind audit; update requirements matrix and mark TR-18.x complete.
-
-*See:* `docs/implementation-plan-mvvm-cqrs-refactor.md`, `docs/implementation-plan-todo.md`.
-
-## Notes
-- Follow AGENTS rule: avoid introducing code-behind logic; enforce MVVM + CQRS only.
-- Preserve existing unrelated worktree changes; do not revert user changes.
-- Every request record: `CorrelationId` is always the first positional parameter.
-- Phase dependency order: 0 → 1 → 3a → 2 → 3b → 4 (sequential, not parallel).
diff --git a/SESSION_HANDOFF_2026-02-19T00-04-44-0600.md b/SESSION_HANDOFF_2026-02-19T00-04-44-0600.md
deleted file mode 100644
index 6f1f2df..0000000
--- a/SESSION_HANDOFF_2026-02-19T00-04-44-0600.md
+++ /dev/null
@@ -1,141 +0,0 @@
-# Session Handoff — 2026-02-19T00:04:44-0600
-
-## Status: MVVM + CQRS Refactor — COMPLETE ✅
-
-All phases of the MVVM + CQRS refactor are finished. All 67 FRs and 112 TRs are marked Done.
-**240 tests passing** (89 `App.Tests` + 151 `Desktop.UiTests`), 0 failures.
-
----
-
-## Completed This Session
-
-### Commits (unpushed, on `develop`)
-
-| SHA | Message |
-|-----|---------|
-| `f7839ab` | test: Phase 3d — mobile handler unit tests (8 handlers, 22 tests) |
-| `95c9313` | docs: mark FR-12.12 Done, update test count to 240, mark all todos complete |
-
-### Phase 3d — Mobile handler unit tests (`tests/RemoteAgent.App.Tests/MobileHandlerTests.cs`)
-- 22 new tests covering all 8 mobile CQRS handlers:
- `ConnectMobileSession`, `DisconnectMobileSession`, `CreateMobileSession`,
- `TerminateMobileSession`, `SendMobileMessage`, `SendMobileAttachment`,
- `ArchiveMobileMessage`, `UsePromptTemplate`
-- `App.Tests` grew from 67 → 89 (+22)
-
-### Bug fixes during test authoring
-| File | Fix |
-|------|-----|
-| `ConnectMobileSessionHandler.cs` | CS8601: `workspace.Host = host ?? ""` (null-coalesce) |
-| `ConnectMobileSessionHandler.cs` | Removed `NotifyConnectionStateChanged()` from catch block — it was overwriting the "Failed:" status via `OnGatewayConnectionStateChanged()` |
-| `McpRegistryPageViewModelTests.cs` | CS0104 ambiguity: added `using LogicRequests = RemoteAgent.App.Logic.Requests;` to distinguish `DeleteMcpServerRequest` from `RemoteAgent.Proto.DeleteMcpServerRequest` |
-| `MobileHandlerTests.cs` | xUnit1031: made `Disconnect` test `async Task`, awaited handler result |
-
-### Docs
-- `requirements-completion-summary.md`: FR-12.12 `Pending` → `Done`
-- `requirements-completion-summary.md`: TR-18.4 test count `218` → `240`
-- `implementation-plan-todo.md`: all 318 remaining `[ ]` items marked `[x]`
-
----
-
-## Architecture Overview
-
-### Project Structure
-```
-src/
- RemoteAgent.Proto/ — shared protobuf contracts + generated gRPC C#
- RemoteAgent.App.Logic/ — shared CQRS foundation, interfaces, VMs, handlers (net10.0)
- RemoteAgent.App/ — MAUI Android client (net10.0-android)
- RemoteAgent.Desktop/ — Avalonia desktop management app (net9.0)
- RemoteAgent.Service/ — ASP.NET Core gRPC service (net10.0)
-tests/
- RemoteAgent.App.Tests/ — 89 tests (net10.0)
- RemoteAgent.Desktop.UiTests/— 151 tests (net9.0)
- RemoteAgent.Service.IntegrationTests/ — isolated integration tests
-```
-
-### CQRS Pattern
-- `IRequest` — all requests carry `Guid CorrelationId` as first property
-- `IRequestHandler` — stateless handlers; no entry/exit logging
-- `IRequestDispatcher` / `ServiceProviderRequestDispatcher` — sole cross-cutting concern; Debug-level entry/exit logging with `[{CorrelationId}]`; throws `ArgumentException` on `Guid.Empty`
-- VMs generate `Guid.NewGuid()` at the UI command boundary
-- All 32 Desktop handlers + 8 mobile (App) handlers + 3 App.Logic handlers
-
-### Sub-VM Decomposition (`ServerWorkspaceViewModel`)
-`ServerWorkspaceViewModel` implements `IServerConnectionContext` and owns 6 sub-VMs:
-- `Security` → `SecurityViewModel` — peers, bans, open sessions
-- `AuthUsers` → `AuthUsersViewModel` — auth users, permission roles
-- `Plugins` → `PluginsViewModel` — plugin assemblies + runner IDs
-- `McpRegistry` → `McpRegistryDesktopViewModel` — MCP servers + agent mappings
-- `PromptTemplates` → `PromptTemplatesViewModel` — prompt templates + seed context
-- `StructuredLogs` → `StructuredLogsViewModel` — log monitoring + filtering
-
-Sub-VMs read connection info (Host/Port/ApiKey/AgentId) from `IServerConnectionContext` at command execution time — not at construction — to avoid stale data.
-
-### Management App Log (FR-12.12)
-- `AppLoggerProvider` + `AppLogEntry` + `IAppLogStore` / `InMemoryAppLogStore` — captures all desktop `ILogger` output
-- `AppLogViewModel` — observable collection with filter
-- `ClearAppLogHandler` / `SaveAppLogHandler` — save to txt/json/csv via `IFileSaveDialogService`
-- Registered via `ILoggerFactory.AddProvider(...)` in `App.axaml.cs`
-
-### Mobile CQRS
-- `MainPageViewModel` — 12-param constructor; `IRequestDispatcher` is 12th param
-- `ISessionCommandBus` — implemented by `MainPageViewModel`; DI forwarding factory
-- All 8 mobile handlers in `src/RemoteAgent.App/Handlers/`
-- 3 App.Logic handlers: `LoadMcpServersHandler`, `SaveMcpServerHandler`, `DeleteMcpServerHandler`
-
----
-
-## Test Infrastructure
-
-### Desktop (`tests/RemoteAgent.Desktop.UiTests/`)
-- `Handlers/SharedHandlerTestStubs.cs` — shared stubs: `StubCapacityClient`, `FakeAgentSession`, `StubLogStore`, `StubSessionFactory`, `StubAppLogStore`, `StubStructuredLogClient`, `NullDispatcher`, `NullFileSaveDialogService`, `SharedWorkspaceFactory`
-- 30 handler test files (one per handler) + `AppLogTests.cs` + `MainWindowUiTests.cs`
-
-### Mobile (`tests/RemoteAgent.App.Tests/`)
-- `MobileHandlerTests.cs` — stubs: `StubGateway`, `StubSessionStore`, `StubApiClient`, `StubConnectionModeSelector`, `StubAgentSelector`, `StubAttachmentPicker`, `StubPromptTemplateSelector`, `NullAppPreferences`, `NullRequestDispatcher`, `CreateWorkspace()` factory
-- `MauiStubs.cs` — additional MAUI-specific stub implementations
-- `McpRegistryPageViewModelTests.cs` — uses `LogicRequests` alias for `DeleteMcpServerRequest` disambiguation
-- `Cqrs/` — dispatcher, CorrelationId, and infrastructure tests
-
----
-
-## Known Constraints
-- `RemoteAgent.App` (MAUI) cannot be built on Linux (no Android SDK); `App.Logic` and `App.Tests` build fine
-- `dotnet test` cannot take multiple `.csproj` arguments; run each project separately
-- `build-desktop-dotnet9.sh` may fail due to .NET version mismatch; use `dotnet build` directly
-- GPG signing unavailable in this environment; use `git commit --no-gpg-sign`
-- Integration tests excluded from normal CI; run via `./scripts/test-integration.sh`
-
----
-
-## Suggested Next Work
-
-The MVVM + CQRS refactor is architecturally complete. Possible next areas:
-
-1. **Push `develop` to remote** — 2 commits (`f7839ab`, `95c9313`) are unpushed
-2. **Phase 3c gap** — the plan specified 133 Desktop handler tests; current count is 151 but some test scenarios (CorrelationId propagation, `ToString()` redaction) may be sparse
-3. **Service integration tests** — `tests/RemoteAgent.Service.IntegrationTests/` may have coverage gaps
-4. **Documentation site** — `docs/` uses DocFX; could regenerate `_site/` with updated content
-5. **CI pipeline** — review `.github/workflows/build-deploy.yml` for any improvements
-6. **Feature work** — review `docs/functional-requirements.md` for any unimplemented FRs beyond the refactor scope
-
----
-
-## Build Commands
-```bash
-# App.Logic + tests (net10.0, works on Linux)
-dotnet build src/RemoteAgent.App.Logic/RemoteAgent.App.Logic.csproj -c Release
-dotnet test tests/RemoteAgent.App.Tests/ -c Release
-
-# Desktop (net9.0)
-dotnet build src/RemoteAgent.Desktop/RemoteAgent.Desktop.csproj -c Release
-dotnet test tests/RemoteAgent.Desktop.UiTests/ -c Release
-
-# Service (net10.0)
-dotnet build src/RemoteAgent.Service/RemoteAgent.Service.csproj -c Release
-dotnet run --project src/RemoteAgent.Service
-
-# Integration tests (isolated)
-./scripts/test-integration.sh Release
-```
diff --git a/SESSION_HANDOFF_2026-02-20T05-22-27-0000.md b/SESSION_HANDOFF_2026-02-20T05-22-27-0000.md
new file mode 100644
index 0000000..2c349f3
--- /dev/null
+++ b/SESSION_HANDOFF_2026-02-20T05-22-27-0000.md
@@ -0,0 +1,279 @@
+# Session Handoff — 2026-02-20T05:22 UTC
+
+## Project Overview
+
+**Remote Agent** — A system consisting of:
+- **.NET MAUI Android app** (`net10.0-android`) — client that connects to a gRPC service, manages chat sessions with AI agents
+- **ASP.NET Core gRPC service** (`net10.0`) — runs agent sessions, handles pairing/auth
+- **Avalonia desktop management app** (`net9.0`) — admin tool for configuring the service (pairing users, API keys, server profiles, session management)
+
+---
+
+## Repository Layout
+
+```
+src/
+ RemoteAgent.App/ — .NET MAUI Android client
+ RemoteAgent.App.Logic/ — Shared logic, CQRS interfaces, platform abstractions (net9.0 + net10.0)
+ RemoteAgent.Desktop/ — Avalonia desktop management UI (net9.0)
+ RemoteAgent.Proto/ — Protobuf contracts + generated gRPC C# (net9.0 + net10.0)
+ RemoteAgent.Service/ — ASP.NET Core gRPC + web service (net10.0)
+tests/
+ RemoteAgent.App.Tests/ — Mobile app unit tests (net10.0, 117 tests)
+ RemoteAgent.Service.Tests/ — Service unit tests (net10.0, 27 tests)
+ RemoteAgent.Desktop.UiTests/— Avalonia desktop handler tests (net9.0, 173 tests)
+ RemoteAgent.Service.IntegrationTests/ — Isolated integration tests (run manually)
+scripts/
+ build-dotnet10.sh — Builds + tests MAUI app + service stack
+ build-desktop-dotnet9.sh — Builds + tests Avalonia desktop stack
+ test-integration.sh — Runs service integration tests (not part of CI)
+ generate-proto.sh — Regenerates gRPC C# from .proto (WSL/Linux only)
+```
+
+**Multi-targeting:** `RemoteAgent.App.Logic` and `RemoteAgent.Proto` target `net9.0;net10.0`. Desktop uses net9.0 TFM, service/mobile use net10.0.
+
+---
+
+## Build Commands
+
+```bash
+# MAUI + Service stack (.NET 10) — includes App.Tests + Service.Tests
+bash ./scripts/build-dotnet10.sh Release
+
+# Avalonia desktop stack (.NET 9) — includes Desktop.UiTests
+bash ./scripts/build-desktop-dotnet9.sh Release
+
+# Build Android APK (Debug only — Release hits XAGNM7009 ARM64 issue):
+dotnet publish src/RemoteAgent.App/RemoteAgent.App.csproj \
+ -c Debug -f net10.0-android \
+ -p:AndroidSdkDirectory=/home/sharpninja/Android/Sdk \
+ -p:EmbedAssembliesIntoApk=true
+
+# Install + launch on emulator:
+WIN_ADB="/mnt/c/Users/kingd/platform-tools-windows/platform-tools/adb.exe"
+APK=$(find src/RemoteAgent.App/bin/Debug/net10.0-android -name '*-Signed.apk' | head -1)
+$WIN_ADB -s emulator-5556 shell am force-stop com.companyname.remoteagent.app
+$WIN_ADB -s emulator-5556 install -r "$(wslpath -w "$APK")"
+$WIN_ADB -s emulator-5556 shell am start -n com.companyname.remoteagent.app/crc6411305d2bb8acc544.MainActivity
+
+# Screenshot:
+$WIN_ADB -s emulator-5556 exec-out screencap -p > /tmp/screenshot.png
+
+# Desktop build (use dotnet build directly — build script hits NETSDK1045):
+dotnet build src/RemoteAgent.Desktop/RemoteAgent.Desktop.csproj -c Release
+dotnet test tests/RemoteAgent.Desktop.UiTests/RemoteAgent.Desktop.UiTests.csproj -c Release
+```
+
+---
+
+## Current Test Counts (all passing as of this handoff)
+
+- `RemoteAgent.App.Tests` — **117 tests** (includes 7 new server-profile handler tests)
+- `RemoteAgent.Service.Tests` — **27 tests**
+- `RemoteAgent.Desktop.UiTests` — **173 tests**
+- **Total: 317 tests, all green**
+
+---
+
+## Architecture: CQRS Pattern (Mobile)
+
+All mobile UI actions dispatch through `IRequestDispatcher`:
+
+```
+ViewModel command → new XxxRequest(Guid, workspace) → IRequestDispatcher.SendAsync()
+ → DI resolves IRequestHandler → handler.HandleAsync()
+```
+
+**Request files:** `src/RemoteAgent.App/Requests/`
+**Handler files:** `src/RemoteAgent.App/Handlers/`
+**Registration:** `src/RemoteAgent.App/MauiProgram.cs` (transient handlers)
+**Tests:** `tests/RemoteAgent.App.Tests/` (linked source files from App project via csproj ``)
+
+The test project can't reference `RemoteAgent.App` directly (MAUI workload). Instead, it links individual `.cs` files and uses `MauiStubs.cs` for minimal `Microsoft.Maui.Controls.Command` stub.
+
+### Adding a new CQRS command:
+1. Create `XxxRequest.cs` in `Requests/` (implements `IRequest`)
+2. Create `XxxHandler.cs` in `Handlers/` (implements `IRequestHandler`)
+3. Register in `MauiProgram.cs`: `builder.Services.AddTransient, XxxHandler>()`
+4. Add `` links in `RemoteAgent.App.Tests.csproj` for both files
+5. Create `XxxHandlerTests.cs` in tests
+6. Wire ViewModel: `new Command(async () => await RunAsync(new XxxRequest(Guid.NewGuid(), this)))`
+
+---
+
+## Architecture: Pairing Flow
+
+1. Admin sets pairing credentials via Desktop → `SetPairingUsers` gRPC RPC → service saves hashed creds + generates API key → writes to `appsettings.json`
+2. Mobile user enters host + port → taps **Login** → WebView opens `http://{host}:1{port}/pair`
+3. User logs in → service redirects to `/pair/key` → shows API key + QR + deep link button
+4. `PairLoginPage` extracts deep link via JS → parses `remoteagent://pair?key=...&host=...&port=...`
+5. API key stored → **Connect** button enabled → gRPC connection established
+
+**Port convention:**
+- gRPC: 5243 (Linux/Docker, default), 5244 (Windows)
+- Web/browser: 15243 / 15244 (formula: `"1" + gRPCport`)
+- Deep link contains gRPC port, not web port
+
+---
+
+## Feature: Server Profiles
+
+**Fully implemented** on both mobile and desktop.
+
+### Mobile (`src/RemoteAgent.App/`)
+- `ServerProfile` model + `IServerProfileStore` interface in `App.Logic/ServerProfile.cs`
+- `LocalServerProfileStore` (LiteDB) in `Services/LocalServerProfileStore.cs`
+- `SettingsPage.xaml` — lists saved servers, edit display name / per-request context / default session context
+- **CQRS handlers:** `SaveServerProfileHandler`, `DeleteServerProfileHandler`, `ClearServerApiKeyHandler`
+- `SettingsPageViewModel` dispatches all commands via `IRequestDispatcher`
+- `ConnectMobileSessionHandler` auto-saves profile on successful connect
+- API key status shown with "Clear API Key" button
+
+### Desktop (`src/RemoteAgent.Desktop/`)
+- `ServerRegistration` model extended with `PerRequestContext` + `DefaultSessionContext`
+- `ServerRegistrationStore.Upsert` persists new fields
+- `ServerSetupPanel.axaml` has text areas for per-request and default session context
+- `SaveServerRegistrationRequest/Handler` passes new fields through
+
+---
+
+## Recent Changes (this session)
+
+### Commits (oldest → newest):
+```
+2d2b678 Add server profiles: save connection details per host:port
+41796c9 Make Enter key send message on Android
+6384ba0 Add FR-19/20, TR-21/22: server profiles and mobile chat UX requirements
+5ceb870 Fix Android Enter-to-send, icon-only Send button, compact button padding
+bfdc4da CQRS server profile handlers + clear API key feature
+032e786 Replace .NET splash screen logo with app icon
+588fcd7 Fix Android soft keyboard Enter: show Send action instead of newline
+```
+
+### Key changes:
+1. **Server profiles** — Full CQRS implementation with 3 request/handler pairs, tests, settings UI
+2. **Android Enter-to-send** — `SetRawInputType(InputTypes.ClassText)` on native `AppCompatEditText` forces IME to show Send button instead of newline for multi-line Editor
+3. **Compact mobile chat UI** — Removed icon label from input area, shrunk session tab close buttons (24×24), compact session card buttons, toolbar reduced to Connect/Disconnect only
+4. **Splash screen** — Replaced .NET logo with app's own icon (`appiconfg.svg`)
+5. **Default port** — Changed from 5244 to 5243 (Linux/Docker is more common deployment)
+
+---
+
+## Key Files Reference
+
+### Mobile App
+| File | Purpose |
+|------|---------|
+| `MainPage.xaml` | Chat screen — connection view, session tabs, message list, input area |
+| `MainPage.xaml.cs` | Platform-specific keyboard: Android `EditorAction`+`SetRawInputType`, Windows Ctrl+Enter |
+| `SettingsPage.xaml` | Server profiles list + edit form + Clear API Key |
+| `ViewModels/MainPageViewModel.cs` | Main VM, default port 5243, CQRS dispatch via `RunAsync()` |
+| `ViewModels/SettingsPageViewModel.cs` | Settings VM, CQRS dispatch for Save/Delete/ClearApiKey |
+| `MauiProgram.cs` | DI registration hub — all handlers, services, ViewModels |
+| `Handlers/ClearServerApiKeyHandler.cs` | Clears API key from stored profile |
+| `Resources/Splash/splash.svg` | Splash screen (now app icon, not .NET logo) |
+| `Resources/Styles/Styles.xaml` | Global Button style: padding `12,6`, min height `36` |
+
+### Service
+| File | Purpose |
+|------|---------|
+| `Program.cs` | Kestrel setup, dual-port (gRPC + web), `/pair` routes |
+| `Services/AgentGatewayService.cs` | gRPC service: Connect, SendMessage, SetPairingUsers |
+| `Web/PairingHtml.cs` | Login form + key page HTML generation |
+
+### Desktop
+| File | Purpose |
+|------|---------|
+| `Infrastructure/ServerRegistration.cs` | Server model with PerRequestContext + DefaultSessionContext |
+| `Views/Panels/ServerSetupPanel.axaml` | Per-request context + default session context text fields |
+
+### Shared
+| File | Purpose |
+|------|---------|
+| `App.Logic/ServerProfile.cs` | ServerProfile model + IServerProfileStore interface |
+| `App.Logic/Cqrs/` | IRequest, IRequestHandler, IRequestDispatcher, CommandResult |
+
+---
+
+## Outstanding Work
+
+### Active bug:
+- **`debug-server-agents`** — Agents aren't running on the server. Need to investigate service logs, gRPC session lifecycle, agent spawning logic. This is the highest priority item.
+
+### Stale SQL todos (already implemented, can be marked done):
+The following SQL todo items are from the `SetPairingUsers` feature that was completed in prior sessions. They can be cleaned up:
+- `proto-update`, `proto-gen`, `service-impl`, `client-impl`, `request-handler`
+- `app-registration`, `dialog-infra`, `mainwindow-xaml`, `viewmodel-updates`
+- `test-file`, `test-stubs`, `build-verify`
+
+### Remaining from server profiles plan:
+- `desktop-profile-align` (in_progress) — Desktop PerRequestContext/DefaultSessionContext fields were added. May need further testing.
+- `profile-tests` (pending) — Additional unit tests for profile store CRUD and auto-save. Basic handler tests exist (7 in `ServerProfileHandlerTests.cs`), but more coverage could be added.
+
+---
+
+## Known Issues / Gotchas
+
+### Android APK build
+- **Release config fails** with `XAGNM7009` (native code gen ARM64 issue in .NET 10 Android SDK 36.1.30). Use **Debug** config with `-p:EmbedAssembliesIntoApk=true`.
+- Requires Linux Android SDK: `-p:AndroidSdkDirectory=/home/sharpninja/Android/Sdk`
+
+### Android soft keyboard Enter key
+- MAUI `Editor` = multi-line `AppCompatEditText`. Android ignores `ImeOptions` for multi-line inputs.
+- Fix: `SetRawInputType(InputTypes.ClassText)` tells IME to show Send button while view still wraps text.
+- `EditorAction` event handles `ImeAction.Send`, `Done`, and `Unspecified` (hardware Enter).
+
+### Desktop build on Linux (NETSDK1045)
+- `build-desktop-dotnet9.sh` shows NETSDK1045 because .NET 9 SDK doesn't know `net10.0`.
+- Workaround: Build desktop directly with `dotnet build src/RemoteAgent.Desktop/RemoteAgent.Desktop.csproj -c Release` (the .NET 10 SDK can build net9.0 targets).
+
+### Test project linking
+- `RemoteAgent.App.Tests` can't reference the MAUI app project. Source files are linked via `` in the `.csproj`.
+- `MauiStubs.cs` provides minimal `Microsoft.Maui.Controls.Command` stub.
+- **When adding new request/handler files, you must add corresponding `` links.**
+
+### `IOptions` vs `IOptionsMonitor`
+- All `/pair` routes use `IOptionsMonitor.CurrentValue` so settings reload after `SetPairingUsers` writes `appsettings.json`.
+
+### ADB on WSL2
+- Windows ADB: `/mnt/c/Users/kingd/platform-tools-windows/platform-tools/adb.exe`
+- APK paths via `wslpath -w "$APK"` for Windows-compatible paths
+- Emulator: `emulator-5556`
+- Activity: `crc6411305d2bb8acc544.MainActivity`
+- Package: `com.companyname.remoteagent.app`
+- Always `force-stop` before relaunching to avoid destroyed-activity crashes
+
+### Warnings as errors
+- `TreatWarningsAsErrors=true` in `Directory.Build.props`
+- Fix nullability issues, don't suppress warnings without approval
+
+### Requirements matrix script
+- `scripts/generate-requirements-matrix.sh` has pre-existing awk syntax errors
+- Matrix entries in `docs/requirements-test-coverage.md` must be added manually
+
+---
+
+## Recent Commit History
+
+```
+588fcd7 Fix Android soft keyboard Enter: show Send action instead of newline
+032e786 Replace .NET splash screen logo with app icon
+bfdc4da CQRS server profile handlers + clear API key feature
+5ceb870 Fix Android Enter-to-send, icon-only Send button, compact button padding
+6384ba0 Add FR-19/20, TR-21/22: server profiles and mobile chat UX requirements
+41796c9 Make Enter key send message on Android
+2d2b678 Add server profiles: save connection details per host:port
+22da942 Hide connection card in chat view when already connected
+b3d01ac Hide navigation until connection and session are established
+a6e698c Migrate GetSessionCapacityAsync to gRPC, remove dead HTTP helpers
+9ef7799 Hardcode server connection mode on Android, remove mode selector dialog
+5b4f109 docs: add FR-17/18 and TR-19/20 for device pairing, API key management, and desktop UX
+a7add4c docs: update session handoff 2026-02-20
+1dfe673 Replace QR camera scanner with server login webview for pairing
+0a3e7e7 Fix: generate QR code server-side (embedded PNG) instead of CDN JS
+324f229 Style: global 4px margin on all interactive/display controls in Avalonia app
+0b491de UX: SelectableTextBlock everywhere in Avalonia; simplify mobile login UI; persist ApiKey on connect
+25a470e Fix: use Shell.Current.Navigation + MainThread for QR scanner modal push
+ddaa2f1 Fix: forward ApiKey to server info and capacity checks in ConnectMobileSessionHandler
+2c30fd5 Feat: generate and return API key when saving pairing user
+```
diff --git a/docs/REPOSITORY_RULES.md b/docs/REPOSITORY_RULES.md
index 19e86bd..5312184 100644
--- a/docs/REPOSITORY_RULES.md
+++ b/docs/REPOSITORY_RULES.md
@@ -1,5 +1,19 @@
# Repository rules
+## UI Buttons and CQRS
+
+**Every button added to the Desktop UI must be backed by a CQRS request/handler pair with complete tests.**
+
+- **Request:** Create a `*Request` record in `src/RemoteAgent.Desktop/Requests/` implementing `IRequest`.
+- **Handler:** Create a matching `*Handler` class in `src/RemoteAgent.Desktop/Handlers/` implementing `IRequestHandler`.
+- **Register:** Add the handler to `ConfigureServices` in `App.axaml.cs` as a transient `IRequestHandler`.
+- **ViewModel:** The command bound to the button must dispatch via `IRequestDispatcher`. No logic that belongs in the handler may live directly in the ViewModel.
+- **Handler tests:** Add `*HandlerTests.cs` in `tests/RemoteAgent.Desktop.UiTests/Handlers/` covering at least success, failure/empty, and any meaningful edge cases. Use `[Fact]` for pure logic tests and `[AvaloniaFact]` only when Avalonia UI context is required.
+- **UI tests:** If the button interaction involves ViewModel state changes observable from the UI, add corresponding tests in `tests/RemoteAgent.Desktop.UiTests/`.
+- **Stubs:** Any new infrastructure interface introduced for the handler must have a `Null*` stub added to `SharedHandlerTestStubs.cs`.
+
+This rule exists so that all button behaviour is independently testable, auditable through the request pipeline, and consistent with the established CQRS architecture.
+
## Warnings as errors
- **All warnings are treated as errors.** The build is configured with `TreatWarningsAsErrors=true` (see `Directory.Build.props`). The CI build fails on any warning.
diff --git a/docs/faq.md b/docs/faq.md
new file mode 100644
index 0000000..b809d63
--- /dev/null
+++ b/docs/faq.md
@@ -0,0 +1,84 @@
+# Frequently Asked Questions
+
+## Service Management
+
+### How do I start the service when systemd is not available?
+
+This applies to environments such as WSL (Windows Subsystem for Linux) where
+systemd is not the active init system (i.e. `/run/systemd/system` does not
+exist).
+
+#### Automatic start on install
+
+When you install the `remote-agent-service` .deb package on a non-systemd
+system, the `postinst` script automatically starts the service using
+`start-stop-daemon` and writes a PID file to `/run/remote-agent.pid`.
+
+On WSL it also registers an auto-start entry in `/etc/wsl.conf`:
+
+```ini
+[boot]
+command = "su -s /bin/sh remote-agent /usr/lib/remote-agent/service/wsl-start.sh"
+```
+
+After this is written, the service starts automatically every time the WSL
+instance launches.
+
+> **Tip:** For full `systemctl` support in WSL, add `systemd = true` under
+> `[boot]` in `/etc/wsl.conf`, then run `wsl --shutdown` to restart the
+> distro.
+
+#### Starting manually
+
+Use `remote-agent-ctl`, installed with the package:
+
+```bash
+sudo remote-agent-ctl start
+```
+
+The script works with or without systemd — it detects the active init system
+automatically and delegates to `systemctl` when available, or falls back to
+`start-stop-daemon` directly.
+
+You can also stop, restart, and check status with the same script:
+
+```bash
+sudo remote-agent-ctl stop
+sudo remote-agent-ctl restart
+sudo remote-agent-ctl status
+```
+
+Environment overrides in `/etc/remote-agent/environment` (e.g.
+`ASPNETCORE_URLS`) are sourced automatically by the script when running without
+systemd.
+
+#### Checking whether the service is running
+
+```bash
+_pid=$(cat /run/remote-agent.pid 2>/dev/null)
+kill -0 "$_pid" 2>/dev/null && echo "running (pid $_pid)" || echo "not running"
+```
+
+#### Viewing logs
+
+```bash
+tail -f /var/log/remote-agent/service.log
+```
+
+Errors are written to the same file. A separate `.err` file is used only when
+the service is started via the `daemonize` fallback (present on some
+distributions such as Pengwin):
+
+```bash
+tail -f /var/log/remote-agent/service.err
+```
+
+#### Configuration
+
+Runtime configuration is loaded from `/etc/remote-agent/appsettings.json`.
+Environment overrides (e.g. `ASPNETCORE_ENVIRONMENT`, `ASPNETCORE_URLS`) are
+read from `/etc/remote-agent/environment`. Both files are preserved during
+package upgrades.
+
+The service runs as the `remote-agent` system user with data stored in
+`/var/lib/remote-agent/` and logs in `/var/log/remote-agent/`.
diff --git a/docs/functional-requirements.md b/docs/functional-requirements.md
index d15d958..8feeb6e 100644
--- a/docs/functional-requirements.md
+++ b/docs/functional-requirements.md
@@ -24,8 +24,8 @@
- **FR-2.2** Agent output (stdout/stderr) shall be **streamed to the app in real time** and displayed in the chat.
- **FR-2.3** Agent output in the chat shall be **formatted with a markdown parser** (e.g. bold, code, lists) so that Cursor output is readable and structured.
- **FR-2.4** The user shall be able to **connect** to the service (host/port) and **disconnect**; connecting shall start a session with the agent; disconnecting shall stop the session and the agent.
-- **FR-2.5** Chat text entry shall support **multi-line input**; pressing **Enter/Return** shall insert a newline in the request text.
-- **FR-2.6** On desktop clients, pressing **Ctrl+Enter** in the chat editor shall submit the request.
+- **FR-2.5** Chat text entry shall support **multi-line input** on desktop; on mobile, Enter sends the message.
+- **FR-2.6** On desktop clients, pressing **Ctrl+Enter** in the chat editor shall submit the request; on mobile clients, pressing **Enter** shall submit the request.
- **FR-2.7** On mobile clients, session establishment shall begin in a **dedicated connection view** and transition to the **chat view** after successful connection.
*See:* [TR-3](technical-requirements.md#3-service-architecture), [TR-4](technical-requirements.md#4-protocol-grpc), [TR-5](technical-requirements.md#5-app-architecture).
@@ -177,3 +177,52 @@
## 16. Test execution policy
- **FR-16.1** Integration tests shall be executable on demand through an explicit isolated workflow or script and shall not run as part of default build-and-release pipeline executions.
+
+---
+
+## 17. Device pairing and API key management
+
+- **FR-17.1** The system shall support a **device pairing flow** that securely provisions a mobile device with connection details and an API key for the server.
+- **FR-17.2** An administrator shall be able to **set pairing credentials** (username and password) from the desktop management app; the service shall hash and store them in configuration.
+- **FR-17.3** When pairing credentials are saved, the service shall **generate a cryptographically random API key**, store it in server configuration, and **return it** to the desktop app.
+- **FR-17.4** The service shall expose a **web-based login page** at `/pair` on the HTTP/1 web port (computed as `"1" + gRPC port`) so that a mobile device can authenticate with the pairing credentials.
+- **FR-17.5** After successful login, the service shall display the API key, a **server-generated QR code** (no external CDN dependency), and a **deep-link button** (`remoteagent://pair?key=…&host=…&port=…`) that carries connection details.
+- **FR-17.6** The mobile app shall provide a **Login button** that opens the server's `/pair` page in an in-app WebView; after successful authentication the app shall **automatically extract the deep-link URI** from the rendered page and populate host, port, and API key.
+ - **FR-17.6.1** The Login button shall be **disabled** until a server host/IP is entered.
+ - **FR-17.6.2** The Login button shall be **disabled** when an API key is already stored (pairing already completed).
+- **FR-17.7** The mobile app's Connect button shall be **disabled** until a valid API key is stored (i.e., pairing must precede connection).
+- **FR-17.8** The API key shall be **persisted** on the mobile device alongside the server host and port after a successful connection.
+
+*See:* [TR-19](technical-requirements.md#19-device-pairing-and-api-key-management).
+
+---
+
+## 18. Desktop UX refinements
+
+- **FR-18.1** All text display controls in Avalonia desktop views shall use **SelectableTextBlock** (instead of `TextBlock`) so users can select and copy displayed text.
+- **FR-18.2** All interactive and display controls in the Avalonia desktop app shall have a uniform **4px margin** applied via a global style.
+
+*See:* [TR-20](technical-requirements.md#20-desktop-ux-refinements).
+
+---
+
+## 19. Server profiles and persistent connection settings
+
+- **FR-19.1** Both mobile and desktop apps shall **persist server connection details** (host, port, API key, display name) after a successful connection, keyed by **host:port**.
+- **FR-19.2** The Settings page (mobile) and Server Setup panel (desktop) shall display a **list of saved server profiles** with the ability to **add, edit, and remove** entries.
+- **FR-19.3** Each saved server profile shall store a configurable **per-request context** (text prepended to every chat message) and a **default session context** (context seeded into new sessions).
+- **FR-19.4** Connecting to a previously saved server shall **auto-load** the stored per-request context into the workspace (without overriding user-entered values).
+- **FR-19.5** Saving or updating a server profile shall not affect **existing active sessions**; changes apply only to subsequent connections.
+
+*See:* [TR-21](technical-requirements.md#21-server-profiles-and-persistent-connection-settings).
+
+---
+
+## 20. Mobile chat UX
+
+- **FR-20.1** On mobile clients, pressing **Enter** in the chat input shall **send the message** (submit the request).
+- **FR-20.2** On mobile clients, the **connection mode shall be fixed to "server"**; no mode-selection dialog shall be shown.
+- **FR-20.3** Navigation items (Settings, Account, MCP Registry) shall be **hidden until a connection is established**; the connection card shall be **hidden once connected**.
+- **FR-20.4** All client-to-server API calls shall use **gRPC exclusively**; no REST/HTTP fallback endpoints shall be used by the mobile client.
+
+*See:* [TR-22](technical-requirements.md#22-mobile-chat-ux).
diff --git a/docs/requirements-completion-summary.md b/docs/requirements-completion-summary.md
index 0932082..76944ad 100644
--- a/docs/requirements-completion-summary.md
+++ b/docs/requirements-completion-summary.md
@@ -80,6 +80,18 @@ Requirement IDs in the tables link to the corresponding section in [Functional r
| [**FR-15.1**](functional-requirements.md#15-connection-protection) | Configurable connection/message rate limiting | **Done** | `ConnectionProtectionService` enforces sliding-window limits |
| [**FR-15.2**](functional-requirements.md#15-connection-protection) | Detect DoS patterns and temporarily throttle/block peers | **Done** | DoS detection cooldown + blocked-peer behavior with structured events |
| [**FR-16.1**](functional-requirements.md#16-test-execution-policy) | Integration tests run on-demand and remain isolated from default pipeline | **Done** | `scripts/test-integration.sh` + isolated workflow (`integration-tests.yml`) |
+| [**FR-17.1**](functional-requirements.md#17-device-pairing-and-api-key-management) | Device pairing flow provisions mobile with connection details and API key | **Done** | Desktop SetPairingUser → service generates key → `/pair` web login → mobile extracts deep link |
+| [**FR-17.2**](functional-requirements.md#17-device-pairing-and-api-key-management) | Admin sets pairing credentials from desktop app | **Done** | Desktop toolbar button → `SetPairingUserHandler` → `SetPairingUsers` gRPC RPC |
+| [**FR-17.3**](functional-requirements.md#17-device-pairing-and-api-key-management) | Service generates random API key on pairing credential save | **Done** | `AgentGatewayService.SetPairingUsers` generates 32-byte hex key via `RandomNumberGenerator` |
+| [**FR-17.4**](functional-requirements.md#17-device-pairing-and-api-key-management) | Web-based login page at `/pair` on web port | **Done** | `PairingHtml.LoginPage` served on `"1" + gRPCport` (e.g. 15244) |
+| [**FR-17.5**](functional-requirements.md#17-device-pairing-and-api-key-management) | After login: API key, server-side QR code, deep-link button displayed | **Done** | `PairingHtml.KeyPage` renders key + `QRCoder` PNG + `remoteagent://pair?…` anchor |
+| [**FR-17.6**](functional-requirements.md#17-device-pairing-and-api-key-management) | Mobile Login button opens `/pair` WebView and auto-extracts deep link | **Done** | `PairLoginPage` WebView → JS `querySelector('a.btn').getAttribute('href')` → `ScanQrCodeHandler.ParseAndApply` |
+| [**FR-17.6.1**](functional-requirements.md#17-device-pairing-and-api-key-management) | Login disabled until host entered | **Done** | `ScanQrCodeCommand` CanExecute includes `!string.IsNullOrWhiteSpace(_host)` |
+| [**FR-17.6.2**](functional-requirements.md#17-device-pairing-and-api-key-management) | Login disabled when API key already stored | **Done** | `ScanQrCodeCommand` CanExecute includes `!HasApiKey` |
+| [**FR-17.7**](functional-requirements.md#17-device-pairing-and-api-key-management) | Connect disabled until API key stored | **Done** | `ConnectCommand` CanExecute: `!_gateway.IsConnected && HasApiKey` |
+| [**FR-17.8**](functional-requirements.md#17-device-pairing-and-api-key-management) | API key persisted on mobile after successful connection | **Done** | `ConnectMobileSessionHandler` saves `PrefApiKey` to preferences on connect |
+| [**FR-18.1**](functional-requirements.md#18-desktop-ux-refinements) | All Avalonia text displays use SelectableTextBlock | **Done** | All 16 `.axaml` view files: `TextBlock` → `SelectableTextBlock` |
+| [**FR-18.2**](functional-requirements.md#18-desktop-ux-refinements) | Uniform 4px margin on all Avalonia controls | **Done** | Global style in `App.axaml` targeting Button, TextBox, ComboBox, CheckBox, ListBox, SelectableTextBlock, TabControl |
---
@@ -199,6 +211,17 @@ Requirement IDs in the tables link to the corresponding section in [Functional r
| [**TR-18.2**](technical-requirements.md#18-ui-commandevent-cqrs-testability) | Command/query/event handlers are unit-testable independent of UI frameworks | **Done** | 218 handler unit tests across Desktop and Mobile; handlers tested with stub dependencies, no UI framework required |
| [**TR-18.3**](technical-requirements.md#18-ui-commandevent-cqrs-testability) | UI pipelines support mockable behavior injection for known outcomes/failures | **Done** | `IRequestDispatcher` is the sole pipeline entry point; `ServiceProviderRequestDispatcher` provides Debug-level entry/exit logging with CorrelationId tracing; all dependencies are interface-backed |
| [**TR-18.4**](technical-requirements.md#18-ui-commandevent-cqrs-testability) | UI tests substitute mocked handlers and validate success/failure UI behavior | **Done** | Handler tests use `StubCapacityClient`, `NullDispatcher`, `TestRequestDispatcher`, and other injectable stubs to validate both success and failure paths; 240 tests covering all handler paths |
+| [**TR-19.1**](technical-requirements.md#19-device-pairing-and-api-key-management) | `SetPairingUsers` RPC hashes passwords, generates API key, returns it | **Done** | `AgentGatewayService.SetPairingUsers` → SHA-256 hash + `RandomNumberGenerator.GetBytes(32)` hex key → `appsettings.json` + `GeneratedApiKey` response field |
+| [**TR-19.2**](technical-requirements.md#19-device-pairing-and-api-key-management) | `/pair` endpoints use `IOptionsMonitor` for live config reload | **Done** | All three `/pair` lambdas use `IOptionsMonitor.CurrentValue` |
+| [**TR-19.3**](technical-requirements.md#19-device-pairing-and-api-key-management) | Secondary HTTP/1+2 web port (`"1" + gRPC port`) for browser endpoints | **Done** | Kestrel dual-port: gRPC HTTP/2-only + web HTTP/1+2; `/pair` routes use `RequireHost` |
+| [**TR-19.4**](technical-requirements.md#19-device-pairing-and-api-key-management) | Server-side QR code via `QRCoder.PngByteQRCode`, no CDN | **Done** | `PairingHtml.GenerateQrPngImg` → base64 `
` data URL |
+| [**TR-19.5**](technical-requirements.md#19-device-pairing-and-api-key-management) | Deep-link URI: `remoteagent://pair?key=…&host=…&port={gRPCport}` | **Done** | `/pair/key` builds deep link with `context.Request.Host` and gRPC port |
+| [**TR-19.6**](technical-requirements.md#19-device-pairing-and-api-key-management) | `PairLoginPage` WebView extracts deep link via JS on `/pair/key` navigation | **Done** | `PairLoginPage.OnNavigated` → `EvaluateJavaScriptAsync` → `querySelector('a.btn').getAttribute('href')` → strip JSON quotes → TCS |
+| [**TR-19.7**](technical-requirements.md#19-device-pairing-and-api-key-management) | `ScanQrCodeHandler` validates host, builds web port, parses deep link | **Done** | Validates host not empty; `loginUrl = http://{host}:1{port}/pair`; `ParseAndApply` extracts key/host/port from URI |
+| [**TR-19.8**](technical-requirements.md#19-device-pairing-and-api-key-management) | Desktop `SetPairingUsersAsync` returns `Task` (generated key) | **Done** | `IServerCapacityClient.SetPairingUsersAsync` → `Task`; handler applies key to `Workspace.ApiKey` |
+| [**TR-19.9**](technical-requirements.md#19-device-pairing-and-api-key-management) | `IQrCodeScanner.ScanAsync` accepts `loginUrl` parameter | **Done** | Interface signature: `Task ScanAsync(string loginUrl)` |
+| [**TR-20.1**](technical-requirements.md#20-desktop-ux-refinements) | All Avalonia `TextBlock` replaced with `SelectableTextBlock` | **Done** | All 16 desktop `.axaml` view files updated |
+| [**TR-20.2**](technical-requirements.md#20-desktop-ux-refinements) | Global 4px margin style on interactive Avalonia controls | **Done** | `App.axaml` style targets Button, TextBox, ComboBox, CheckBox, ListBox, SelectableTextBlock, TabControl |
---
@@ -206,8 +229,8 @@ Requirement IDs in the tables link to the corresponding section in [Functional r
| Category | Done | Partial | Not started |
|----------|------|--------|-------------|
-| **Functional (FR)** | 67 | 0 | 0 |
-| **Technical (TR)** | 112 | 0 | 0 |
+| **Functional (FR)** | 80 | 0 | 0 |
+| **Technical (TR)** | 124 | 0 | 0 |
**Not started (FR):** None.
@@ -215,4 +238,4 @@ Requirement IDs in the tables link to the corresponding section in [Functional r
---
-*Generated from `docs/functional-requirements.md`, `docs/technical-requirements.md`, and the current codebase. Last refreshed: TR-18.1–TR-18.4 marked Done following completion of the MVVM + CQRS refactor (32 Desktop handlers, 17 Mobile handlers, 218 unit tests, ServerWorkspaceViewModel decomposed into 6 sub-VMs, IRequestDispatcher with Debug-level CorrelationId tracing).*
+*Generated from `docs/functional-requirements.md`, `docs/technical-requirements.md`, and the current codebase. Last refreshed: FR-17 (device pairing + API key management), FR-18 (desktop UX refinements), TR-19 (pairing infrastructure), TR-20 (desktop UX) marked Done following web-based login flow, server-side QR code, SelectableTextBlock, and global margin implementation.*
diff --git a/docs/requirements-test-coverage.md b/docs/requirements-test-coverage.md
index addfe70..4e4009e 100644
--- a/docs/requirements-test-coverage.md
+++ b/docs/requirements-test-coverage.md
@@ -2,6 +2,8 @@
This document maps every requirement ID in `docs/functional-requirements.md` (FR) and `docs/technical-requirements.md` (TR) to current automated test coverage.
+**Note:** This file is auto-generated by `scripts/generate-requirements-matrix.sh`. Do not edit manually.
+
Coverage status values:
- `Covered`: direct automated test coverage exists.
- `Partial`: some behavior is covered, but not the full requirement scope.
@@ -11,74 +13,100 @@ Coverage status values:
| Requirement | Coverage | Tests |
|---|---|---|
-| FR-1.1 | Partial | `HostBootstrapSmokeTests.RootEndpoint_RespondsQuickly` (`tests/RemoteAgent.Service.IntegrationTests/HostBootstrapSmokeTests.cs`), `MobileConnectionUiTests.*` (`tests/RemoteAgent.Mobile.UiTests/MobileConnectionUiTests.cs`) |
-| FR-1.2 | Covered | `AgentGatewayServiceIntegrationTests_Echo.Connect_StartThenSendText_ReceivesEchoFromAgent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`), `AgentGatewayServiceIntegrationTests_Stop.Connect_SendStop_ReceivesSessionStoppedEvent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Stop.cs`), `AgentGatewayServiceIntegrationTests_NoCommand.Connect_WhenNoAgentCommandConfigured_ReceivesSessionErrorEvent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_NoCommand.cs`) |
-| FR-1.3 | Covered | `AgentGatewayServiceIntegrationTests_Echo.Connect_StartThenSendText_ReceivesEchoFromAgent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`) |
-| FR-1.4 | Covered | `AgentGatewayServiceIntegrationTests_Echo.Connect_StartThenSendText_ReceivesEchoFromAgent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`) |
-| FR-1.5 | Partial | `StructuredLogServiceTests.*` (`tests/RemoteAgent.Service.Tests/StructuredLogServiceTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| FR-1.6 | Partial | `ChatMessageTests.*` (`tests/RemoteAgent.App.Tests/ChatMessageTests.cs`), `MobileConnectionUiTests.*` (`tests/RemoteAgent.Mobile.UiTests/MobileConnectionUiTests.cs`) |
-| FR-2.1 | Covered | `AgentGatewayServiceIntegrationTests_Echo.Connect_StartThenSendText_ReceivesEchoFromAgent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`) |
-| FR-2.2 | Covered | `AgentGatewayServiceIntegrationTests_Echo.Connect_StartThenSendText_ReceivesEchoFromAgent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`) |
+| FR-1.1 | Covered | `HostBootstrapSmokeTests.*` (`tests/RemoteAgent.Service.IntegrationTests/HostBootstrapSmokeTests.cs`), `CheckLocalServerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/CheckLocalServerHandlerTests.cs`) |
+| FR-1.2 | Covered | `AgentGatewayServiceIntegrationTests_NoCommand.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_NoCommand.cs`), `AgentGatewayServiceIntegrationTests_Stop.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Stop.cs`), `HostBootstrapSmokeTests.*` (`tests/RemoteAgent.Service.IntegrationTests/HostBootstrapSmokeTests.cs`), `AgentGatewayServiceIntegrationTests_Echo.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`), `ApplyLocalServerActionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/ApplyLocalServerActionHandlerTests.cs`) |
+| FR-1.3 | Covered | `AgentGatewayServiceIntegrationTests_Echo.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`) |
+| FR-1.4 | Covered | `AgentGatewayServiceIntegrationTests_Echo.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`) |
+| FR-1.5 | Covered | `StructuredLogServiceTests.*` (`tests/RemoteAgent.Service.Tests/StructuredLogServiceTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| FR-1.6 | None | None |
+| FR-2.1 | Covered | `SendDesktopMessageHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SendDesktopMessageHandlerTests.cs`), `AgentGatewayServiceIntegrationTests_Echo.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`) |
+| FR-2.2 | Covered | `SendDesktopMessageHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SendDesktopMessageHandlerTests.cs`), `AgentGatewayServiceIntegrationTests_Echo.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`) |
| FR-2.3 | Covered | `MarkdownFormatTests.*` (`tests/RemoteAgent.App.Tests/MarkdownFormatTests.cs`), `ChatMessageTests.*` (`tests/RemoteAgent.App.Tests/ChatMessageTests.cs`) |
-| FR-2.4 | Covered | `AgentGatewayServiceIntegrationTests_Stop.Connect_SendStop_ReceivesSessionStoppedEvent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Stop.cs`), `MobileConnectionUiTests.*` (`tests/RemoteAgent.Mobile.UiTests/MobileConnectionUiTests.cs`) |
+| FR-2.4 | Covered | `AgentGatewayServiceIntegrationTests_Stop.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Stop.cs`) |
| FR-2.5 | None | None |
-| FR-2.6 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| FR-2.7 | Covered | `MobileConnectionUiTests.*` (`tests/RemoteAgent.Mobile.UiTests/MobileConnectionUiTests.cs`) |
+| FR-2.6 | None | None |
+| FR-2.7 | None | None |
| FR-3.1 | Covered | `ChatMessageTests.*` (`tests/RemoteAgent.App.Tests/ChatMessageTests.cs`) |
| FR-3.2 | None | None |
| FR-3.3 | None | None |
-| FR-3.4 | Partial | `ChatMessageTests.*` (`tests/RemoteAgent.App.Tests/ChatMessageTests.cs`) |
+| FR-3.4 | None | None |
| FR-4.1 | Covered | `ChatMessageTests.*` (`tests/RemoteAgent.App.Tests/ChatMessageTests.cs`) |
-| FR-4.2 | Covered | `ChatMessageTests.*` (`tests/RemoteAgent.App.Tests/ChatMessageTests.cs`) |
+| FR-4.2 | None | None |
| FR-5.1 | None | None |
| FR-5.2 | None | None |
| FR-5.3 | None | None |
| FR-6.1 | None | None |
| FR-6.2 | None | None |
-| FR-7.1 | Covered | `AgentGatewayServiceIntegrationTests_Echo.Connect_StartThenSendText_ReceivesEchoFromAgent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`), `AgentGatewayServiceIntegrationTests_Stop.Connect_SendStop_ReceivesSessionStoppedEvent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Stop.cs`) |
-| FR-7.2 | Covered | `AgentGatewayServiceIntegrationTests_Stop.Connect_SendStop_ReceivesSessionStoppedEvent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Stop.cs`), `ChatMessageTests.*` (`tests/RemoteAgent.App.Tests/ChatMessageTests.cs`), `MobileConnectionUiTests.*` (`tests/RemoteAgent.Mobile.UiTests/MobileConnectionUiTests.cs`) |
-| FR-8.1 | Covered | `PluginConfigurationServiceTests.*` (`tests/RemoteAgent.Service.Tests/PluginConfigurationServiceTests.cs`), `AgentGatewayServiceIntegrationTests_GetServerInfo.GetServerInfo_ReturnsVersionAndCapabilities` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_GetServerInfo.cs`) |
-| FR-9.1 | Partial | `AgentGatewayServiceIntegrationTests_GetServerInfo.GetServerInfo_ReturnsVersionAndCapabilities` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_GetServerInfo.cs`) |
+| FR-7.1 | Covered | `AgentGatewayServiceIntegrationTests_Stop.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Stop.cs`), `AgentGatewayServiceIntegrationTests_Echo.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`) |
+| FR-7.2 | Covered | `AgentGatewayServiceIntegrationTests_Stop.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Stop.cs`), `ChatMessageTests.*` (`tests/RemoteAgent.App.Tests/ChatMessageTests.cs`) |
+| FR-8.1 | Covered | `AgentGatewayServiceIntegrationTests_GetServerInfo.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_GetServerInfo.cs`), `PluginConfigurationServiceTests.*` (`tests/RemoteAgent.Service.Tests/PluginConfigurationServiceTests.cs`) |
+| FR-9.1 | Covered | `AgentGatewayServiceIntegrationTests_GetServerInfo.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_GetServerInfo.cs`) |
| FR-9.2 | None | None |
| FR-10.1 | None | None |
-| FR-11.1 | Partial | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| FR-11.1 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
| FR-11.1.1 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| FR-11.1.2 | Covered | `AgentGatewayServiceIntegrationTests_GetServerInfo.GetServerInfo_ReturnsVersionAndCapabilities` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_GetServerInfo.cs`) |
-| FR-11.1.3 | Partial | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| FR-11.1.2 | Covered | `AgentGatewayServiceIntegrationTests_GetServerInfo.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_GetServerInfo.cs`) |
+| FR-11.1.3 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
| FR-11.1.3.1 | None | None |
| FR-11.1.3.2 | None | None |
-| FR-12.1 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| FR-12.1.1 | Partial | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| FR-12.1.2 | Partial | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| FR-12.1.3 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| FR-12.1.4 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| FR-12.1.5 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| FR-12.2 | Partial | `StructuredLogStoreTests.Query_ShouldApplyFilterCriteria` (`tests/RemoteAgent.Desktop.UiTests/StructuredLogStoreTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| FR-12.3 | Covered | `StructuredLogStoreTests.Query_ShouldApplyFilterCriteria` (`tests/RemoteAgent.Desktop.UiTests/StructuredLogStoreTests.cs`) |
-| FR-12.4 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| FR-12.5 | Partial | `AgentGatewayServiceIntegrationTests_GetServerInfo.GetServerInfo_ReturnsVersionAndCapabilities` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_GetServerInfo.cs`) |
-| FR-12.6 | Partial | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| FR-12.7 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| FR-12.8 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| FR-12.9 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| FR-12.10 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| FR-12.11 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`), `StructuredLogStoreTests.Query_ShouldApplyFilterCriteria` (`tests/RemoteAgent.Desktop.UiTests/StructuredLogStoreTests.cs`) |
-| FR-12.12 | Not covered | Pending implementation of management app log view |
-| FR-13.1 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`), `SessionCapacityServiceTests.MarkSessionAbandoned_ShouldTrackAndClearOnRegister` (`tests/RemoteAgent.Service.Tests/SessionCapacityServiceTests.cs`) |
-| FR-13.2 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`), `ConnectionProtectionServiceTests.*` (`tests/RemoteAgent.Service.Tests/ConnectionProtectionServiceTests.cs`) |
-| FR-13.3 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| FR-13.4 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`), `ConnectionProtectionServiceTests.*` (`tests/RemoteAgent.Service.Tests/ConnectionProtectionServiceTests.cs`) |
-| FR-13.5 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`), `AuthUserServiceTests.*` (`tests/RemoteAgent.Service.Tests/AuthUserServiceTests.cs`) |
-| FR-13.6 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| FR-13.7 | Covered | `SessionCapacityServiceTests_CapacityLimits.*` (`tests/RemoteAgent.Service.Tests/SessionCapacityServiceTests_CapacityLimits.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| FR-13.8 | Covered | `SessionCapacityServiceTests_CapacityLimits.*` (`tests/RemoteAgent.Service.Tests/SessionCapacityServiceTests_CapacityLimits.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| FR-14.1 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| FR-14.2 | Covered | `PromptTemplateEngineTests.*` (`tests/RemoteAgent.App.Tests/PromptTemplateEngineTests.cs`), `PromptTemplateEngineTests_EdgeCases.*` (`tests/RemoteAgent.App.Tests/PromptTemplateEngineTests_EdgeCases.cs`) |
-| FR-14.3 | Partial | `PromptTemplateEngineTests.*` (`tests/RemoteAgent.App.Tests/PromptTemplateEngineTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| FR-14.4 | Partial | `PromptTemplateEngineTests.*` (`tests/RemoteAgent.App.Tests/PromptTemplateEngineTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| FR-12.1 | Covered | `SetManagementSectionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SetManagementSectionHandlerTests.cs`), `OpenNewSessionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/OpenNewSessionHandlerTests.cs`), `ConnectionSettingsDialogViewModelTests.*` (`tests/RemoteAgent.Desktop.UiTests/ViewModels/ConnectionSettingsDialogViewModelTests.cs`) |
+| FR-12.1.1 | None | None |
+| FR-12.1.2 | None | None |
+| FR-12.1.3 | None | None |
+| FR-12.1.4 | None | None |
+| FR-12.1.5 | None | None |
+| FR-12.2 | Covered | `ConnectionSettingsDialogViewModelTests.*` (`tests/RemoteAgent.Desktop.UiTests/ViewModels/ConnectionSettingsDialogViewModelTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| FR-12.3 | Covered | `TerminateDesktopSessionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/TerminateDesktopSessionHandlerTests.cs`) |
+| FR-12.4 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| FR-12.5 | Covered | `AgentGatewayServiceIntegrationTests_GetServerInfo.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_GetServerInfo.cs`), `DeleteMcpServerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/DeleteMcpServerHandlerTests.cs`), `SavePluginsHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SavePluginsHandlerTests.cs`), `SaveAgentMcpMappingHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveAgentMcpMappingHandlerTests.cs`), `SaveMcpServerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveMcpServerHandlerTests.cs`), `RefreshMcpRegistryHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshMcpRegistryHandlerTests.cs`), `RefreshPluginsHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshPluginsHandlerTests.cs`) |
+| FR-12.6 | Covered | `SeedSessionContextHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SeedSessionContextHandlerTests.cs`), `DeletePromptTemplateHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/DeletePromptTemplateHandlerTests.cs`), `RefreshPromptTemplatesHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshPromptTemplatesHandlerTests.cs`), `SavePromptTemplateHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SavePromptTemplateHandlerTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| FR-12.7 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| FR-12.8 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| FR-12.9 | Covered | `RemoveServerRegistrationHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RemoveServerRegistrationHandlerTests.cs`), `SaveServerRegistrationHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveServerRegistrationHandlerTests.cs`) |
+| FR-12.10 | None | None |
+| FR-12.11 | Covered | `ExpandStatusLogPanelHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/ExpandStatusLogPanelHandlerTests.cs`), `StartLogMonitoringHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/StartLogMonitoringHandlerTests.cs`) |
+| FR-12.12 | Covered | `ClearAppLogHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/ClearAppLogHandlerTests.cs`), `SaveAppLogHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveAppLogHandlerTests.cs`) |
+| FR-12.12.1 | None | None |
+| FR-12.12.2 | None | None |
+| FR-12.12.3 | None | None |
+| FR-12.12.4 | None | None |
+| FR-12.12.5 | None | None |
+| FR-13.1 | Covered | `RefreshOpenSessionsHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshOpenSessionsHandlerTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| FR-13.2 | Covered | `ConnectionProtectionServiceTests.*` (`tests/RemoteAgent.Service.Tests/ConnectionProtectionServiceTests.cs`), `RefreshSecurityDataHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshSecurityDataHandlerTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| FR-13.3 | Covered | `TerminateDesktopSessionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/TerminateDesktopSessionHandlerTests.cs`), `TerminateOpenServerSessionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/TerminateOpenServerSessionHandlerTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| FR-13.4 | Covered | `BanPeerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/BanPeerHandlerTests.cs`), `UnbanPeerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/UnbanPeerHandlerTests.cs`), `ConnectionProtectionServiceTests.*` (`tests/RemoteAgent.Service.Tests/ConnectionProtectionServiceTests.cs`), `RefreshSecurityDataHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshSecurityDataHandlerTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| FR-13.5 | Covered | `DeleteAuthUserHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/DeleteAuthUserHandlerTests.cs`), `RefreshAuthUsersHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshAuthUsersHandlerTests.cs`), `SaveAuthUserHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveAuthUserHandlerTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| FR-13.6 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| FR-13.7 | Covered | `CheckSessionCapacityHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/CheckSessionCapacityHandlerTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| FR-13.8 | Covered | `CheckSessionCapacityHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/CheckSessionCapacityHandlerTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| FR-14.1 | Covered | `PromptTemplateServiceTests.*` (`tests/RemoteAgent.Service.Tests/PromptTemplateServiceTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| FR-14.2 | Covered | `PromptTemplateServiceTests.*` (`tests/RemoteAgent.Service.Tests/PromptTemplateServiceTests.cs`), `PromptTemplateEngineTests.*` (`tests/RemoteAgent.App.Tests/PromptTemplateEngineTests.cs`) |
+| FR-14.3 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| FR-14.4 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
| FR-15.1 | Covered | `ConnectionProtectionServiceTests.*` (`tests/RemoteAgent.Service.Tests/ConnectionProtectionServiceTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
| FR-15.2 | Covered | `ConnectionProtectionServiceTests.*` (`tests/RemoteAgent.Service.Tests/ConnectionProtectionServiceTests.cs`) |
-| FR-16.1 | Covered | `AgentGatewayServiceIntegrationTests_Echo.Connect_StartThenSendText_ReceivesEchoFromAgent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`), `AgentGatewayServiceIntegrationTests_Stop.Connect_SendStop_ReceivesSessionStoppedEvent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Stop.cs`), `AgentGatewayServiceIntegrationTests_NoCommand.Connect_WhenNoAgentCommandConfigured_ReceivesSessionErrorEvent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_NoCommand.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| FR-16.1 | Covered | `AgentGatewayServiceIntegrationTests_NoCommand.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_NoCommand.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| FR-17.1 | Partial | `ScanQrCodeHandlerTests.*` (`tests/RemoteAgent.App.Tests/ScanQrCodeHandlerTests.cs`), `MobileHandlerTests.*` (`tests/RemoteAgent.App.Tests/MobileHandlerTests.cs`) |
+| FR-17.2 | Partial | `SetPairingUserHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SetPairingUserHandlerTests.cs`) |
+| FR-17.3 | None | None |
+| FR-17.4 | None | None |
+| FR-17.5 | None | None |
+| FR-17.6 | Partial | `ScanQrCodeHandlerTests.*` (`tests/RemoteAgent.App.Tests/ScanQrCodeHandlerTests.cs`) |
+| FR-17.6.1 | None | None |
+| FR-17.6.2 | None | None |
+| FR-17.7 | None | None |
+| FR-17.8 | Partial | `MobileHandlerTests.*` (`tests/RemoteAgent.App.Tests/MobileHandlerTests.cs`) |
+| FR-18.1 | None | None |
+| FR-18.2 | None | None |
+| FR-19.1 | Partial | `MobileHandlerTests.*` (`tests/RemoteAgent.App.Tests/MobileHandlerTests.cs`), `SaveServerRegistrationHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveServerRegistrationHandlerTests.cs`) |
+| FR-19.2 | Partial | `SaveServerRegistrationHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveServerRegistrationHandlerTests.cs`), `RemoveServerRegistrationHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RemoveServerRegistrationHandlerTests.cs`) |
+| FR-19.3 | Partial | `SaveServerRegistrationHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveServerRegistrationHandlerTests.cs`) |
+| FR-19.4 | None | None |
+| FR-19.5 | None | None |
+| FR-20.1 | None | None |
+| FR-20.2 | Covered | `MobileHandlerTests.*` (`tests/RemoteAgent.App.Tests/MobileHandlerTests.cs`) |
+| FR-20.3 | None | None |
+| FR-20.4 | Covered | `MobileHandlerTests.*` (`tests/RemoteAgent.App.Tests/MobileHandlerTests.cs`) |
## Technical Requirements (TR)
@@ -86,36 +114,36 @@ Coverage status values:
|---|---|---|
| TR-1.1 | None | None |
| TR-1.2 | None | None |
-| TR-1.3 | None | None |
+| TR-1.3 | Covered | `HostBootstrapSmokeTests.*` (`tests/RemoteAgent.Service.IntegrationTests/HostBootstrapSmokeTests.cs`), `AgentOptionsTests.*` (`tests/RemoteAgent.Service.Tests/AgentOptionsTests.cs`) |
| TR-1.4 | None | None |
| TR-1.5 | None | None |
-| TR-2.1 | Partial | `MobileConnectionUiTests.*` (`tests/RemoteAgent.Mobile.UiTests/MobileConnectionUiTests.cs`) |
-| TR-2.1.1 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
+| TR-2.1 | None | None |
+| TR-2.1.1 | None | None |
| TR-2.1.2 | None | None |
-| TR-2.2 | Partial | `HostBootstrapSmokeTests.RootEndpoint_RespondsQuickly` (`tests/RemoteAgent.Service.IntegrationTests/HostBootstrapSmokeTests.cs`) |
-| TR-2.3 | Covered | `AgentGatewayServiceIntegrationTests_Echo.Connect_StartThenSendText_ReceivesEchoFromAgent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`), `AgentGatewayServiceIntegrationTests_Stop.Connect_SendStop_ReceivesSessionStoppedEvent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Stop.cs`), `AgentGatewayServiceIntegrationTests_NoCommand.Connect_WhenNoAgentCommandConfigured_ReceivesSessionErrorEvent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_NoCommand.cs`) |
+| TR-2.2 | Covered | `HostBootstrapSmokeTests.*` (`tests/RemoteAgent.Service.IntegrationTests/HostBootstrapSmokeTests.cs`), `AgentOptionsTests.*` (`tests/RemoteAgent.Service.Tests/AgentOptionsTests.cs`) |
+| TR-2.3 | Covered | `AgentGatewayServiceIntegrationTests_NoCommand.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_NoCommand.cs`), `AgentGatewayServiceIntegrationTests_Stop.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Stop.cs`), `AgentGatewayServiceIntegrationTests_Echo.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`) |
| TR-2.4 | None | None |
-| TR-3.1 | Partial | `HostBootstrapSmokeTests.RootEndpoint_RespondsQuickly` (`tests/RemoteAgent.Service.IntegrationTests/HostBootstrapSmokeTests.cs`) |
-| TR-3.2 | Covered | `AgentGatewayServiceIntegrationTests_Echo.Connect_StartThenSendText_ReceivesEchoFromAgent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`), `AgentGatewayServiceIntegrationTests_Stop.Connect_SendStop_ReceivesSessionStoppedEvent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Stop.cs`) |
-| TR-3.3 | Covered | `AgentGatewayServiceIntegrationTests_Echo.Connect_StartThenSendText_ReceivesEchoFromAgent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`) |
-| TR-3.4 | Covered | `AgentGatewayServiceIntegrationTests_Echo.Connect_StartThenSendText_ReceivesEchoFromAgent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`) |
-| TR-3.5 | Partial | `ChatMessageTests.*` (`tests/RemoteAgent.App.Tests/ChatMessageTests.cs`) |
-| TR-3.6 | Partial | `StructuredLogServiceTests.*` (`tests/RemoteAgent.Service.Tests/StructuredLogServiceTests.cs`) |
-| TR-3.7 | Covered | `SessionCapacityServiceTests_CapacityLimits.*` (`tests/RemoteAgent.Service.Tests/SessionCapacityServiceTests_CapacityLimits.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| TR-3.8 | Covered | `SessionCapacityServiceTests_CapacityLimits.*` (`tests/RemoteAgent.Service.Tests/SessionCapacityServiceTests_CapacityLimits.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| TR-4.1 | Partial | `AgentGatewayServiceIntegrationTests_Echo.Connect_StartThenSendText_ReceivesEchoFromAgent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`) |
-| TR-4.2 | Covered | `AgentGatewayServiceIntegrationTests_Echo.Connect_StartThenSendText_ReceivesEchoFromAgent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`), `AgentGatewayServiceIntegrationTests_Stop.Connect_SendStop_ReceivesSessionStoppedEvent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Stop.cs`) |
-| TR-4.3 | Covered | `AgentGatewayServiceIntegrationTests_Echo.Connect_StartThenSendText_ReceivesEchoFromAgent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`), `AgentGatewayServiceIntegrationTests_CorrelationId.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_CorrelationId.cs`) |
-| TR-4.4 | Covered | `AgentGatewayServiceIntegrationTests_Echo.Connect_StartThenSendText_ReceivesEchoFromAgent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`), `AgentGatewayServiceIntegrationTests_Stop.Connect_SendStop_ReceivesSessionStoppedEvent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Stop.cs`) |
-| TR-4.5 | Covered | `AgentGatewayServiceIntegrationTests_CorrelationId.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_CorrelationId.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| TR-3.1 | Covered | `HostBootstrapSmokeTests.*` (`tests/RemoteAgent.Service.IntegrationTests/HostBootstrapSmokeTests.cs`), `AgentOptionsTests.*` (`tests/RemoteAgent.Service.Tests/AgentOptionsTests.cs`) |
+| TR-3.2 | Covered | `AgentGatewayServiceIntegrationTests_NoCommand.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_NoCommand.cs`), `AgentGatewayServiceIntegrationTests_Stop.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Stop.cs`), `AgentGatewayServiceIntegrationTests_Echo.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`), `AgentOptionsTests.*` (`tests/RemoteAgent.Service.Tests/AgentOptionsTests.cs`) |
+| TR-3.3 | Covered | `AgentGatewayServiceIntegrationTests_Echo.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`) |
+| TR-3.4 | Covered | `AgentGatewayServiceIntegrationTests_Stop.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Stop.cs`), `AgentGatewayServiceIntegrationTests_Echo.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`) |
+| TR-3.5 | None | None |
+| TR-3.6 | Covered | `LiteDbLocalStorageTests.*` (`tests/RemoteAgent.Service.Tests/LiteDbLocalStorageTests.cs`), `StructuredLogServiceTests.*` (`tests/RemoteAgent.Service.Tests/StructuredLogServiceTests.cs`) |
+| TR-3.7 | None | None |
+| TR-3.8 | None | None |
+| TR-4.1 | None | None |
+| TR-4.2 | Covered | `AgentGatewayServiceIntegrationTests_Stop.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Stop.cs`) |
+| TR-4.3 | Covered | `AgentGatewayServiceIntegrationTests_NoCommand.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_NoCommand.cs`), `AgentGatewayServiceIntegrationTests_Stop.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Stop.cs`) |
+| TR-4.4 | None | None |
+| TR-4.5 | Covered | `AgentGatewayServiceIntegrationTests_GetServerInfo.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_GetServerInfo.cs`), `AgentGatewayServiceIntegrationTests_CorrelationId.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_CorrelationId.cs`) |
| TR-5.1 | Covered | `ChatMessageTests.*` (`tests/RemoteAgent.App.Tests/ChatMessageTests.cs`) |
-| TR-5.2 | Covered | `MobileConnectionUiTests.*` (`tests/RemoteAgent.Mobile.UiTests/MobileConnectionUiTests.cs`) |
+| TR-5.2 | None | None |
| TR-5.3 | Covered | `MarkdownFormatTests.*` (`tests/RemoteAgent.App.Tests/MarkdownFormatTests.cs`), `ChatMessageTests.*` (`tests/RemoteAgent.App.Tests/ChatMessageTests.cs`) |
| TR-5.4 | None | None |
| TR-5.5 | Covered | `ChatMessageTests.*` (`tests/RemoteAgent.App.Tests/ChatMessageTests.cs`) |
| TR-5.6 | None | None |
-| TR-5.7 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| TR-5.8 | Covered | `MobileConnectionUiTests.*` (`tests/RemoteAgent.Mobile.UiTests/MobileConnectionUiTests.cs`) |
+| TR-5.7 | None | None |
+| TR-5.8 | None | None |
| TR-6.1 | None | None |
| TR-6.2 | None | None |
| TR-6.3 | None | None |
@@ -127,73 +155,95 @@ Coverage status values:
| TR-7.3.3 | None | None |
| TR-7.3.4 | None | None |
| TR-7.3.5 | None | None |
-| TR-8.1 | Covered | `ChatMessageTests.*` (`tests/RemoteAgent.App.Tests/ChatMessageTests.cs`), `AgentOptionsTests.*` (`tests/RemoteAgent.Service.Tests/AgentOptionsTests.cs`), `AgentGatewayServiceIntegrationTests_Echo.Connect_StartThenSendText_ReceivesEchoFromAgent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`) |
-| TR-8.2 | Covered | `MarkdownFormatTests.*` (`tests/RemoteAgent.App.Tests/MarkdownFormatTests.cs`), `ChatMessageTests.*` (`tests/RemoteAgent.App.Tests/ChatMessageTests.cs`), `AgentOptionsTests.*` (`tests/RemoteAgent.Service.Tests/AgentOptionsTests.cs`) |
-| TR-8.3 | Covered | `AgentGatewayServiceIntegrationTests_NoCommand.Connect_WhenNoAgentCommandConfigured_ReceivesSessionErrorEvent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_NoCommand.cs`), `AgentGatewayServiceIntegrationTests_Echo.Connect_StartThenSendText_ReceivesEchoFromAgent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Echo.cs`), `AgentGatewayServiceIntegrationTests_Stop.Connect_SendStop_ReceivesSessionStoppedEvent` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_Stop.cs`) |
-| TR-8.4 | Partial | `ChatMessageTests.*` (`tests/RemoteAgent.App.Tests/ChatMessageTests.cs`), `AgentOptionsTests.*` (`tests/RemoteAgent.Service.Tests/AgentOptionsTests.cs`), `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| TR-8.4.1 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| TR-8.5 | Covered | `MobileConnectionUiTests.*` (`tests/RemoteAgent.Mobile.UiTests/MobileConnectionUiTests.cs`) |
-| TR-8.6 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
+| TR-8.1 | None | None |
+| TR-8.2 | None | None |
+| TR-8.3 | Covered | `AgentGatewayServiceIntegrationTests_NoCommand.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_NoCommand.cs`) |
+| TR-8.4 | None | None |
+| TR-8.4.1 | None | None |
+| TR-8.5 | None | None |
+| TR-8.6 | None | None |
| TR-9.1 | None | None |
| TR-9.2 | None | None |
| TR-9.3 | None | None |
-| TR-10.1 | Covered | `PluginConfigurationServiceTests.*` (`tests/RemoteAgent.Service.Tests/PluginConfigurationServiceTests.cs`), `AgentGatewayServiceIntegrationTests_GetServerInfo.GetServerInfo_ReturnsVersionAndCapabilities` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_GetServerInfo.cs`) |
-| TR-10.2 | Covered | `PluginConfigurationServiceTests.*` (`tests/RemoteAgent.Service.Tests/PluginConfigurationServiceTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| TR-11.1 | Covered | `LiteDbLocalStorageTests.SessionExists_ReturnsTrue_WhenEntriesExist` (`tests/RemoteAgent.Service.Tests/LiteDbLocalStorageTests.cs`), `StructuredLogStoreTests.Query_ShouldApplyFilterCriteria` (`tests/RemoteAgent.Desktop.UiTests/StructuredLogStoreTests.cs`) |
+| TR-10.1 | Covered | `AgentGatewayServiceIntegrationTests_GetServerInfo.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_GetServerInfo.cs`), `PluginConfigurationServiceTests.*` (`tests/RemoteAgent.Service.Tests/PluginConfigurationServiceTests.cs`) |
+| TR-10.2 | Covered | `PluginConfigurationServiceTests.*` (`tests/RemoteAgent.Service.Tests/PluginConfigurationServiceTests.cs`) |
+| TR-11.1 | Covered | `LiteDbLocalStorageTests.*` (`tests/RemoteAgent.Service.Tests/LiteDbLocalStorageTests.cs`) |
| TR-11.2 | None | None |
| TR-11.3 | None | None |
| TR-12.1 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
| TR-12.1.1 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| TR-12.1.2 | Covered | `AgentGatewayServiceIntegrationTests_GetServerInfo.GetServerInfo_ReturnsVersionAndCapabilities` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_GetServerInfo.cs`) |
-| TR-12.1.3 | Partial | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| TR-12.2 | Covered | `AgentGatewayServiceIntegrationTests_GetServerInfo.GetServerInfo_ReturnsVersionAndCapabilities` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_GetServerInfo.cs`) |
-| TR-12.2.1 | Partial | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| TR-12.1.2 | None | None |
+| TR-12.1.3 | None | None |
+| TR-12.2 | Covered | `AgentGatewayServiceIntegrationTests_GetServerInfo.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_GetServerInfo.cs`) |
+| TR-12.2.1 | None | None |
| TR-12.2.2 | None | None |
| TR-13.1 | Covered | `StructuredLogServiceTests.*` (`tests/RemoteAgent.Service.Tests/StructuredLogServiceTests.cs`) |
-| TR-13.2 | Covered | `StructuredLogServiceTests.*` (`tests/RemoteAgent.Service.Tests/StructuredLogServiceTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| TR-13.2 | Covered | `StructuredLogServiceTests.*` (`tests/RemoteAgent.Service.Tests/StructuredLogServiceTests.cs`) |
| TR-13.3 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| TR-13.4 | Covered | `StructuredLogStoreTests.Query_ShouldApplyFilterCriteria` (`tests/RemoteAgent.Desktop.UiTests/StructuredLogStoreTests.cs`) |
-| TR-13.5 | Covered | `StructuredLogStoreTests.Query_ShouldApplyFilterCriteria` (`tests/RemoteAgent.Desktop.UiTests/StructuredLogStoreTests.cs`) |
-| TR-13.6 | Partial | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| TR-14.1 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| TR-14.1.0 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| TR-14.1.1 | Partial | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| TR-14.1.2 | Partial | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| TR-14.1.3 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| TR-14.1.4 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| TR-14.1.5 | Partial | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| TR-14.1.6 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| TR-14.1.7 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| TR-14.1.8 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| TR-14.1.9 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| TR-14.1.10 | Covered | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| TR-14.2 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`), `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| TR-14.3 | Covered | `AgentGatewayServiceIntegrationTests_GetServerInfo.GetServerInfo_ReturnsVersionAndCapabilities` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_GetServerInfo.cs`) |
+| TR-13.4 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| TR-13.5 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| TR-13.6 | None | None |
+| TR-14.1 | None | None |
+| TR-14.1.0 | None | None |
+| TR-14.1.1 | None | None |
+| TR-14.1.2 | None | None |
+| TR-14.1.3 | None | None |
+| TR-14.1.4 | None | None |
+| TR-14.1.5 | None | None |
+| TR-14.1.6 | None | None |
+| TR-14.1.7 | None | None |
+| TR-14.1.8 | None | None |
+| TR-14.1.9 | None | None |
+| TR-14.1.10 | None | None |
+| TR-14.2 | None | None |
+| TR-14.3 | None | None |
| TR-14.4 | None | None |
| TR-14.5 | None | None |
-| TR-15.1 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| TR-15.2 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`), `ConnectionProtectionServiceTests.*` (`tests/RemoteAgent.Service.Tests/ConnectionProtectionServiceTests.cs`) |
-| TR-15.3 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`), `AuthUserServiceTests.*` (`tests/RemoteAgent.Service.Tests/AuthUserServiceTests.cs`) |
-| TR-15.4 | Partial | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| TR-15.5 | Covered | `ConnectionProtectionServiceTests.*` (`tests/RemoteAgent.Service.Tests/ConnectionProtectionServiceTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| TR-15.1 | Covered | `ConnectionProtectionServiceTests.*` (`tests/RemoteAgent.Service.Tests/ConnectionProtectionServiceTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| TR-15.2 | Covered | `ConnectionProtectionServiceTests.*` (`tests/RemoteAgent.Service.Tests/ConnectionProtectionServiceTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| TR-15.3 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| TR-15.4 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| TR-15.5 | Covered | `ConnectionProtectionServiceTests.*` (`tests/RemoteAgent.Service.Tests/ConnectionProtectionServiceTests.cs`) |
| TR-15.6 | Covered | `ConnectionProtectionServiceTests.*` (`tests/RemoteAgent.Service.Tests/ConnectionProtectionServiceTests.cs`) |
| TR-15.7 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
| TR-15.8 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
| TR-15.9 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| TR-15.10 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`), `AuthUserServiceTests.*` (`tests/RemoteAgent.Service.Tests/AuthUserServiceTests.cs`) |
-| TR-16.1 | Partial | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| TR-16.2 | Partial | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| TR-16.3 | Partial | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| TR-16.4 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| TR-16.5 | Partial | `MainWindowUiTests.*` (`tests/RemoteAgent.Desktop.UiTests/MainWindowUiTests.cs`) |
-| TR-16.6 | Partial | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-| TR-16.7 | Partial | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| TR-15.10 | Covered | `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
+| TR-16.1 | None | None |
+| TR-16.2 | None | None |
+| TR-16.3 | None | None |
+| TR-16.4 | None | None |
+| TR-16.5 | None | None |
+| TR-16.6 | None | None |
+| TR-16.7 | None | None |
| TR-17.1 | Covered | `PromptTemplateServiceTests.*` (`tests/RemoteAgent.Service.Tests/PromptTemplateServiceTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
| TR-17.2 | Covered | `PromptTemplateServiceTests.*` (`tests/RemoteAgent.Service.Tests/PromptTemplateServiceTests.cs`) |
-| TR-17.3 | Covered | `PromptTemplateEngineTests.*` (`tests/RemoteAgent.App.Tests/PromptTemplateEngineTests.cs`), `PromptTemplateEngineTests_EdgeCases.*` (`tests/RemoteAgent.App.Tests/PromptTemplateEngineTests_EdgeCases.cs`) |
-| TR-17.4 | Partial | `PromptTemplateEngineTests.*` (`tests/RemoteAgent.App.Tests/PromptTemplateEngineTests.cs`), `AgentGatewayServiceIntegrationTests_ManagementApis.*` (`tests/RemoteAgent.Service.IntegrationTests/AgentGatewayServiceIntegrationTests_ManagementApis.cs`) |
-
-## Notes
-
-- This is an automated-test matrix only. Some `None` rows may still be validated by implementation review, workflow checks, or manual QA.
-- If you want, this matrix can be tightened further by adding direct FR/TR annotations in every test method and generating this file automatically from source comments.
+| TR-17.3 | Covered | `PromptTemplateEngineTests.*` (`tests/RemoteAgent.App.Tests/PromptTemplateEngineTests.cs`) |
+| TR-17.4 | None | None |
+| TR-18.1 | Covered | `RefreshOpenSessionsHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshOpenSessionsHandlerTests.cs`), `BanPeerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/BanPeerHandlerTests.cs`), `DeleteAuthUserHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/DeleteAuthUserHandlerTests.cs`), `ClearAppLogHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/ClearAppLogHandlerTests.cs`), `SendDesktopMessageHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SendDesktopMessageHandlerTests.cs`), `SaveAppLogHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveAppLogHandlerTests.cs`), `ExpandStatusLogPanelHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/ExpandStatusLogPanelHandlerTests.cs`), `RemoveServerRegistrationHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RemoveServerRegistrationHandlerTests.cs`), `LiteDbLocalStorageTests.*` (`tests/RemoteAgent.Service.Tests/LiteDbLocalStorageTests.cs`), `SetManagementSectionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SetManagementSectionHandlerTests.cs`), `TerminateDesktopSessionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/TerminateDesktopSessionHandlerTests.cs`), `DeleteMcpServerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/DeleteMcpServerHandlerTests.cs`), `SavePluginsHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SavePluginsHandlerTests.cs`), `UnbanPeerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/UnbanPeerHandlerTests.cs`), `RefreshAuthUsersHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshAuthUsersHandlerTests.cs`), `SeedSessionContextHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SeedSessionContextHandlerTests.cs`), `DeletePromptTemplateHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/DeletePromptTemplateHandlerTests.cs`), `OpenNewSessionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/OpenNewSessionHandlerTests.cs`), `StartLogMonitoringHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/StartLogMonitoringHandlerTests.cs`), `RefreshSecurityDataHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshSecurityDataHandlerTests.cs`), `StructuredLogServiceTests.*` (`tests/RemoteAgent.Service.Tests/StructuredLogServiceTests.cs`), `SaveAgentMcpMappingHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveAgentMcpMappingHandlerTests.cs`), `CheckSessionCapacityHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/CheckSessionCapacityHandlerTests.cs`), `ServiceProviderRequestDispatcherTests.*` (`tests/RemoteAgent.App.Tests/Cqrs/ServiceProviderRequestDispatcherTests.cs`), `CheckLocalServerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/CheckLocalServerHandlerTests.cs`), `ApplyLocalServerActionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/ApplyLocalServerActionHandlerTests.cs`), `SaveServerRegistrationHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveServerRegistrationHandlerTests.cs`), `SaveMcpServerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveMcpServerHandlerTests.cs`), `SaveAuthUserHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveAuthUserHandlerTests.cs`), `ConnectionSettingsDialogViewModelTests.*` (`tests/RemoteAgent.Desktop.UiTests/ViewModels/ConnectionSettingsDialogViewModelTests.cs`), `TerminateOpenServerSessionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/TerminateOpenServerSessionHandlerTests.cs`), `RefreshMcpRegistryHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshMcpRegistryHandlerTests.cs`), `RefreshPromptTemplatesHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshPromptTemplatesHandlerTests.cs`), `SavePromptTemplateHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SavePromptTemplateHandlerTests.cs`), `RefreshPluginsHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshPluginsHandlerTests.cs`) |
+| TR-18.2 | Covered | `RefreshOpenSessionsHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshOpenSessionsHandlerTests.cs`), `BanPeerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/BanPeerHandlerTests.cs`), `DeleteAuthUserHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/DeleteAuthUserHandlerTests.cs`), `ClearAppLogHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/ClearAppLogHandlerTests.cs`), `SendDesktopMessageHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SendDesktopMessageHandlerTests.cs`), `SaveAppLogHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveAppLogHandlerTests.cs`), `ExpandStatusLogPanelHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/ExpandStatusLogPanelHandlerTests.cs`), `RemoveServerRegistrationHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RemoveServerRegistrationHandlerTests.cs`), `SetManagementSectionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SetManagementSectionHandlerTests.cs`), `TerminateDesktopSessionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/TerminateDesktopSessionHandlerTests.cs`), `DeleteMcpServerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/DeleteMcpServerHandlerTests.cs`), `SavePluginsHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SavePluginsHandlerTests.cs`), `UnbanPeerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/UnbanPeerHandlerTests.cs`), `RefreshAuthUsersHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshAuthUsersHandlerTests.cs`), `SeedSessionContextHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SeedSessionContextHandlerTests.cs`), `DeletePromptTemplateHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/DeletePromptTemplateHandlerTests.cs`), `OpenNewSessionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/OpenNewSessionHandlerTests.cs`), `StartLogMonitoringHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/StartLogMonitoringHandlerTests.cs`), `RefreshSecurityDataHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshSecurityDataHandlerTests.cs`), `StructuredLogServiceTests.*` (`tests/RemoteAgent.Service.Tests/StructuredLogServiceTests.cs`), `SaveAgentMcpMappingHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveAgentMcpMappingHandlerTests.cs`), `CheckSessionCapacityHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/CheckSessionCapacityHandlerTests.cs`), `ServiceProviderRequestDispatcherTests.*` (`tests/RemoteAgent.App.Tests/Cqrs/ServiceProviderRequestDispatcherTests.cs`), `CheckLocalServerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/CheckLocalServerHandlerTests.cs`), `ApplyLocalServerActionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/ApplyLocalServerActionHandlerTests.cs`), `SaveServerRegistrationHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveServerRegistrationHandlerTests.cs`), `SaveMcpServerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveMcpServerHandlerTests.cs`), `SaveAuthUserHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveAuthUserHandlerTests.cs`), `ConnectionSettingsDialogViewModelTests.*` (`tests/RemoteAgent.Desktop.UiTests/ViewModels/ConnectionSettingsDialogViewModelTests.cs`), `TerminateOpenServerSessionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/TerminateOpenServerSessionHandlerTests.cs`), `RefreshMcpRegistryHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshMcpRegistryHandlerTests.cs`), `RefreshPromptTemplatesHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshPromptTemplatesHandlerTests.cs`), `SavePromptTemplateHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SavePromptTemplateHandlerTests.cs`), `RefreshPluginsHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshPluginsHandlerTests.cs`) |
+| TR-18.3 | Covered | `RefreshOpenSessionsHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshOpenSessionsHandlerTests.cs`), `BanPeerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/BanPeerHandlerTests.cs`), `DeleteAuthUserHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/DeleteAuthUserHandlerTests.cs`), `ClearAppLogHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/ClearAppLogHandlerTests.cs`), `SendDesktopMessageHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SendDesktopMessageHandlerTests.cs`), `SaveAppLogHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveAppLogHandlerTests.cs`), `ExpandStatusLogPanelHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/ExpandStatusLogPanelHandlerTests.cs`), `RemoveServerRegistrationHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RemoveServerRegistrationHandlerTests.cs`), `SetManagementSectionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SetManagementSectionHandlerTests.cs`), `TerminateDesktopSessionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/TerminateDesktopSessionHandlerTests.cs`), `DeleteMcpServerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/DeleteMcpServerHandlerTests.cs`), `SavePluginsHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SavePluginsHandlerTests.cs`), `UnbanPeerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/UnbanPeerHandlerTests.cs`), `RefreshAuthUsersHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshAuthUsersHandlerTests.cs`), `SeedSessionContextHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SeedSessionContextHandlerTests.cs`), `DeletePromptTemplateHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/DeletePromptTemplateHandlerTests.cs`), `OpenNewSessionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/OpenNewSessionHandlerTests.cs`), `StartLogMonitoringHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/StartLogMonitoringHandlerTests.cs`), `RefreshSecurityDataHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshSecurityDataHandlerTests.cs`), `SaveAgentMcpMappingHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveAgentMcpMappingHandlerTests.cs`), `CheckSessionCapacityHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/CheckSessionCapacityHandlerTests.cs`), `ServiceProviderRequestDispatcherTests.*` (`tests/RemoteAgent.App.Tests/Cqrs/ServiceProviderRequestDispatcherTests.cs`), `CheckLocalServerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/CheckLocalServerHandlerTests.cs`), `ApplyLocalServerActionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/ApplyLocalServerActionHandlerTests.cs`), `SaveServerRegistrationHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveServerRegistrationHandlerTests.cs`), `SaveMcpServerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveMcpServerHandlerTests.cs`), `SaveAuthUserHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveAuthUserHandlerTests.cs`), `ConnectionSettingsDialogViewModelTests.*` (`tests/RemoteAgent.Desktop.UiTests/ViewModels/ConnectionSettingsDialogViewModelTests.cs`), `TerminateOpenServerSessionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/TerminateOpenServerSessionHandlerTests.cs`), `RefreshMcpRegistryHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshMcpRegistryHandlerTests.cs`), `RefreshPromptTemplatesHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshPromptTemplatesHandlerTests.cs`), `SavePromptTemplateHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SavePromptTemplateHandlerTests.cs`), `RefreshPluginsHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshPluginsHandlerTests.cs`) |
+| TR-18.4 | Covered | `RefreshOpenSessionsHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshOpenSessionsHandlerTests.cs`), `BanPeerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/BanPeerHandlerTests.cs`), `DeleteAuthUserHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/DeleteAuthUserHandlerTests.cs`), `ClearAppLogHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/ClearAppLogHandlerTests.cs`), `SendDesktopMessageHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SendDesktopMessageHandlerTests.cs`), `SaveAppLogHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveAppLogHandlerTests.cs`), `ExpandStatusLogPanelHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/ExpandStatusLogPanelHandlerTests.cs`), `RemoveServerRegistrationHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RemoveServerRegistrationHandlerTests.cs`), `SetManagementSectionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SetManagementSectionHandlerTests.cs`), `TerminateDesktopSessionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/TerminateDesktopSessionHandlerTests.cs`), `DeleteMcpServerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/DeleteMcpServerHandlerTests.cs`), `SavePluginsHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SavePluginsHandlerTests.cs`), `UnbanPeerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/UnbanPeerHandlerTests.cs`), `RefreshAuthUsersHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshAuthUsersHandlerTests.cs`), `SeedSessionContextHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SeedSessionContextHandlerTests.cs`), `DeletePromptTemplateHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/DeletePromptTemplateHandlerTests.cs`), `OpenNewSessionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/OpenNewSessionHandlerTests.cs`), `StartLogMonitoringHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/StartLogMonitoringHandlerTests.cs`), `RefreshSecurityDataHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshSecurityDataHandlerTests.cs`), `SaveAgentMcpMappingHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveAgentMcpMappingHandlerTests.cs`), `CheckSessionCapacityHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/CheckSessionCapacityHandlerTests.cs`), `CheckLocalServerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/CheckLocalServerHandlerTests.cs`), `ApplyLocalServerActionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/ApplyLocalServerActionHandlerTests.cs`), `SaveServerRegistrationHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveServerRegistrationHandlerTests.cs`), `SaveMcpServerHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveMcpServerHandlerTests.cs`), `SaveAuthUserHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveAuthUserHandlerTests.cs`), `ConnectionSettingsDialogViewModelTests.*` (`tests/RemoteAgent.Desktop.UiTests/ViewModels/ConnectionSettingsDialogViewModelTests.cs`), `TerminateOpenServerSessionHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/TerminateOpenServerSessionHandlerTests.cs`), `RefreshMcpRegistryHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshMcpRegistryHandlerTests.cs`), `RefreshPromptTemplatesHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshPromptTemplatesHandlerTests.cs`), `SavePromptTemplateHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SavePromptTemplateHandlerTests.cs`), `RefreshPluginsHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/RefreshPluginsHandlerTests.cs`) |
+| TR-19.1 | Partial | `SetPairingUserHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SetPairingUserHandlerTests.cs`) |
+| TR-19.2 | None | None |
+| TR-19.3 | None | None |
+| TR-19.4 | None | None |
+| TR-19.5 | None | None |
+| TR-19.6 | Partial | `ScanQrCodeHandlerTests.*` (`tests/RemoteAgent.App.Tests/ScanQrCodeHandlerTests.cs`) |
+| TR-19.7 | Partial | `ScanQrCodeHandlerTests.*` (`tests/RemoteAgent.App.Tests/ScanQrCodeHandlerTests.cs`) |
+| TR-19.8 | Partial | `SetPairingUserHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SetPairingUserHandlerTests.cs`) |
+| TR-19.9 | Partial | `ScanQrCodeHandlerTests.*` (`tests/RemoteAgent.App.Tests/ScanQrCodeHandlerTests.cs`) |
+| TR-20.1 | None | None |
+| TR-20.2 | None | None |
+| TR-21.1 | Partial | `MobileHandlerTests.*` (`tests/RemoteAgent.App.Tests/MobileHandlerTests.cs`) |
+| TR-21.2 | None | None |
+| TR-21.3 | Covered | `SaveServerRegistrationHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveServerRegistrationHandlerTests.cs`) |
+| TR-21.4 | Partial | `MobileHandlerTests.*` (`tests/RemoteAgent.App.Tests/MobileHandlerTests.cs`) |
+| TR-21.5 | None | None |
+| TR-21.6 | Covered | `SaveServerRegistrationHandlerTests.*` (`tests/RemoteAgent.Desktop.UiTests/Handlers/SaveServerRegistrationHandlerTests.cs`) |
+| TR-21.7 | None | None |
+| TR-22.1 | None | None |
+| TR-22.2 | Covered | `MobileHandlerTests.*` (`tests/RemoteAgent.App.Tests/MobileHandlerTests.cs`) |
+| TR-22.3 | None | None |
+| TR-22.4 | None | None |
+| TR-22.5 | Covered | `MobileHandlerTests.*` (`tests/RemoteAgent.App.Tests/MobileHandlerTests.cs`) |
diff --git a/docs/technical-requirements.md b/docs/technical-requirements.md
index a20b775..f877ae6 100644
--- a/docs/technical-requirements.md
+++ b/docs/technical-requirements.md
@@ -65,7 +65,7 @@
- **TR-5.4** When the app receives a message with **notify** priority, it shall **show a system notification** (e.g. on Android, using a notification channel and `NotificationManager`/`NotificationCompat`); tapping the notification shall open the app so the message is visible in the chat.
- **TR-5.5** The app shall support **swipe gestures** (e.g. left or right) on a message to **archive** it; archived messages shall be hidden from the visible list (e.g. via a property on the message and binding or filtering).
- **TR-5.6** The chat input control shall be multi-line (editor semantics), preserving newline characters in submitted requests.
-- **TR-5.7** Desktop keyboard handling shall support **Ctrl+Enter** as the submit accelerator while plain Enter inserts a newline.
+- **TR-5.7** Desktop keyboard handling shall support **Ctrl+Enter** as the submit accelerator while plain Enter inserts a newline. Mobile keyboard handling shall use **Enter** as the submit accelerator.
- **TR-5.8** Mobile UX shall use a connection-first screen (host/port/session connect) and toggle to the chat workspace only after successful connection state.
*See:* [FR-1](functional-requirements.md#1-product-purpose), [FR-2](functional-requirements.md#2-chat-and-messaging), [FR-3](functional-requirements.md#3-message-priority-and-notifications), [FR-4](functional-requirements.md#4-archive).
@@ -236,3 +236,54 @@
- **TR-18.4** UI tests shall validate known use cases by substituting mocked CQRS handlers and asserting rendered UI state changes, status messages, and command completion/error behavior.
*See:* [FR-12](functional-requirements.md#12-desktop-management-app), [FR-2](functional-requirements.md#2-chat-and-messaging), [TR-8](#8-testing), [TR-14](#14-desktop-management-capabilities).
+
+---
+
+## 19. Device pairing and API key management
+
+- **TR-19.1** The service shall expose a `SetPairingUsers` gRPC RPC that accepts username/password pairs, **hashes passwords with SHA-256**, generates a **32-byte cryptographically random hex API key**, writes both credentials and key to `appsettings.json`, and returns the generated key in the response (`SetPairingUsersResponse.generated_api_key`).
+- **TR-19.2** The `/pair` web endpoints shall use **`IOptionsMonitor.CurrentValue`** (not `IOptions`) so that configuration changes from `SetPairingUsers` are reflected immediately without service restart.
+- **TR-19.3** The service shall listen on a **secondary HTTP/1+HTTP/2 web port** (computed as `"1" + gRPC port`, e.g. 15244) for browser-accessible endpoints including the `/pair` login and `/pair/key` pages.
+- **TR-19.4** The `/pair/key` page shall render the API key, a QR code generated **server-side** via `QRCoder.PngByteQRCode` embedded as a `data:image/png;base64,…` `
` tag (no external CDN or JavaScript dependency), and a deep-link anchor (``).
+- **TR-19.5** The deep-link URI format shall be `remoteagent://pair?key={apiKey}&host={serverHost}&port={gRPCport}` where `port` is the **gRPC port** (not the web port) and all values are URI-escaped.
+- **TR-19.6** The mobile app shall implement the Login flow via a modal **`PairLoginPage`** containing a `WebView` that loads `http://{host}:1{port}/pair`; on navigation to `/pair/key`, the page shall execute `document.querySelector('a.btn')?.getAttribute('href')` via `EvaluateJavaScriptAsync` to extract the deep-link URI, strip any JSON-quoting artifacts from Android's WebView, resolve a `TaskCompletionSource`, and dismiss the modal.
+- **TR-19.7** The `ScanQrCodeHandler` shall validate that a host is configured before building the login URL, construct the web port as `"1" + gRPC port`, and parse the returned `remoteagent://` URI to populate `Host`, `Port`, and `ApiKey` on the view model.
+- **TR-19.8** The desktop `SetPairingUsersAsync` client method shall return `Task` (the generated API key) rather than `Task`, and the handler shall apply the returned key to the workspace immediately.
+- **TR-19.9** The `IQrCodeScanner` abstraction shall accept a `loginUrl` parameter (`Task ScanAsync(string loginUrl)`) to support the web-based login flow instead of camera-based QR scanning.
+
+*See:* [FR-17](functional-requirements.md#17-device-pairing-and-api-key-management).
+
+---
+
+## 20. Desktop UX refinements
+
+- **TR-20.1** All `TextBlock` controls in Avalonia desktop `.axaml` view files shall be replaced with **`SelectableTextBlock`** to enable text selection and copy.
+- **TR-20.2** A **global Avalonia style** in `App.axaml` shall apply a **4px margin** to `Button`, `TextBox`, `ComboBox`, `CheckBox`, `ListBox`, `SelectableTextBlock`, and `TabControl` controls.
+
+*See:* [FR-18](functional-requirements.md#18-desktop-ux-refinements).
+
+---
+
+## 21. Server profiles and persistent connection settings
+
+- **TR-21.1** A shared `ServerProfile` model and `IServerProfileStore` interface shall be defined in `RemoteAgent.App.Logic` so both mobile and desktop can consume the same contract.
+- **TR-21.2** The mobile app shall implement `IServerProfileStore` using **LiteDB** with a unique index on `host:port` (lowercased). CRUD operations: `GetAll`, `GetByHostPort`, `Upsert`, `Delete`.
+- **TR-21.3** The desktop `ServerRegistration` model shall include `PerRequestContext` and `DefaultSessionContext` string properties, persisted by `LiteDbServerRegistrationStore.Upsert`.
+- **TR-21.4** `ConnectMobileSessionHandler` shall **auto-save** a `ServerProfile` on successful connection and load the saved `PerRequestContext` into the workspace when the workspace value is empty.
+- **TR-21.5** The desktop `ServerWorkspaceViewModel` shall load `PerRequestContext` from the `ServerRegistration` during construction.
+- **TR-21.6** The desktop `SaveServerRegistrationRequest` and `SaveServerRegistrationHandler` shall accept and persist `PerRequestContext` and `DefaultSessionContext`.
+- **TR-21.7** The mobile `SettingsPage` shall be backed by a `SettingsPageViewModel` (DI-registered) that exposes the profile list with save and delete commands.
+
+*See:* [FR-19](functional-requirements.md#19-server-profiles-and-persistent-connection-settings).
+
+---
+
+## 22. Mobile chat UX
+
+- **TR-22.1** On Android, the native `AppCompatEditText` platform view of the message `Editor` shall intercept `Keycode.Enter` on `KeyEventActions.Down` and dispatch `SendMessageCommand`, preventing newline insertion.
+- **TR-22.2** The `IConnectionModeSelector` interface and `MauiConnectionModeSelector` implementation shall be removed; `ConnectMobileSessionHandler` shall hardcode `ConnectionMode = "server"`.
+- **TR-22.3** `ISessionCommandBus` shall expose `IsConnected` (bool) and `ConnectionStateChanged` (event) so `AppShellViewModel` can bind flyout item visibility to connection state.
+- **TR-22.4** The connection card in `MainPage.xaml` shall bind `IsVisible` to `IsConnected` via `InverseBool` converter, hiding it once connected.
+- **TR-22.5** `ServerApiClient` shall use `ExecuteGrpcAsync` for all 12 server API methods; no `HttpClient`-based REST calls shall remain.
+
+*See:* [FR-20](functional-requirements.md#20-mobile-chat-ux).
diff --git a/docs/toc.yml b/docs/toc.yml
index 8dd8148..c1678d8 100644
--- a/docs/toc.yml
+++ b/docs/toc.yml
@@ -17,6 +17,8 @@
href: REPOSITORY_RULES.md
- name: Branch protection
href: branch-protection.md
+ - name: FAQ
+ href: faq.md
- name: Testing
href: testing-strategy.md
diff --git a/msix.yml b/msix.yml
new file mode 100644
index 0000000..ed7c812
--- /dev/null
+++ b/msix.yml
@@ -0,0 +1,47 @@
+# msix.yml — MSIX package configuration for Remote Agent
+# Used by the MsixTools PowerShell module (scripts/MsixTools).
+#
+# Usage:
+# .\scripts\package-msix.ps1 [-Clean] [-DevCert] [-Force] [-Install]
+# .\scripts\package-msix.ps1 -BumpPatch -Clean -Force -Install
+#
+# Any value here can be overridden by passing the corresponding parameter
+# directly to package-msix.ps1 or New-MsixPackage.
+
+package:
+ name: RemoteAgent
+ displayName: Remote Agent
+ publisher: CN=RemoteAgent Dev
+
+service:
+ path: src/RemoteAgent.Service/RemoteAgent.Service.csproj
+ framework: net10.0
+ serviceName: RemoteAgentService
+ displayName: Remote Agent Service
+ subDir: service
+ startAccount: localSystem
+ startupType: auto
+
+desktop:
+ path: src/RemoteAgent.Desktop/RemoteAgent.Desktop.csproj
+ framework: net9.0
+ subDir: desktop
+ displayName: Remote Agent Desktop
+ appId: RemoteAgentApp
+ processName: RemoteAgent.Desktop # process name to kill before update
+
+plugins:
+ - path: src/RemoteAgent.Plugins.Ollama/RemoteAgent.Plugins.Ollama.csproj
+ framework: net10.0
+ destSubDir: service/plugins
+
+build:
+ configuration: Release
+ rid: win-x64
+ selfContained: true
+
+output:
+ dir: artifacts
+
+icons:
+ svg: src/RemoteAgent.Desktop/Assets/AppIcon/appicon.svg
diff --git a/scripts/MsixTools b/scripts/MsixTools
new file mode 160000
index 0000000..16ffb08
--- /dev/null
+++ b/scripts/MsixTools
@@ -0,0 +1 @@
+Subproject commit 16ffb08e461dd27341be909ef0dd9dfca2ed02e7
diff --git a/scripts/build-msix.ps1 b/scripts/build-msix.ps1
index 34702c7..1e01d05 100644
--- a/scripts/build-msix.ps1
+++ b/scripts/build-msix.ps1
@@ -1,100 +1,60 @@
#Requires -Version 7.0
<#
.SYNOPSIS
- Build the Remote Agent MSIX package and optionally install it.
+ Convenience script to build and optionally install the Remote Agent MSIX.
.DESCRIPTION
- Convenience wrapper around scripts/package-msix.ps1.
- Publishes the service (net10.0) and desktop app (net9.0), packs a combined
- MSIX, signs it with a self-signed dev certificate, and — when -Install is
- specified — installs the package and starts the Windows service.
-
- Equivalent to:
- .\scripts\package-msix.ps1 -Configuration -DevCert [-SelfContained]
- .\scripts\install-remote-agent.ps1 -CertPath artifacts\remote-agent-dev.cer (if -Install)
+ Simplified wrapper around New-MsixPackage (scripts/MsixTools).
+ For full control use package-msix.ps1.
.PARAMETER Configuration
Build configuration: Release (default) or Debug.
.PARAMETER SelfContained
- Bundle the .NET runtime inside the package (no runtime prereq on the target
- machine). Results in a larger MSIX. Default: false.
+ Bundle the .NET runtime. Default: true.
.PARAMETER Install
- After building, install the MSIX on this machine and start the service.
- Requires the script to be run as Administrator.
+ Install the MSIX and start the service after packaging. Requires Administrator.
.PARAMETER NoCert
- Skip self-signed certificate creation and produce an unsigned package.
- The package can still be installed locally with Add-AppxPackage -AllowUnsigned.
+ Skip signing (auto-signed on install by Install-MsixPackage).
-.EXAMPLE
- # Build a signed dev package (Release):
- .\scripts\build-msix.ps1
+.PARAMETER NoBuild
+ Skip dotnet publish; repackage existing artifacts\publish-* output.
- # Build Debug, bundle runtime:
- .\scripts\build-msix.ps1 -Configuration Debug -SelfContained
+.PARAMETER Force
+ Skip the AppxManifest review pause.
- # Build and install on this machine:
+.EXAMPLE
+ .\scripts\build-msix.ps1
.\scripts\build-msix.ps1 -Install
-
- # Build unsigned (no cert):
- .\scripts\build-msix.ps1 -NoCert
+ .\scripts\build-msix.ps1 -NoBuild -Force
#>
[CmdletBinding()]
param(
- [ValidateSet("Debug", "Release")]
- [string] $Configuration = "Release",
-
- [switch] $SelfContained,
-
+ [ValidateSet('Debug', 'Release')]
+ [string] $Configuration = 'Release',
+ [bool] $SelfContained = $true,
[switch] $Install,
-
- [switch] $NoCert
+ [switch] $NoCert,
+ [switch] $NoBuild,
+ [switch] $Force
)
-$ErrorActionPreference = "Stop"
-$ScriptDir = $PSScriptRoot
-$RepoRoot = (Get-Item $ScriptDir).Parent.FullName
-$ArtifactsDir = Join-Path $RepoRoot "artifacts"
+$ErrorActionPreference = 'Stop'
+$RepoRoot = (Get-Item $PSScriptRoot).Parent.FullName
+Import-Module (Join-Path $PSScriptRoot 'MsixTools\MsixTools.psd1') -Force
-# ── Validate ──────────────────────────────────────────────────────────────────
-if ($Install -and -not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
- [Security.Principal.WindowsBuiltInRole]::Administrator)) {
- Write-Error "-Install requires the script to be run as Administrator. Re-run from an elevated prompt."
-}
-
-# ── Build the MSIX ────────────────────────────────────────────────────────────
-Write-Host "[build-msix] configuration : $Configuration"
-Write-Host "[build-msix] self-contained: $SelfContained"
-Write-Host "[build-msix] sign (dev cert): $(-not $NoCert)"
-Write-Host ""
-
-$packageParams = @{
+$params = @{
+ WorkspaceRoot = $RepoRoot
+ ConfigPath = Join-Path $RepoRoot 'msix.yml'
Configuration = $Configuration
- OutDir = $ArtifactsDir
-}
-if ($SelfContained) { $packageParams["SelfContained"] = $true }
-if (-not $NoCert) { $packageParams["DevCert"] = $true }
-
-& "$ScriptDir\package-msix.ps1" @packageParams
-if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
-
-# ── Install (optional) ────────────────────────────────────────────────────────
-if ($Install) {
- Write-Host ""
- Write-Host "[build-msix] installing package and starting service..."
-
- $installParams = @{}
- $cerPath = Join-Path $ArtifactsDir "remote-agent-dev.cer"
- if (-not $NoCert -and (Test-Path $cerPath)) {
- $installParams["CertPath"] = $cerPath
- }
-
- & "$ScriptDir\install-remote-agent.ps1" @installParams
- if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ SelfContained = $SelfContained
+ NoBuild = $NoBuild
+ Force = $Force
+ Install = $Install
}
+if (-not $NoCert) { $params['DevCert'] = $true }
-Write-Host ""
-Write-Host "[build-msix] done."
+New-MsixPackage @params
diff --git a/scripts/bump-minor-version.sh b/scripts/bump-minor-version.sh
new file mode 100644
index 0000000..a03ec3e
--- /dev/null
+++ b/scripts/bump-minor-version.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+# bump-minor-version.sh
+# Increments the minor version in GitVersion.yml and resets the patch to 0.
+# Usage: ./scripts/bump-minor-version.sh
+
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+GV_FILE="$REPO_ROOT/GitVersion.yml"
+
+# Extract current next-version value (e.g. "0.1.0")
+current=$(grep -E '^next-version:' "$GV_FILE" | sed 's/next-version:[[:space:]]*//')
+
+IFS='.' read -r major minor patch <<< "$current"
+
+if [[ -z "$major" || -z "$minor" || -z "$patch" ]]; then
+ echo "error: could not parse next-version '$current' from $GV_FILE" >&2
+ exit 1
+fi
+
+new_version="$major.$((minor + 1)).0"
+
+sed -i "s/^next-version:.*/next-version: $new_version/" "$GV_FILE"
+
+echo "Bumped next-version: $current -> $new_version"
diff --git a/scripts/bump-patch-version.sh b/scripts/bump-patch-version.sh
new file mode 100644
index 0000000..f44aa7c
--- /dev/null
+++ b/scripts/bump-patch-version.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+# bump-patch-version.sh
+# Increments the patch version in GitVersion.yml.
+# Usage: ./scripts/bump-patch-version.sh
+
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+GV_FILE="$REPO_ROOT/GitVersion.yml"
+
+# Extract current next-version value (e.g. "0.1.0")
+current=$(grep -E '^next-version:' "$GV_FILE" | sed 's/next-version:[[:space:]]*//')
+
+IFS='.' read -r major minor patch <<< "$current"
+
+if [[ -z "$major" || -z "$minor" || -z "$patch" ]]; then
+ echo "error: could not parse next-version '$current' from $GV_FILE" >&2
+ exit 1
+fi
+
+new_version="$major.$minor.$((patch + 1))"
+
+sed -i "s/^next-version:.*/next-version: $new_version/" "$GV_FILE"
+
+echo "Bumped next-version: $current -> $new_version"
diff --git a/scripts/generate-requirements-matrix.sh b/scripts/generate-requirements-matrix.sh
new file mode 100755
index 0000000..3b3a445
--- /dev/null
+++ b/scripts/generate-requirements-matrix.sh
@@ -0,0 +1,233 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Script to generate requirements-test-coverage.md from test method annotations
+# This script scans all test files for [Trait("Requirement", "FR-X.X")] and [Trait("Requirement", "TR-X.X")]
+# annotations and generates a traceability matrix
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+TESTS_DIR="$REPO_ROOT/tests"
+OUTPUT_FILE="$REPO_ROOT/docs/requirements-test-coverage.md"
+FR_FILE="$REPO_ROOT/docs/functional-requirements.md"
+TR_FILE="$REPO_ROOT/docs/technical-requirements.md"
+
+echo "Scanning test files for requirement annotations..."
+
+# Temporary file for storing requirement mappings
+TEMP_FR_MAP=$(mktemp)
+TEMP_TR_MAP=$(mktemp)
+trap 'rm -f "$TEMP_FR_MAP" "$TEMP_TR_MAP"' EXIT
+
+# Extract all FR and TR IDs from requirements files
+extract_requirement_ids() {
+ local req_file="$1"
+ local prefix="$2"
+ # Match patterns like "- **FR-1.1**" or "- **TR-1.1**"
+ grep -oP "(?<=\*\*${prefix}-)[0-9]+(\.[0-9]+)*(?=\*\*)" "$req_file" 2>/dev/null || true
+}
+
+echo "Extracting requirement IDs from requirement documents..."
+FR_IDS=$(extract_requirement_ids "$FR_FILE" "FR" | sort -V | uniq)
+TR_IDS=$(extract_requirement_ids "$TR_FILE" "TR" | sort -V | uniq)
+
+# Function to scan test files and extract requirement mappings
+scan_test_files() {
+ local req_prefix="$1"
+ local output_map="$2"
+
+ # Find all test files and process them
+ local test_files=$(find "$TESTS_DIR" -name "*Tests*.cs" -type f)
+ for test_file in $test_files; do
+ local rel_path="${test_file#$REPO_ROOT/}"
+
+ # Read file with awk to better handle state
+ awk -v prefix="$req_prefix" -v outfile="$output_map" -v filepath="$rel_path" '
+ BEGIN {
+ in_class = 0
+ is_test_method = 0
+ }
+
+ # Match class-level Trait annotations (before class declaration)
+ !in_class && /\[Trait\("Requirement", "[A-Z]+-[0-9.]+"\)/ {
+ req_id = ""
+ if (match($0, /"[A-Z]+-[0-9.]+"/)) {
+ req_id = substr($0, RSTART + 1, RLENGTH - 2)
+ }
+ if (index(req_id, prefix) == 1) {
+ class_reqs[++class_req_count] = req_id
+ }
+ }
+
+ # Match class declaration
+ /public class [a-zA-Z0-9_]+/ {
+ if (match($0, /public class [a-zA-Z0-9_]+/)) {
+ class_name = substr($0, RSTART + 13, RLENGTH - 13)
+ in_class = 1
+ }
+ }
+
+ # Match method-level Trait annotations (inside class)
+ in_class && /\[Trait\("Requirement", "[A-Z]+-[0-9.]+"\)/ {
+ req_id = ""
+ if (match($0, /"[A-Z]+-[0-9.]+"/)) {
+ req_id = substr($0, RSTART + 1, RLENGTH - 2)
+ }
+ if (index(req_id, prefix) == 1) {
+ method_reqs[++method_req_count] = req_id
+ }
+ }
+
+ # Match test attributes
+ in_class && /\[(Fact|Theory)\]/ {
+ is_test_method = 1
+ }
+
+ # Match method declaration
+ in_class && /public (async )?(Task|void) [a-zA-Z0-9_]+\(/ {
+ if (match($0, /public (async )?(Task|void) [a-zA-Z0-9_]+\(/)) {
+ method_name = substr($0, RSTART, RLENGTH)
+ sub(/^public (async )?(Task|void) /, "", method_name)
+ sub(/\($/, "", method_name)
+ } else {
+ method_name = ""
+ }
+
+ if (is_test_method) {
+ # Use method-level requirements if available, otherwise class-level
+ if (method_req_count > 0) {
+ for (i = 1; i <= method_req_count; i++) {
+ print method_reqs[i] "|" class_name "." method_name "|" filepath "|method" >> outfile
+ }
+ } else if (class_req_count > 0) {
+ for (i = 1; i <= class_req_count; i++) {
+ print class_reqs[i] "|" class_name "." method_name "|" filepath "|class" >> outfile
+ }
+ }
+ }
+
+ # Reset method-level state
+ method_req_count = 0
+ delete method_reqs
+ is_test_method = 0
+ }
+ ' "$test_file"
+ done
+}
+
+echo "Scanning for FR requirements..."
+scan_test_files "FR" "$TEMP_FR_MAP"
+
+echo "Scanning for TR requirements..."
+scan_test_files "TR" "$TEMP_TR_MAP"
+
+# Function to generate coverage row for a requirement
+generate_coverage_row() {
+ local req_id="$1"
+ local map_file="$2"
+
+ # Find all tests covering this requirement
+ local tests=$(grep "^${req_id}|" "$map_file" 2>/dev/null | sort -u || true)
+
+ if [[ -z "$tests" ]]; then
+ echo "| $req_id | None | None |"
+ else
+ # Determine coverage level
+ local coverage="Covered"
+
+ # Group by class to determine if we should use ClassName.* notation
+ declare -A class_methods
+ declare -A class_files
+ declare -A method_specific
+
+ while IFS='|' read -r req method file source_type; do
+ [[ -z "$method" ]] && continue
+ local class="${method%%.*}"
+ local method_name="${method#*.}"
+
+ if [[ "$source_type" == "method" ]]; then
+ # Method has specific requirements
+ method_specific["$class|$method"]="$file"
+ else
+ # Method inherits class requirements
+ if [[ -z "${class_methods[$class]:-}" ]]; then
+ class_methods["$class"]="$method_name"
+ else
+ class_methods["$class"]="${class_methods[$class]} $method_name"
+ fi
+ class_files["$class"]="$file"
+ fi
+ done <<< "$tests"
+
+ # Format tests list
+ local tests_formatted=""
+
+ # First, output classes with all methods (using .*)
+ for class in "${!class_methods[@]}"; do
+ if [[ -n "$tests_formatted" ]]; then
+ tests_formatted="${tests_formatted}, "
+ fi
+ tests_formatted="${tests_formatted}\`${class}.*\` (\`${class_files[$class]}\`)"
+ done
+
+ # Then, output individual methods with specific requirements
+ for key in "${!method_specific[@]}"; do
+ local method="${key#*|}"
+ local file="${method_specific[$key]}"
+ if [[ -n "$tests_formatted" ]]; then
+ tests_formatted="${tests_formatted}, "
+ fi
+ tests_formatted="${tests_formatted}\`${method}\` (\`${file}\`)"
+ done
+
+ echo "| $req_id | $coverage | $tests_formatted |"
+ fi
+}
+
+echo "Generating requirements-test-coverage.md..."
+
+# Generate the output file
+{
+ echo "# Requirements Test Coverage"
+ echo ""
+ echo "This document maps every requirement ID in \`docs/functional-requirements.md\` (FR) and \`docs/technical-requirements.md\` (TR) to current automated test coverage."
+ echo ""
+ echo "**Note:** This file is auto-generated by \`scripts/generate-requirements-matrix.sh\`. Do not edit manually."
+ echo ""
+ echo "Coverage status values:"
+ echo "- \`Covered\`: direct automated test coverage exists."
+ echo "- \`Partial\`: some behavior is covered, but not the full requirement scope."
+ echo "- \`None\`: no automated test currently covers the requirement."
+ echo ""
+ echo "## Functional Requirements (FR)"
+ echo ""
+ echo "| Requirement | Coverage | Tests |"
+ echo "|---|---|---|"
+
+ # Generate FR rows
+ while IFS= read -r fr_id; do
+ if [[ -n "$fr_id" ]]; then
+ generate_coverage_row "FR-${fr_id}" "$TEMP_FR_MAP"
+ fi
+ done <<< "$FR_IDS"
+
+ echo ""
+ echo "## Technical Requirements (TR)"
+ echo ""
+ echo "| Requirement | Coverage | Tests |"
+ echo "|---|---|---|"
+
+ # Generate TR rows
+ while IFS= read -r tr_id; do
+ if [[ -n "$tr_id" ]]; then
+ generate_coverage_row "TR-${tr_id}" "$TEMP_TR_MAP"
+ fi
+ done <<< "$TR_IDS"
+
+} > "$OUTPUT_FILE"
+
+echo "✅ Generated $OUTPUT_FILE"
+echo "Total FR IDs: $(echo "$FR_IDS" | grep -c . || echo 0)"
+echo "Total TR IDs: $(echo "$TR_IDS" | grep -c . || echo 0)"
+echo "FR mappings: $(wc -l < "$TEMP_FR_MAP")"
+echo "TR mappings: $(wc -l < "$TEMP_TR_MAP")"
diff --git a/scripts/install-remote-agent.ps1 b/scripts/install-remote-agent.ps1
index dea5da3..001d6ef 100644
--- a/scripts/install-remote-agent.ps1
+++ b/scripts/install-remote-agent.ps1
@@ -2,46 +2,43 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
- Install the Remote Agent MSIX package and immediately start the Windows service.
+ Install (or uninstall) the Remote Agent MSIX package and manage the Windows service.
.DESCRIPTION
- 1. Optionally trusts the signing certificate (required for self-signed dev builds).
- 2. Installs the MSIX package via Add-AppxPackage (or updates an existing install).
- 3. Polls the Windows SCM until RemoteAgentService is registered (up to -TimeoutSeconds).
- 4. Starts the service and reports its final status.
+ Wrapper around the MsixTools module's Install-MsixPackage / Uninstall-MsixPackage functions.
+ Package, service, and path configuration is read from msix.yml in the repository root.
- The service is registered by the MSIX windows.service extension with StartupType=auto,
- so it will also start automatically on every subsequent boot.
-
- Run as Administrator — Add-AppxPackage with a service extension requires elevation.
+ Install flow:
+ 1. Optionally trust the signing certificate (-CertPath).
+ 2. Auto-sign unsigned packages with a self-signed dev cert if needed.
+ 3. Stop RemoteAgentService and close RemoteAgent.Desktop before updating.
+ 4. Remove any existing version, then fresh-install (avoids HRESULT 0x80073CFB).
+ 5. Wait for the service to register in the SCM and start it.
.PARAMETER MsixPath
- Path to the .msix file. Defaults to the most recent remote-agent_*.msix in
- \artifacts\.
+ Path to the .msix file. Defaults to the most recent remote-agent_*.msix in artifacts\.
.PARAMETER CertPath
- Path to a .cer file to trust before installing (required for self-signed dev packages).
- Imports the certificate into Cert:\LocalMachine\Root.
+ Path to a .cer file to trust in Cert:\LocalMachine\Root before installing.
.PARAMETER TimeoutSeconds
- Seconds to wait for the service to appear in the SCM after package install. Default: 30.
+ Seconds to wait for RemoteAgentService to register in the SCM after install. Default: 30.
.PARAMETER Uninstall
- Remove the installed package and stop the service instead of installing.
+ Stop the service and remove the installed package instead of installing.
.EXAMPLE
- # Build then install (dev machine):
- .\scripts\package-msix.ps1 -DevCert
- .\scripts\install-remote-agent.ps1 -CertPath artifacts\remote-agent-dev.cer
+ # Build then install:
+ .\scripts\package-msix.ps1 -DevCert -Force -Install
- # Install a release-signed package:
- .\scripts\install-remote-agent.ps1 -MsixPath artifacts\remote-agent_2.1.0_x64.msix
+ # Install a specific package:
+ .\scripts\install-remote-agent.ps1 -MsixPath artifacts\remote-agent_0.1.0_x64.msix
# Uninstall:
.\scripts\install-remote-agent.ps1 -Uninstall
#>
-[CmdletBinding(SupportsShouldProcess)]
+[CmdletBinding()]
param(
[string] $MsixPath = "",
[string] $CertPath = "",
@@ -50,119 +47,21 @@ param(
)
$ErrorActionPreference = "Stop"
-$RepoRoot = (Get-Item $PSScriptRoot).Parent.FullName
-$ArtifactsDir = Join-Path $RepoRoot "artifacts"
-$ServiceName = "RemoteAgentService"
-$PackageFamilyPattern = "RemoteAgent*"
+$RepoRoot = (Get-Item $PSScriptRoot).Parent.FullName
+Import-Module (Join-Path $PSScriptRoot "MsixTools\MsixTools.psd1") -Force
-# ── Uninstall path ────────────────────────────────────────────────────────────
-if ($Uninstall) {
- Write-Host "[install] Stopping service '$ServiceName'..."
- $svc = Get-Service $ServiceName -ErrorAction SilentlyContinue
- if ($svc -and $svc.Status -ne "Stopped") {
- Stop-Service $ServiceName -Force
- Write-Host "[install] Service stopped."
- } else {
- Write-Host "[install] Service not running or not found."
- }
+$configPath = Join-Path $RepoRoot "msix.yml"
- Write-Host "[install] Removing MSIX package..."
- $pkg = Get-AppxPackage -Name "RemoteAgent" -ErrorAction SilentlyContinue
- if ($pkg) {
- Remove-AppxPackage -Package $pkg.PackageFullName
- Write-Host "[install] Package removed: $($pkg.PackageFullName)"
- } else {
- Write-Host "[install] No installed RemoteAgent package found."
- }
+if ($Uninstall) {
+ Uninstall-MsixPackage -ConfigPath $configPath
exit 0
}
-# ── Resolve MSIX path ─────────────────────────────────────────────────────────
-if (-not $MsixPath) {
- $candidates = Get-ChildItem $ArtifactsDir -Filter "remote-agent_*.msix" -ErrorAction SilentlyContinue |
- Sort-Object LastWriteTime -Descending
- if (-not $candidates) {
- Write-Error "No remote-agent_*.msix found in $ArtifactsDir. Run scripts\package-msix.ps1 first."
- }
- $MsixPath = $candidates[0].FullName
- Write-Host "[install] Using most recent package: $MsixPath"
-}
-
-if (-not (Test-Path $MsixPath)) {
- Write-Error "MSIX file not found: $MsixPath"
-}
-
-# ── Trust signing certificate ─────────────────────────────────────────────────
-if ($CertPath) {
- if (-not (Test-Path $CertPath)) {
- Write-Error "Certificate file not found: $CertPath"
- }
- Write-Host "[install] Importing certificate into Cert:\LocalMachine\Root..."
- Import-Certificate -FilePath $CertPath -CertStoreLocation Cert:\LocalMachine\Root | Out-Null
- Write-Host "[install] Certificate trusted."
-}
-
-# ── Install or update the MSIX ────────────────────────────────────────────────
-$existing = Get-AppxPackage -Name "RemoteAgent" -ErrorAction SilentlyContinue
-if ($existing) {
- Write-Host "[install] Updating existing package ($($existing.Version) -> installing)..."
- Add-AppxPackage -Path $MsixPath -ForceUpdateFromAnyVersion
-} else {
- Write-Host "[install] Installing package..."
- Add-AppxPackage -Path $MsixPath
-}
-Write-Host "[install] Package installed."
-
-# ── Wait for the service to be registered in the SCM ─────────────────────────
-# The MSIX windows.service extension registers the service asynchronously after
-# the package install completes; poll until it appears or the timeout expires.
-Write-Host "[install] Waiting for '$ServiceName' to be registered in the SCM..."
-$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
-$svc = $null
-while ((Get-Date) -lt $deadline) {
- $svc = Get-Service $ServiceName -ErrorAction SilentlyContinue
- if ($svc) { break }
- Start-Sleep -Milliseconds 500
-}
-
-if (-not $svc) {
- Write-Error @"
-Timed out after ${TimeoutSeconds}s waiting for '$ServiceName' to appear in the SCM.
-The MSIX package installed successfully but the service extension may not have registered yet.
-Try starting manually: Start-Service $ServiceName
-"@
-}
-
-Write-Host "[install] Service registered (status: $($svc.Status))."
-
-# ── Start the service ─────────────────────────────────────────────────────────
-if ($svc.Status -eq "Running") {
- Write-Host "[install] Service is already running."
-} else {
- Write-Host "[install] Starting '$ServiceName'..."
- Start-Service $ServiceName
- # Give it a moment and re-query.
- Start-Sleep -Seconds 2
- $svc = Get-Service $ServiceName
- Write-Host "[install] Service status: $($svc.Status)"
- if ($svc.Status -ne "Running") {
- Write-Warning "Service did not reach Running state. Check Event Viewer for details."
- }
+$params = @{
+ ConfigPath = $configPath
+ TimeoutSeconds = $TimeoutSeconds
}
+if ($MsixPath) { $params['MsixPath'] = $MsixPath }
+if ($CertPath) { $params['CertPath'] = $CertPath }
-# ── Summary ───────────────────────────────────────────────────────────────────
-$pkg = Get-AppxPackage -Name "RemoteAgent"
-Write-Host ""
-Write-Host "── Remote Agent installed ───────────────────────────────────────────────"
-Write-Host " Package : $($pkg.PackageFullName)"
-Write-Host " Version : $($pkg.Version)"
-Write-Host " Service : $ServiceName [$($svc.Status)] (StartType: Automatic)"
-Write-Host ""
-Write-Host " Service management:"
-Write-Host " Stop-Service $ServiceName"
-Write-Host " Start-Service $ServiceName"
-Write-Host " Restart-Service $ServiceName"
-Write-Host ""
-Write-Host " Uninstall:"
-Write-Host " .\scripts\install-remote-agent.ps1 -Uninstall"
-Write-Host "────────────────────────────────────────────────────────────────────────"
+Install-MsixPackage @params
diff --git a/scripts/monitor-service-windows.ps1 b/scripts/monitor-service-windows.ps1
new file mode 100644
index 0000000..cc000d4
--- /dev/null
+++ b/scripts/monitor-service-windows.ps1
@@ -0,0 +1,267 @@
+#Requires -Version 7.0
+<#
+.SYNOPSIS
+ Monitor the Remote Agent Windows Service — Event Log errors and optional file log tail.
+
+.DESCRIPTION
+ Watches the Windows Application Event Log for entries from the Remote Agent Service
+ source and streams new errors/warnings to the console. Optionally also tails the
+ structured file log written to Agent.LogDirectory.
+
+ Event IDs written by the service:
+ 1000 — service started successfully (Information)
+ 1001 — startup or runtime fatal error (Error)
+ 1002 — unhandled background-thread exception (Error)
+
+ Run without -Tail to print recent history only, then exit.
+ Run with -Tail to stream new events continuously until Ctrl+C.
+
+.PARAMETER Tail
+ Stream new events continuously (poll every -IntervalSeconds). Default: $false.
+
+.PARAMETER IntervalSeconds
+ Polling interval when -Tail is active. Default: 5.
+
+.PARAMETER Hours
+ How many hours of history to show on startup. Default: 24.
+
+.PARAMETER Level
+ Minimum severity to display: Error | Warning | Information. Default: Warning.
+
+.PARAMETER LogFile
+ Path to the service file log (Agent.LogDirectory\service.log).
+ When set, the file log is tailed in parallel with the Event Log.
+ If omitted the script auto-detects the path from the service registry.
+
+.PARAMETER ServiceName
+ Windows Service name. Default: "Remote Agent Service".
+
+.EXAMPLE
+ .\scripts\monitor-service-windows.ps1
+ .\scripts\monitor-service-windows.ps1 -Tail
+ .\scripts\monitor-service-windows.ps1 -Tail -Level Error -Hours 1
+ .\scripts\monitor-service-windows.ps1 -Tail -LogFile "C:\ProgramData\RemoteAgent\logs\service.log"
+#>
+[CmdletBinding()]
+param(
+ [switch] $Tail,
+ [int] $IntervalSeconds = 5,
+ [int] $Hours = 24,
+ [ValidateSet('Error','Warning','Information')]
+ [string] $Level = 'Warning',
+ [string] $LogFile = '',
+ [string] $ServiceName = 'Remote Agent Service'
+)
+
+$ErrorActionPreference = 'Stop'
+
+# ── Severity filter ───────────────────────────────────────────────────────────
+# Get-WinEvent Level values: 1=Critical, 2=Error, 3=Warning, 4=Information, 5=Verbose
+$levelOrder = @{ Information = 0; Warning = 1; Error = 2 }
+$minLevel = $levelOrder[$Level]
+
+# Map WinEvent Level numbers to display names and severity order.
+$winEventLevels = @{
+ 1 = @{ Name = 'Critical'; Order = 2 }
+ 2 = @{ Name = 'Error'; Order = 2 }
+ 3 = @{ Name = 'Warning'; Order = 1 }
+ 4 = @{ Name = 'Information'; Order = 0 }
+ 5 = @{ Name = 'Verbose'; Order = 0 }
+}
+
+function Get-EntryOrder([int]$level) {
+ $info = $winEventLevels[$level]
+ if ($null -eq $info) { return 0 }
+ return $info.Order
+}
+
+function Get-EntryName([int]$level) {
+ $info = $winEventLevels[$level]
+ if ($null -eq $info) { return 'Unknown' }
+ return $info.Name
+}
+
+function Test-SeverityIncluded([int]$level) {
+ return (Get-EntryOrder $level) -ge $minLevel
+}
+
+# ── Colour map ────────────────────────────────────────────────────────────────
+$colours = @{
+ Critical = 'Magenta'
+ Error = 'Red'
+ Warning = 'Yellow'
+ Information = 'Cyan'
+ Verbose = 'DarkGray'
+}
+
+function Write-EventEntry($entry) {
+ $name = Get-EntryName $entry.Level
+ $colour = $colours[$name] ?? 'Gray'
+ $ts = $entry.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss')
+ Write-Host "[$ts] [$name] EventId=$($entry.Id)" -ForegroundColor $colour
+ Write-Host $entry.Message -ForegroundColor $colour
+ Write-Host ''
+}
+
+# ── Service status ────────────────────────────────────────────────────────────
+Write-Host "── Remote Agent Service Monitor ────────────────────────────────" -ForegroundColor Cyan
+
+$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
+if ($null -eq $svc) {
+ # Try a partial-name search in case the service was registered under a slightly different name.
+ $svc = Get-Service -ErrorAction SilentlyContinue |
+ Where-Object { $_.DisplayName -like "*Remote Agent*" -or $_.Name -like "*RemoteAgent*" } |
+ Select-Object -First 1
+ if ($svc) {
+ Write-Host "Note: service '$ServiceName' not found; using '$($svc.Name)' instead." -ForegroundColor DarkYellow
+ $ServiceName = $svc.Name
+ }
+}
+
+if ($null -eq $svc) {
+ Write-Warning "Windows service '$ServiceName' is not installed."
+} else {
+ $statusColour = if ($svc.Status -eq 'Running') { 'Green' } else { 'Red' }
+ Write-Host "Service status : " -NoNewline
+ Write-Host $svc.Status -ForegroundColor $statusColour
+ Write-Host "Display name : $($svc.DisplayName)"
+}
+Write-Host ''
+
+# ── Auto-detect log file from service image path ──────────────────────────────
+if (-not $LogFile -and $svc) {
+ try {
+ $imagePath = (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\$ServiceName" `
+ -Name ImagePath -ErrorAction SilentlyContinue).ImagePath -replace '"',''
+ if ($imagePath) {
+ $installDir = Split-Path $imagePath -Parent
+ # Service defaults: Agent.LogDirectory resolves to %ProgramData%\RemoteAgent\logs or
+ # the directory next to the binary. Check both common locations.
+ $candidates = @(
+ "$env:ProgramData\RemoteAgent\logs\service.log",
+ (Join-Path $installDir 'logs\service.log'),
+ (Join-Path $installDir 'service.log')
+ )
+ foreach ($c in $candidates) {
+ if (Test-Path $c) { $LogFile = $c; break }
+ }
+ }
+ } catch { }
+}
+
+if ($LogFile) {
+ Write-Host "File log : $LogFile" -ForegroundColor DarkCyan
+} else {
+ Write-Host "File log : (not found — Event Log only)" -ForegroundColor DarkGray
+}
+Write-Host ''
+
+# ── Shared helper: query Application Event Log via Get-WinEvent ───────────────
+function Get-ServiceEvents([datetime]$after, [datetime]$before = [datetime]::MaxValue) {
+ $filter = @{
+ LogName = 'Application'
+ ProviderName = 'Remote Agent Service'
+ StartTime = $after
+ }
+ if ($before -ne [datetime]::MaxValue) { $filter['EndTime'] = $before }
+ try {
+ $events = Get-WinEvent -FilterHashtable $filter -ErrorAction Stop |
+ Where-Object { Test-SeverityIncluded $_.Level } |
+ Sort-Object TimeCreated
+ return $events
+ } catch [System.Exception] {
+ if ($_.Exception.Message -like '*No events were found*' -or
+ $_.Exception.HResult -eq -2147024809) {
+ return @() # no matching events — normal condition
+ }
+ throw
+ }
+}
+
+# ── Event Log: recent history ─────────────────────────────────────────────────
+$since = (Get-Date).AddHours(-$Hours)
+$eventLog = 'Application'
+$source = $ServiceName
+
+Write-Host "── Event Log history (last $Hours h, level >= $Level) ──────────────" -ForegroundColor Cyan
+
+try {
+ $entries = Get-ServiceEvents -after $since
+ if ($entries.Count -eq 0) {
+ Write-Host ' (no matching events)' -ForegroundColor DarkGray
+ } else {
+ foreach ($e in $entries) { Write-EventEntry $e }
+ }
+} catch {
+ Write-Warning "Could not read Event Log: $_"
+}
+
+Write-Host ''
+
+if (-not $Tail) {
+ Write-Host "Tip: run with -Tail to stream new events continuously." -ForegroundColor DarkGray
+ exit 0
+}
+
+# ── Continuous tail ───────────────────────────────────────────────────────────
+Write-Host "── Streaming new events (Ctrl+C to stop) ───────────────────────" -ForegroundColor Cyan
+Write-Host ''
+
+# Track the timestamp of the last event already shown.
+$lastEventTime = [datetime]::UtcNow
+
+# Track file log position.
+$fileLogStream = $null
+$fileLogReader = $null
+if ($LogFile -and (Test-Path $LogFile)) {
+ $fileLogStream = [System.IO.File]::Open($LogFile,
+ [System.IO.FileMode]::Open,
+ [System.IO.FileAccess]::Read,
+ [System.IO.FileShare]::ReadWrite)
+ $fileLogStream.Seek(0, [System.IO.SeekOrigin]::End) | Out-Null
+ $fileLogReader = [System.IO.StreamReader]::new($fileLogStream, [System.Text.Encoding]::UTF8, $true)
+}
+
+# Error keywords to highlight in the file log.
+$errorPattern = [regex]'(?i)fail|error|exception|crit|unhandled|warn'
+
+try {
+ while ($true) {
+ # ── Poll Event Log ────────────────────────────────────────────────────
+ try {
+ $newEntries = Get-ServiceEvents -after $lastEventTime
+ foreach ($e in $newEntries) {
+ Write-EventEntry $e
+ if ($e.TimeCreated -gt $lastEventTime) {
+ $lastEventTime = $e.TimeCreated
+ }
+ }
+ } catch { }
+
+ # ── Poll file log ─────────────────────────────────────────────────────
+ if ($fileLogReader) {
+ while (-not $fileLogReader.EndOfStream) {
+ $line = $fileLogReader.ReadLine()
+ if ($null -eq $line) { break }
+ if ($errorPattern.IsMatch($line)) {
+ $lineColour = if ($line -match '(?i)fail|error|exception|crit|unhandled') { 'Red' }
+ elseif ($line -match '(?i)warn') { 'Yellow' }
+ else { 'Gray' }
+ Write-Host "[filelog] $line" -ForegroundColor $lineColour
+ }
+ }
+ }
+
+ # ── Check service is still running ────────────────────────────────────
+ $svcNow = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
+ if ($svcNow -and $svcNow.Status -ne 'Running') {
+ Write-Host "[$(Get-Date -Format 'HH:mm:ss')] WARNING: service status changed to $($svcNow.Status)" `
+ -ForegroundColor Red
+ }
+
+ Start-Sleep -Seconds $IntervalSeconds
+ }
+} finally {
+ $fileLogReader?.Dispose()
+ $fileLogStream?.Dispose()
+}
diff --git a/scripts/package-deb.sh b/scripts/package-deb.sh
index 7ba9878..dcbeb0e 100644
--- a/scripts/package-deb.sh
+++ b/scripts/package-deb.sh
@@ -33,6 +33,7 @@
# /var/lib/remote-agent/ runtime data (LiteDB, media uploads)
# /var/log/remote-agent/ session log files
# /lib/systemd/system/ remote-agent.service unit
+# /usr/sbin/remote-agent-ctl service management script (start/stop/restart/status)
#
# Desktop install layout:
# /usr/lib/remote-agent/desktop/ application binaries
@@ -196,6 +197,11 @@ if [[ "$BUILD_SERVICE" == "true" ]]; then
cp -a "$SERVICE_PUBLISH/." "$SVC_DIR/usr/lib/remote-agent/service/"
chmod 755 "$SVC_DIR/usr/lib/remote-agent/service/RemoteAgent.Service"
+ # ── Management script ─────────────────────────────────────────────────────
+ install -d "$SVC_DIR/usr/sbin"
+ install -m 755 "$SCRIPT_DIR/remote-agent-ctl.sh" \
+ "$SVC_DIR/usr/sbin/remote-agent-ctl"
+
# ── Ollama plugin ─────────────────────────────────────────────────────────
install -d "$SVC_DIR/usr/lib/remote-agent/plugins"
cp "$PLUGIN_PUBLISH/RemoteAgent.Plugins.Ollama.dll" \
diff --git a/scripts/package-msix.ps1 b/scripts/package-msix.ps1
index 9cf861c..ba0c6f6 100644
--- a/scripts/package-msix.ps1
+++ b/scripts/package-msix.ps1
@@ -4,485 +4,112 @@
Build a combined MSIX package for the Remote Agent service and desktop management app.
.DESCRIPTION
- Publishes the service (net10.0, win-x64) and desktop app (net9.0, win-x64), assembles
- them into a single MSIX package, and optionally signs it.
-
- The resulting package installs both:
- - RemoteAgent.Service.exe, registered as a Windows service (RemoteAgentService) that
- starts automatically under LocalSystem via the MSIX windows.service extension.
- - RemoteAgent.Desktop.exe, visible in the Start menu as "Remote Agent Desktop".
-
- The Ollama plugin DLL is included in the service sub-directory when it has been built.
-
- Install layout inside the package:
- service\ service binaries + appsettings.json
- service\plugins\ Ollama plugin DLL (if built)
- desktop\ desktop app binaries
- Assets\ MSIX icon assets
-
- Requirements:
- - Windows SDK >= 10.0.19041 (makeappx.exe, signtool.exe)
- - .NET SDK 10.x (service + plugin) and 9.x (desktop; via src/RemoteAgent.Desktop/global.json)
-
- Signing:
- -DevCert Create a temporary self-signed certificate (development / CI testing).
- -CertThumbprint Use an existing certificate from the current user's My store.
- Neither flag Build an unsigned package (can be installed with Add-AppxPackage -AllowUnsigned).
-
- After building, install on the same machine:
- Add-AppxPackage -Path .\artifacts\remote-agent__x64.msix
-
- To trust a self-signed dev cert on the target machine before installing:
- $cert = (Get-PfxCertificate -FilePath artifacts\remote-agent-dev.cer)
- Import-Certificate -Certificate $cert -CertStoreLocation Cert:\LocalMachine\Root
+ Wrapper around New-MsixPackage (scripts/MsixTools). Reads all project and build
+ configuration from msix.yml in the repository root.
.PARAMETER Configuration
Build configuration: Release (default) or Debug.
.PARAMETER Version
- Package version (e.g. 1.2.3). Defaults to GitVersion SemVer, then most recent git tag, else 1.0.0.
+ Package version (e.g. 1.2.3). Auto-detected if omitted.
.PARAMETER Publisher
- MSIX Identity Publisher string, must match the signing certificate Subject exactly.
- Default: "CN=RemoteAgent Dev".
+ MSIX Identity Publisher string. Default: read from msix.yml.
.PARAMETER CertThumbprint
- SHA1 thumbprint of an existing certificate in Cert:\CurrentUser\My to sign with.
- Mutually exclusive with -DevCert.
+ SHA1 thumbprint of a certificate in Cert:\CurrentUser\My. Mutually exclusive with -DevCert.
.PARAMETER DevCert
- Create (or reuse) a self-signed certificate and sign the package with it.
- The public certificate is exported to \remote-agent-dev.cer.
+ Create/reuse a self-signed dev certificate. Mutually exclusive with -CertThumbprint.
.PARAMETER SelfContained
- Publish self-contained packages (bundles .NET runtime; no runtime prereq on target).
+ Publish self-contained single-file. Default: true.
.PARAMETER ServiceOnly
- Build and bundle only the service component; omit the desktop app.
+ Omit the desktop app from the package.
.PARAMETER DesktopOnly
- Build and bundle only the desktop component; omit the service and Windows service extension.
+ Omit the service and windows.service extension from the package.
-.PARAMETER OutDir
- Output directory for the .msix file. Default: \artifacts\.
+.PARAMETER Clean
+ Delete bin/ and obj/ before publishing. Mutually exclusive with -NoBuild.
-.EXAMPLE
- .\scripts\package-msix.ps1 -DevCert
- .\scripts\package-msix.ps1 -Configuration Release -DevCert -SelfContained
- .\scripts\package-msix.ps1 -CertThumbprint "ABC123DEF456..." -Version 2.1.0
-#>
+.PARAMETER NoBuild
+ Skip dotnet publish; repackage existing artifacts\publish-* output. Mutually exclusive with -Clean.
-[CmdletBinding()]
-param(
- [ValidateSet("Debug", "Release")]
- [string] $Configuration = "Release",
+.PARAMETER BumpMajor
+ Increment major version in GitVersion.yml before building. Mutually exclusive with -BumpMinor/-BumpPatch.
- [string] $Version = "",
+.PARAMETER BumpMinor
+ Increment minor version in GitVersion.yml before building. Mutually exclusive with -BumpMajor/-BumpPatch.
- [string] $Publisher = "CN=RemoteAgent Dev",
+.PARAMETER BumpPatch
+ Increment patch version in GitVersion.yml before building. Mutually exclusive with -BumpMajor/-BumpMinor.
- [string] $CertThumbprint = "",
+.PARAMETER Install
+ Install the MSIX and start the service after packaging. Requires Administrator.
- [switch] $DevCert,
+.PARAMETER Force
+ Skip the AppxManifest review pause.
- [switch] $SelfContained,
+.PARAMETER OutDir
+ Output directory. Default: \artifacts.
- [switch] $ServiceOnly,
+.EXAMPLE
+ .\scripts\package-msix.ps1 -DevCert -Force -Install
+ .\scripts\package-msix.ps1 -BumpPatch -Clean -Force -Install
+ .\scripts\package-msix.ps1 -NoBuild -Force
+#>
+[CmdletBinding()]
+param(
+ [ValidateSet('Debug', 'Release')]
+ [string] $Configuration = 'Release',
+ [string] $Version = '',
+ [string] $Publisher = '',
+ [string] $CertThumbprint = '',
+ [switch] $DevCert,
+ [bool] $SelfContained = $true,
+ [switch] $ServiceOnly,
[switch] $DesktopOnly,
-
- [string] $OutDir = ""
-)
-
-$ErrorActionPreference = "Stop"
-$RepoRoot = (Get-Item $PSScriptRoot).Parent.FullName
-$NuGetConfig = Join-Path $RepoRoot "NuGet.Config"
-if (-not $OutDir) { $OutDir = Join-Path $RepoRoot "artifacts" }
-
-# ── Validate mutually exclusive flags ────────────────────────────────────────
-if ($ServiceOnly -and $DesktopOnly) {
- Write-Error "-ServiceOnly and -DesktopOnly are mutually exclusive."
-}
-if ($DevCert -and $CertThumbprint) {
- Write-Error "-DevCert and -CertThumbprint are mutually exclusive."
-}
-
-$BuildService = -not $DesktopOnly
-$BuildDesktop = -not $ServiceOnly
-
-# ── Version detection ─────────────────────────────────────────────────────────
-if (-not $Version) {
- # Prefer GitVersion (dotnet tool) for accurate semver from branch/tag history.
- try {
- $gvJson = dotnet tool run dotnet-gitversion -- /output json 2>$null | ConvertFrom-Json
- if ($gvJson -and $gvJson.SemVer) { $Version = $gvJson.SemVer }
- } catch { }
-
- if (-not $Version) {
- # Fallback: most recent git tag.
- $tag = git -C $RepoRoot describe --tags --abbrev=0 2>$null
- if ($tag -match '^v?(\d+\.\d+\.\d+)') { $Version = $Matches[1] } else { $Version = "1.0.0" }
- }
-}
-
-# MSIX Identity Version must be 4-part (major.minor.patch.revision).
-# Strip pre-release suffix (e.g. 1.0.0-develop.1 -> 1.0.0) then pad to 4 parts.
-function ConvertTo-MsixVersion([string]$semver) {
- $base = ($semver -split '-')[0]
- $parts = $base -split '\.'
- while ($parts.Count -lt 4) { $parts += "0" }
- ($parts[0..3] -join '.')
-}
-$Version4 = ConvertTo-MsixVersion $Version
-
-# ── Architecture / RID ────────────────────────────────────────────────────────
-# MSIX currently supports x64, x86, and arm64 for full-trust desktop apps.
-$Rid = "win-x64"
-$MsixArch = "x64"
-$PackageName = "remote-agent"
-$MsixFile = Join-Path $OutDir "${PackageName}_${Version}_${MsixArch}.msix"
-
-Write-Host "[package-msix] version=$Version ($Version4) rid=$Rid config=$Configuration self-contained=$SelfContained"
-Write-Host "[package-msix] service=$BuildService desktop=$BuildDesktop out=$OutDir"
-
-# ── Locate Windows SDK tools ─────────────────────────────────────────────────
-function Find-WinSdkTool([string]$Name) {
- $sdkRoot = "C:\Program Files (x86)\Windows Kits\10\bin"
- if (Test-Path $sdkRoot) {
- Get-ChildItem $sdkRoot -Directory | Sort-Object Name -Descending | ForEach-Object {
- $p = Join-Path $_.FullName "x64\$Name"
- if (Test-Path $p) { return $p }
- }
- }
- $inPath = Get-Command $Name -ErrorAction SilentlyContinue
- if ($inPath) { return $inPath.Source }
- return $null
-}
-
-$MakeAppx = Find-WinSdkTool "makeappx.exe"
-$SignTool = Find-WinSdkTool "signtool.exe"
-
-if (-not $MakeAppx) {
- Write-Error @"
-makeappx.exe not found. Install the Windows SDK:
- winget install Microsoft.WindowsSDK.10.0.22621
- -- or --
- https://developer.microsoft.com/windows/downloads/windows-sdk/
-"@
-}
-
-Write-Host "[package-msix] makeappx : $MakeAppx"
-if ($SignTool) { Write-Host "[package-msix] signtool : $SignTool" }
-
-# ── Ensure output directory ───────────────────────────────────────────────────
-New-Item -ItemType Directory -Path $OutDir -Force | Out-Null
-
-# ── Publish ───────────────────────────────────────────────────────────────────
-$scFlag = if ($SelfContained) { "--self-contained true" } else { "" }
-
-if ($BuildService) {
- $ServicePub = Join-Path $OutDir "publish-service"
- Write-Host "[package-msix] publishing service -> $ServicePub"
- $cmd = "dotnet publish `"$(Join-Path $RepoRoot 'src\RemoteAgent.Service\RemoteAgent.Service.csproj')`" " +
- "-c $Configuration -r $Rid -f net10.0 --configfile `"$NuGetConfig`" " +
- "-o `"$ServicePub`" $scFlag"
- Invoke-Expression $cmd
- if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
-
- # Publish Ollama plugin (always framework-dependent; it loads into the service process).
- $OllamaProj = Join-Path $RepoRoot "src\RemoteAgent.Plugins.Ollama\RemoteAgent.Plugins.Ollama.csproj"
- if (Test-Path $OllamaProj) {
- $PluginPub = Join-Path $OutDir "publish-plugin"
- Write-Host "[package-msix] publishing Ollama plugin -> $PluginPub"
- $cmd = "dotnet publish `"$OllamaProj`" " +
- "-c $Configuration -r $Rid -f net10.0 --configfile `"$NuGetConfig`" " +
- "--self-contained false -o `"$PluginPub`""
- Invoke-Expression $cmd
- if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- }
-}
-
-if ($BuildDesktop) {
- $DesktopPub = Join-Path $OutDir "publish-desktop"
- Write-Host "[package-msix] publishing desktop -> $DesktopPub"
- $cmd = "dotnet publish `"$(Join-Path $RepoRoot 'src\RemoteAgent.Desktop\RemoteAgent.Desktop.csproj')`" " +
- "-c $Configuration -r $Rid -f net9.0 --configfile `"$NuGetConfig`" " +
- "-o `"$DesktopPub`" $scFlag"
- Invoke-Expression $cmd
- if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
-}
-
-# ── Generate MSIX icon assets ─────────────────────────────────────────────────
-# Generates solid-color placeholder PNGs using System.Drawing. Replace with
-# real artwork before publishing to the Microsoft Store or any production channel.
-function New-IconPng([string]$Path, [int]$W, [int]$H) {
- Add-Type -AssemblyName System.Drawing
- $bmp = [System.Drawing.Bitmap]::new($W, $H)
- $g = [System.Drawing.Graphics]::FromImage($bmp)
- $g.Clear([System.Drawing.Color]::FromArgb(0x1E, 0x88, 0xE5)) # Material Blue 600
- try {
- $fontSize = [int]([Math]::Max(8, $H * 0.35))
- $font = [System.Drawing.Font]::new("Segoe UI", $fontSize, [System.Drawing.FontStyle]::Bold)
- $sf = [System.Drawing.StringFormat]::new()
- $sf.Alignment = [System.Drawing.StringAlignment]::Center
- $sf.LineAlignment = [System.Drawing.StringAlignment]::Center
- $rect = [System.Drawing.RectangleF]::new(0, 0, $W, $H)
- $g.DrawString("RA", $font, [System.Drawing.Brushes]::White, $rect, $sf)
- $font.Dispose(); $sf.Dispose()
- } catch { } # text rendering is best-effort
- $g.Dispose()
- $bmp.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png)
- $bmp.Dispose()
-}
-
-# ── Assemble MSIX package layout ──────────────────────────────────────────────
-$PkgRoot = Join-Path $OutDir "msix-layout"
-$AssetsDir = Join-Path $PkgRoot "Assets"
-
-# Clean any previous layout.
-if (Test-Path $PkgRoot) { Remove-Item $PkgRoot -Recurse -Force }
-New-Item -ItemType Directory -Path $AssetsDir -Force | Out-Null
-
-# Icons required by the MSIX manifest.
-$iconSpecs = @(
- @{ Name = "Square44x44Logo.png"; W = 44; H = 44 },
- @{ Name = "Square150x150Logo.png"; W = 150; H = 150 },
- @{ Name = "Wide310x150Logo.png"; W = 310; H = 150 },
- @{ Name = "Square310x310Logo.png"; W = 310; H = 310 },
- @{ Name = "StoreLogo.png"; W = 50; H = 50 }
+ [switch] $Clean,
+ [switch] $NoBuild,
+ [switch] $BumpMajor,
+ [switch] $BumpMinor,
+ [switch] $BumpPatch,
+ [switch] $Install,
+ [switch] $Force,
+ [string] $OutDir = ''
)
-foreach ($spec in $iconSpecs) {
- $iconPath = Join-Path $AssetsDir $spec.Name
-
- # Try to rasterise the repo SVG with Inkscape or ImageMagick first.
- $svgSrc = Join-Path $RepoRoot "src\RemoteAgent.Desktop\Assets\AppIcon\appicon.svg"
- $rendered = $false
- if (Test-Path $svgSrc) {
- $inkscape = Get-Command inkscape -ErrorAction SilentlyContinue
- $magick = Get-Command magick -ErrorAction SilentlyContinue
- if ($inkscape) {
- & inkscape --export-filename="$iconPath" --export-width=$spec.W --export-height=$spec.H "$svgSrc" 2>$null
- $rendered = $LASTEXITCODE -eq 0
- } elseif ($magick) {
- & magick -background none -resize "$($spec.W)x$($spec.H)" "$svgSrc" "$iconPath" 2>$null
- $rendered = $LASTEXITCODE -eq 0
- }
- }
-
- if (-not $rendered) { New-IconPng -Path $iconPath -W $spec.W -H $spec.H }
-}
-Write-Host "[package-msix] icon assets written to $AssetsDir"
-
-# Service binaries.
-if ($BuildService) {
- $ServiceDest = Join-Path $PkgRoot "service"
- New-Item -ItemType Directory -Path $ServiceDest -Force | Out-Null
- Copy-Item -Path (Join-Path $ServicePub "*") -Destination $ServiceDest -Recurse -Force
-
- # Include default appsettings so the service has sensible defaults on first run.
- $appSettings = Join-Path $RepoRoot "src\RemoteAgent.Service\appsettings.json"
- if (Test-Path $appSettings) {
- Copy-Item -Path $appSettings -Destination $ServiceDest -Force
- }
-
- # Ollama plugin.
- if (Test-Path (Join-Path $OutDir "publish-plugin")) {
- $PluginDest = Join-Path $ServiceDest "plugins"
- New-Item -ItemType Directory -Path $PluginDest -Force | Out-Null
- Get-ChildItem (Join-Path $OutDir "publish-plugin") -Filter "*.dll" |
- Copy-Item -Destination $PluginDest -Force
- }
-}
-
-# Desktop binaries.
-if ($BuildDesktop) {
- $DesktopDest = Join-Path $PkgRoot "desktop"
- New-Item -ItemType Directory -Path $DesktopDest -Force | Out-Null
- Copy-Item -Path (Join-Path $DesktopPub "*") -Destination $DesktopDest -Recurse -Force
-}
-
-# ── Write AppxManifest.xml ────────────────────────────────────────────────────
-# The manifest always has one Application entry (desktop app, or a headless stub
-# when -ServiceOnly is set). The windows.service Extension is added when the
-# service component is included.
-$desktopExe = "desktop\RemoteAgent.Desktop.exe"
-$serviceExe = "service\RemoteAgent.Service.exe"
-
-# Application element: use desktop exe if available, else service exe as stub.
-$appExe = if ($BuildDesktop) { $desktopExe } else { $serviceExe }
-$appDisplayName = if ($BuildDesktop) { "Remote Agent Desktop" } else { "Remote Agent Service" }
-$appDescription = if ($BuildDesktop) {
- "Remote Agent desktop management application"
-} else {
- "Remote Agent gRPC service host"
-}
-
-$serviceExtensionXml = ""
-if ($BuildService) {
- $serviceExtensionXml = @"
-
-
-
-
-
-
-"@
-}
-
-$rescapNs = 'xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"'
-$desktop6Ns = if ($BuildService) { 'xmlns:desktop6="http://schemas.microsoft.com/appx/manifest/desktop/windows10/6"' } else { "" }
-$ignorable = if ($BuildService) { 'IgnorableNamespaces="rescap desktop6"' } else { 'IgnorableNamespaces="rescap"' }
-
-$manifest = @"
-
-
-
-
-
-
- Remote Agent
- Remote Agent
- Assets\StoreLogo.png
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-$serviceExtensionXml
-
-"@
-
-$manifestPath = Join-Path $PkgRoot "AppxManifest.xml"
-$manifest | Set-Content -Path $manifestPath -Encoding UTF8
-Write-Host "[package-msix] wrote AppxManifest.xml"
-
-# ── Run makeappx ──────────────────────────────────────────────────────────────
-Write-Host "[package-msix] packing $MsixFile ..."
-& $MakeAppx pack /d "$PkgRoot" /p "$MsixFile" /o /nv
-if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
-Write-Host "[package-msix] packed: $MsixFile"
-
-# ── Sign ──────────────────────────────────────────────────────────────────────
-$signThumb = $CertThumbprint
-
-if ($DevCert -and -not $signThumb) {
- if (-not $SignTool) {
- Write-Warning "signtool.exe not found — skipping signing. Install Windows SDK to enable signing."
- } else {
- # Reuse existing dev cert if one with the same subject already exists.
- $existing = Get-ChildItem Cert:\CurrentUser\My |
- Where-Object { $_.Subject -eq $Publisher -and $_.NotAfter -gt (Get-Date) } |
- Sort-Object NotAfter -Descending |
- Select-Object -First 1
-
- if ($existing) {
- Write-Host "[package-msix] reusing existing dev cert: $($existing.Thumbprint)"
- $signThumb = $existing.Thumbprint
- } else {
- Write-Host "[package-msix] creating self-signed dev certificate for '$Publisher'..."
- $cert = New-SelfSignedCertificate `
- -Type Custom `
- -Subject $Publisher `
- -KeyUsage DigitalSignature `
- -FriendlyName "Remote Agent Dev Certificate" `
- -CertStoreLocation "Cert:\CurrentUser\My" `
- -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}")
- $signThumb = $cert.Thumbprint
- Write-Host "[package-msix] dev cert thumbprint: $signThumb"
-
- # Export the public certificate so it can be installed as a trusted root
- # on the target machine before running Add-AppxPackage.
- $cerPath = Join-Path $OutDir "remote-agent-dev.cer"
- Export-Certificate -Cert $cert -FilePath $cerPath -Type CERT | Out-Null
- Write-Host "[package-msix] exported public cert: $cerPath"
- Write-Host "[package-msix] To trust on target machine:"
- Write-Host " Import-Certificate -FilePath '$cerPath' -CertStoreLocation Cert:\LocalMachine\Root"
- }
- }
-}
-
-if ($signThumb -and $SignTool) {
- Write-Host "[package-msix] signing with thumbprint $signThumb ..."
- & $SignTool sign /sha1 $signThumb /fd SHA256 /tr http://timestamp.digicert.com /td sha256 "$MsixFile"
- if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- Write-Host "[package-msix] signed: $MsixFile"
-} elseif (-not $signThumb) {
- Write-Warning "Package is unsigned. To install it locally, use:"
- Write-Warning " Add-AppxPackage -Path '$MsixFile' -AllowUnsigned"
-}
-
-# ── Summary ────────────────────────────────────────────────────────────────────
-$msixSize = [math]::Round((Get-Item $MsixFile).Length / 1MB, 1)
-$installPs1 = Join-Path $PSScriptRoot "install-remote-agent.ps1"
-$cerArg = if ($DevCert -and (Test-Path (Join-Path $OutDir "remote-agent-dev.cer"))) {
- " -CertPath '$(Join-Path $OutDir 'remote-agent-dev.cer')'"
-} else { "" }
-
-Write-Host ""
-Write-Host "── MSIX package ready ──────────────────────────────────────────────────"
-Write-Host " File : $MsixFile ($msixSize MB)"
-Write-Host " Identity : RemoteAgent $Version4 $MsixArch"
-Write-Host " Publisher: $Publisher"
-if ($BuildService) { Write-Host " Service : service\RemoteAgent.Service.exe (RemoteAgentService, Automatic, LocalSystem)" }
-if ($BuildDesktop) { Write-Host " Desktop : desktop\RemoteAgent.Desktop.exe (Start menu: Remote Agent Desktop)" }
-Write-Host ""
-Write-Host " Install + start service (run as Administrator):"
-Write-Host " .\scripts\install-remote-agent.ps1$cerArg"
-Write-Host " -- or manually --"
-if ($signThumb) {
- Write-Host " Add-AppxPackage -Path '$MsixFile'"
-} else {
- Write-Host " Add-AppxPackage -Path '$MsixFile' -AllowUnsigned"
-}
-if ($BuildService) {
- Write-Host " Start-Service RemoteAgentService"
-}
-Write-Host ""
-Write-Host " Uninstall:"
-Write-Host " .\scripts\install-remote-agent.ps1 -Uninstall"
-Write-Host "────────────────────────────────────────────────────────────────────────"
+$ErrorActionPreference = 'Stop'
+$RepoRoot = (Get-Item $PSScriptRoot).Parent.FullName
+Import-Module (Join-Path $PSScriptRoot 'MsixTools\MsixTools.psd1') -Force
+
+$params = @{
+ WorkspaceRoot = $RepoRoot
+ ConfigPath = Join-Path $RepoRoot 'msix.yml'
+ Configuration = $Configuration
+ SelfContained = $SelfContained
+ Clean = $Clean
+ NoBuild = $NoBuild
+ BumpMajor = $BumpMajor
+ BumpMinor = $BumpMinor
+ BumpPatch = $BumpPatch
+ Force = $Force
+ Install = $Install
+}
+if ($Version) { $params['Version'] = $Version }
+if ($Publisher) { $params['Publisher'] = $Publisher }
+if ($CertThumbprint) { $params['CertThumbprint'] = $CertThumbprint }
+if ($DevCert) { $params['DevCert'] = $true }
+if ($OutDir) { $params['OutDir'] = $OutDir }
+if ($ServiceOnly) { $params['ExcludeDesktop'] = $true }
+if ($DesktopOnly) { $params['ExcludeService'] = $true }
+
+New-MsixPackage @params
+
+# Explicitly exit 0 to prevent $LASTEXITCODE set by any native command inside
+# New-MsixPackage (dotnet, makeappx, signtool) from causing the GitHub Actions
+# pwsh runner to report failure even when the package was created successfully.
+exit 0
diff --git a/scripts/remote-agent-ctl.sh b/scripts/remote-agent-ctl.sh
new file mode 100644
index 0000000..23c272f
--- /dev/null
+++ b/scripts/remote-agent-ctl.sh
@@ -0,0 +1,149 @@
+#!/usr/bin/env bash
+# remote-agent-ctl — Start, stop, restart, or check the Remote Agent gRPC service.
+#
+# Works with both systemd and non-systemd environments (WSL, Pengwin, etc.).
+# When systemd is active, delegates to systemctl. Otherwise uses start-stop-daemon
+# directly, tracking the process via /run/remote-agent.pid.
+#
+# Usage:
+# remote-agent-ctl {start|stop|restart|status}
+
+set -euo pipefail
+
+SVC_NAME="remote-agent.service"
+SVC_BIN="/usr/lib/remote-agent/service/RemoteAgent.Service"
+SVC_USER="remote-agent"
+PID_FILE="/run/remote-agent.pid"
+LOG_FILE="/var/log/remote-agent/service.log"
+ENV_FILE="/etc/remote-agent/environment"
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+_has_systemd() {
+ [ -d /run/systemd/system ]
+}
+
+_source_env() {
+ if [ -f "$ENV_FILE" ]; then
+ set -a
+ # shellcheck disable=SC1090
+ . "$ENV_FILE" 2>/dev/null || true
+ set +a
+ fi
+ export ASPNETCORE_CONTENTROOT=/etc/remote-agent
+}
+
+_is_running() {
+ # Check PID file first.
+ if [ -f "$PID_FILE" ]; then
+ _pid=$(cat "$PID_FILE" 2>/dev/null || true)
+ if [ -n "$_pid" ] && kill -0 "$_pid" 2>/dev/null; then
+ return 0
+ fi
+ fi
+ # Fallback: check if the binary is already running (e.g. started at boot
+ # outside of this script, so no PID file was written by us).
+ if pgrep -x "$(basename "$SVC_BIN")" > /dev/null 2>&1; then
+ # Capture the PID and update the PID file so future calls use it.
+ _live_pid=$(pgrep -x "$(basename "$SVC_BIN")" | head -1)
+ echo "$_live_pid" | sudo tee "$PID_FILE" > /dev/null 2>/dev/null || true
+ return 0
+ fi
+ return 1
+}
+
+# ── Commands ──────────────────────────────────────────────────────────────────
+
+_start() {
+ if _has_systemd; then
+ systemctl start "$SVC_NAME"
+ return
+ fi
+
+ if _is_running; then
+ echo "remote-agent is already running (pid $(cat "$PID_FILE" 2>/dev/null || pgrep -x "$(basename "$SVC_BIN")" | head -1))."
+ return 0
+ fi
+
+ if [ ! -x "$SVC_BIN" ]; then
+ echo "ERROR: service binary not found: $SVC_BIN" >&2
+ exit 1
+ fi
+
+ _source_env
+ mkdir -p "$(dirname "$LOG_FILE")"
+
+ echo "Starting remote-agent via start-stop-daemon..."
+ start-stop-daemon --start --background \
+ --make-pidfile --pidfile "$PID_FILE" \
+ --chuid "$SVC_USER" \
+ --output "$LOG_FILE" \
+ --exec "$SVC_BIN"
+
+ # Give the process a moment to start.
+ sleep 1
+
+ if _is_running; then
+ echo "remote-agent started (pid $(cat "$PID_FILE"))."
+ else
+ echo "ERROR: remote-agent failed to start. Check $LOG_FILE" >&2
+ exit 1
+ fi
+}
+
+_stop() {
+ if _has_systemd; then
+ systemctl stop "$SVC_NAME"
+ return
+ fi
+
+ if ! _is_running; then
+ echo "remote-agent is not running."
+ return 0
+ fi
+
+ echo "Stopping remote-agent..."
+ start-stop-daemon --stop --retry 5 \
+ --pidfile "$PID_FILE" \
+ --exec "$SVC_BIN" 2>/dev/null || true
+ rm -f "$PID_FILE"
+ echo "remote-agent stopped."
+}
+
+_restart() {
+ if _has_systemd; then
+ systemctl restart "$SVC_NAME"
+ return
+ fi
+
+ _stop
+ sleep 1
+ _start
+}
+
+_status() {
+ if _has_systemd; then
+ systemctl status "$SVC_NAME"
+ return
+ fi
+
+ if _is_running; then
+ echo "remote-agent is running (pid $(cat "$PID_FILE"))."
+ else
+ echo "remote-agent is not running."
+ exit 1
+ fi
+}
+
+# ── Dispatch ──────────────────────────────────────────────────────────────────
+
+case "${1:-}" in
+ start) _start ;;
+ stop) _stop ;;
+ restart) _restart ;;
+ status) _status ;;
+ *)
+ echo "Usage: $(basename "$0") {start|stop|restart|status}" >&2
+ exit 1
+ ;;
+esac
diff --git a/scripts/set-pairing-user.ps1 b/scripts/set-pairing-user.ps1
new file mode 100644
index 0000000..4542b56
--- /dev/null
+++ b/scripts/set-pairing-user.ps1
@@ -0,0 +1,165 @@
+<#
+.SYNOPSIS
+ Adds or updates a pairing user in the RemoteAgent service appsettings.json.
+
+.PARAMETER Username
+ The pairing user's login name (case-insensitive match for upsert).
+
+.PARAMETER Password
+ The plaintext password. The SHA-256 hex digest is stored, never the plaintext.
+
+.PARAMETER Replace
+ When specified, replaces the entire PairingUsers array with only this user.
+ Without this switch the entry is upserted (added if not present, updated if it exists).
+
+.EXAMPLE
+ .\set-pairing-user.ps1 -Username alice -Password s3cr3t
+ .\set-pairing-user.ps1 -Username alice -Password newpassword -Replace
+#>
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [string] $Username,
+
+ [Parameter(Mandatory = $true)]
+ [string] $Password,
+
+ [switch] $Replace
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+# ---------------------------------------------------------------------------
+# 1. Locate appsettings.json
+# ---------------------------------------------------------------------------
+function Find-AppSettings {
+ # Try to find the service executable path via SC query
+ try {
+ $scOutput = & sc.exe qc RemoteAgentService 2>$null
+ if ($LASTEXITCODE -eq 0) {
+ $binaryLine = $scOutput | Where-Object { $_ -match 'BINARY_PATH_NAME' }
+ if ($binaryLine) {
+ $exePath = ($binaryLine -replace '.*BINARY_PATH_NAME\s+:\s+', '').Trim()
+ # Strip any CLI arguments (path ends at the first unquoted space after the exe)
+ $exePath = $exePath -replace '^"([^"]+)".*', '$1'
+ $exePath = $exePath -replace '^(\S+).*', '$1'
+ $serviceDir = Split-Path -Parent $exePath
+ $candidate = Join-Path $serviceDir 'appsettings.json'
+ if (Test-Path $candidate) {
+ return $candidate
+ }
+ }
+ }
+ }
+ catch {
+ # SC query failed – ignore and fall through
+ }
+
+ # Default installed location
+ $defaultPath = 'C:\Program Files\WindowsApps\RemoteAgent\service\appsettings.json'
+ if (Test-Path $defaultPath) {
+ return $defaultPath
+ }
+
+ # Fall back to repo source (development)
+ $repoFallback = Join-Path $PSScriptRoot '..' 'src' 'RemoteAgent.Service' 'appsettings.json'
+ $repoFallback = [System.IO.Path]::GetFullPath($repoFallback)
+ if (Test-Path $repoFallback) {
+ return $repoFallback
+ }
+
+ throw "Could not find appsettings.json. Searched:`n $defaultPath`n $repoFallback"
+}
+
+# ---------------------------------------------------------------------------
+# 2. Compute SHA-256 hex of the password
+# ---------------------------------------------------------------------------
+$passwordBytes = [System.Text.Encoding]::UTF8.GetBytes($Password)
+$hashBytes = [System.Security.Cryptography.SHA256]::HashData($passwordBytes)
+$passwordHash = ($hashBytes | ForEach-Object { $_.ToString('x2') }) -join ''
+
+Write-Verbose "Password SHA-256: $passwordHash"
+
+# ---------------------------------------------------------------------------
+# 3. Find and read appsettings.json
+# ---------------------------------------------------------------------------
+$appSettingsPath = Find-AppSettings
+Write-Host "Using appsettings.json: $appSettingsPath"
+
+$json = Get-Content -Path $appSettingsPath -Raw -Encoding UTF8
+$settings = $json | ConvertFrom-Json -AsHashtable
+
+# ---------------------------------------------------------------------------
+# 4. Upsert (or replace) the pairing user entry
+# ---------------------------------------------------------------------------
+if (-not $settings.ContainsKey('Agent')) {
+ $settings['Agent'] = @{}
+}
+
+$agent = $settings['Agent']
+
+if ($Replace) {
+ # Replace entire array with only this user
+ $agent['PairingUsers'] = @(
+ [ordered]@{ Username = $Username; PasswordHash = $passwordHash }
+ )
+}
+else {
+ # Upsert: replace existing entry matched case-insensitively, or append
+ $existingUsers = if ($agent.ContainsKey('PairingUsers') -and $agent['PairingUsers']) {
+ @($agent['PairingUsers'])
+ }
+ else {
+ @()
+ }
+
+ $replaced = $false
+ $newUsers = @()
+ foreach ($user in $existingUsers) {
+ $uName = if ($user -is [hashtable]) { $user['Username'] } else { $user.Username }
+ if ([string]::Equals($uName, $Username, [System.StringComparison]::OrdinalIgnoreCase)) {
+ $newUsers += [ordered]@{ Username = $Username; PasswordHash = $passwordHash }
+ $replaced = $true
+ }
+ else {
+ $newUsers += $user
+ }
+ }
+
+ if (-not $replaced) {
+ $newUsers += [ordered]@{ Username = $Username; PasswordHash = $passwordHash }
+ }
+
+ $agent['PairingUsers'] = $newUsers
+}
+
+$settings['Agent'] = $agent
+
+# ---------------------------------------------------------------------------
+# 5. Write appsettings.json back with indentation
+# ---------------------------------------------------------------------------
+$updatedJson = $settings | ConvertTo-Json -Depth 10
+Set-Content -Path $appSettingsPath -Value $updatedJson -Encoding UTF8
+
+Write-Host "SUCCESS: Pairing user '$Username' has been set in $appSettingsPath"
+
+# ---------------------------------------------------------------------------
+# 6. Restart RemoteAgentService (non-fatal if not found)
+# ---------------------------------------------------------------------------
+try {
+ $svc = Get-Service -Name 'RemoteAgentService' -ErrorAction SilentlyContinue
+ if ($svc) {
+ Write-Host "Stopping RemoteAgentService..."
+ Stop-Service -Name 'RemoteAgentService' -Force
+ Write-Host "Starting RemoteAgentService..."
+ Start-Service -Name 'RemoteAgentService'
+ Write-Host "RemoteAgentService restarted."
+ }
+ else {
+ Write-Verbose "RemoteAgentService not found – skipping service restart."
+ }
+}
+catch {
+ Write-Warning "Could not restart RemoteAgentService: $_"
+}
diff --git a/src/RemoteAgent.App.Logic/AgentSessionClient.cs b/src/RemoteAgent.App.Logic/AgentSessionClient.cs
index 4304f13..d230dbb 100644
--- a/src/RemoteAgent.App.Logic/AgentSessionClient.cs
+++ b/src/RemoteAgent.App.Logic/AgentSessionClient.cs
@@ -13,6 +13,7 @@ public interface IAgentSessionClient : IAgentInteractionSession
string? PerRequestContext { get; set; }
event Action? ConnectionStateChanged;
event Action? MessageReceived;
+ event Action? FileTransferReceived;
Task ConnectAsync(
string host,
@@ -47,6 +48,7 @@ public sealed class AgentSessionClient(Func? mediaTextFormat
public event Action? ConnectionStateChanged;
public event Action? MessageReceived;
+ public event Action? FileTransferReceived;
public async Task ConnectAsync(
string host,
@@ -207,6 +209,23 @@ private async Task ReceiveLoop(CancellationToken ct)
while (await _call.ResponseStream.MoveNext(ct))
{
var incoming = _call.ResponseStream.Current;
+
+ // Handle file transfers separately: raise dedicated event + chat notification.
+ if (incoming.PayloadCase == ServerMessage.PayloadOneofCase.FileTransfer && incoming.FileTransfer != null)
+ {
+ var ft = incoming.FileTransfer;
+ FileTransferReceived?.Invoke(ft);
+ var sizeText = ft.TotalSize < 1024 ? $"{ft.TotalSize} B" : $"{ft.TotalSize / 1024.0:F1} KB";
+ var chatMsg = new ChatMessage
+ {
+ IsUser = false,
+ Text = $"\U0001F4C1 File received: {ft.RelativePath} ({sizeText})",
+ FileTransferPath = ft.RelativePath
+ };
+ MessageReceived?.Invoke(chatMsg);
+ continue;
+ }
+
var mapped = MapServerMessage(incoming);
if (mapped != null)
MessageReceived?.Invoke(mapped);
diff --git a/src/RemoteAgent.App.Logic/ChatMessage.cs b/src/RemoteAgent.App.Logic/ChatMessage.cs
index 05f7338..b5aca54 100644
--- a/src/RemoteAgent.App.Logic/ChatMessage.cs
+++ b/src/RemoteAgent.App.Logic/ChatMessage.cs
@@ -39,6 +39,9 @@ public class ChatMessage : INotifyPropertyChanged
/// Message priority (FR-3.1). Notify causes a system notification; tap opens the app (FR-3.2, FR-3.3).
public ChatMessagePriority Priority { get; init; } = ChatMessagePriority.Normal;
+ /// When set, indicates this message represents a file transfer. Contains the relative path of the transferred file.
+ public string? FileTransferPath { get; init; }
+
/// Plain text for display: event message or raw text (no markdown).
public string DisplayText => IsEvent ? (EventMessage ?? "") : Text;
diff --git a/src/RemoteAgent.App.Logic/ISessionCommandBus.cs b/src/RemoteAgent.App.Logic/ISessionCommandBus.cs
index 8e818a8..71b2de2 100644
--- a/src/RemoteAgent.App.Logic/ISessionCommandBus.cs
+++ b/src/RemoteAgent.App.Logic/ISessionCommandBus.cs
@@ -10,4 +10,6 @@ public interface ISessionCommandBus
bool SelectSession(string? sessionId);
Task TerminateSessionAsync(string? sessionId);
string? GetCurrentSessionId();
+ bool IsConnected { get; }
+ event Action? ConnectionStateChanged;
}
diff --git a/src/RemoteAgent.App.Logic/PlatformAbstractions.cs b/src/RemoteAgent.App.Logic/PlatformAbstractions.cs
index d25cc3a..65b2141 100644
--- a/src/RemoteAgent.App.Logic/PlatformAbstractions.cs
+++ b/src/RemoteAgent.App.Logic/PlatformAbstractions.cs
@@ -2,11 +2,6 @@
namespace RemoteAgent.App.Logic;
-public interface IConnectionModeSelector
-{
- Task SelectAsync();
-}
-
public interface IAgentSelector
{
Task SelectAsync(ServerInfoResponse serverInfo);
@@ -38,3 +33,18 @@ public interface INotificationService
{
void Show(string title, string body);
}
+
+/// Opens the server login page and extracts the pairing deep-link URI after the user authenticates, or returns null if cancelled.
+public interface IQrCodeScanner
+{
+ Task ScanAsync(string loginUrl);
+}
+
+/// Routes deep-link URIs arriving from the OS (e.g. remoteagent://pair?…) to subscribers. Queues one URI if dispatched before any subscriber registers.
+public interface IDeepLinkService
+{
+ /// Subscribe and immediately receive any queued pending URI.
+ void Subscribe(Action handler);
+ /// Dispatch a URI to all subscribers; queues it if there are none yet.
+ void Dispatch(string rawUri);
+}
diff --git a/src/RemoteAgent.App.Logic/ServerApiClient.cs b/src/RemoteAgent.App.Logic/ServerApiClient.cs
index 9a79f6e..4fb31e4 100644
--- a/src/RemoteAgent.App.Logic/ServerApiClient.cs
+++ b/src/RemoteAgent.App.Logic/ServerApiClient.cs
@@ -1,19 +1,12 @@
using Grpc.Core;
using Grpc.Net.Client;
using RemoteAgent.Proto;
-using System.Net.Http.Json;
-using System.Text.Json;
namespace RemoteAgent.App.Logic;
/// Shared server interaction APIs used by mobile and desktop clients.
public static class ServerApiClient
{
- private static readonly JsonSerializerOptions JsonOptions = new()
- {
- PropertyNameCaseInsensitive = true
- };
-
public static Task GetServerInfoAsync(
string host,
int port,
@@ -276,27 +269,30 @@ public static async Task MonitorStructuredLogsAsync(
CancellationToken ct = default,
bool throwOnError = false)
{
- var baseUrl = BuildBaseUrl(host, port).TrimEnd('/');
- var query = string.IsNullOrWhiteSpace(agentId)
- ? ""
- : $"?agentId={Uri.EscapeDataString(agentId.Trim())}";
- var url = $"{baseUrl}/api/sessions/capacity{query}";
-
- using var client = new HttpClient();
- if (!string.IsNullOrWhiteSpace(apiKey))
- client.DefaultRequestHeaders.Add("x-api-key", apiKey.Trim());
-
- using var response = await client.GetAsync(url, ct);
- if (!response.IsSuccessStatusCode)
- {
- if (!throwOnError)
- return null;
+ var response = await ExecuteGrpcAsync(
+ host,
+ port,
+ apiKey,
+ "Check session capacity",
+ throwOnError,
+ ct,
+ (client, headers, token) => client.CheckSessionCapacityAsync(
+ new CheckSessionCapacityRequest { AgentId = agentId ?? "" },
+ headers,
+ cancellationToken: token).ResponseAsync);
- var detail = await TryReadErrorDetailAsync(response, ct);
- throw CreateHttpFailure("Get session capacity", response.StatusCode, response.ReasonPhrase, detail);
- }
+ if (response == null) return null;
- return await response.Content.ReadFromJsonAsync(JsonOptions, ct);
+ return new SessionCapacitySnapshot(
+ response.CanCreateSession,
+ response.Reason,
+ response.MaxConcurrentSessions,
+ response.ActiveSessionCount,
+ response.RemainingServerCapacity,
+ response.AgentId,
+ response.HasAgentLimit ? response.AgentMaxConcurrentSessions : null,
+ response.AgentActiveSessionCount,
+ response.HasAgentLimit ? response.RemainingAgentCapacity : null);
}
private static async Task ExecuteGrpcAsync(
@@ -349,51 +345,59 @@ private static InvalidOperationException CreateGrpcFailure(string operation, Rpc
return new InvalidOperationException($"{operation} failed ({ex.StatusCode}): {detail}", ex);
}
- private static InvalidOperationException CreateHttpFailure(string operation, System.Net.HttpStatusCode statusCode, string? reasonPhrase, string? detail)
- {
- var code = (int)statusCode;
- var reason = string.IsNullOrWhiteSpace(reasonPhrase) ? "HTTP error" : reasonPhrase;
- var message = string.IsNullOrWhiteSpace(detail)
- ? $"{operation} failed ({code} {reason})."
- : $"{operation} failed ({code} {reason}): {detail}";
- return new InvalidOperationException(message);
- }
-
- private static async Task TryReadErrorDetailAsync(HttpResponseMessage response, CancellationToken ct)
- {
- try
- {
- var body = await response.Content.ReadAsStringAsync(ct);
- if (string.IsNullOrWhiteSpace(body))
- return null;
-
- using var doc = JsonDocument.Parse(body);
- if (doc.RootElement.ValueKind == JsonValueKind.Object &&
- doc.RootElement.TryGetProperty("message", out var messageNode) &&
- messageNode.ValueKind == JsonValueKind.String)
- {
- return messageNode.GetString();
- }
-
- return body.Length <= 200 ? body : body[..200];
- }
- catch
- {
- return null;
- }
- }
-
public static Metadata? CreateHeaders(string? apiKey)
{
if (string.IsNullOrWhiteSpace(apiKey)) return null;
return new Metadata { { "x-api-key", apiKey.Trim() } };
}
- public static string BuildBaseUrl(string host, int port)
- {
- return port == 443 ? $"https://{host}" : $"http://{host}:{port}";
- }
-}
+ public static string BuildBaseUrl(string host, int port)
+ {
+ return port == 443 ? $"https://{host}" : $"http://{host}:{port}";
+ }
+
+ public static Task ListAgentRunnersAsync(
+ string host,
+ int port,
+ string? apiKey = null,
+ CancellationToken ct = default,
+ bool throwOnError = false)
+ => ExecuteGrpcAsync(
+ host,
+ port,
+ apiKey,
+ "List agent runners",
+ throwOnError,
+ ct,
+ (client, headers, token) => client.ListAgentRunnersAsync(new ListAgentRunnersRequest(), headers, deadline: null, cancellationToken: token).ResponseAsync);
+
+ public static Task UpdateAgentRunnerAsync(
+ string host,
+ int port,
+ string runnerId,
+ string? command = null,
+ string? arguments = null,
+ int maxConcurrentSessions = 0,
+ bool setAsDefault = false,
+ string? apiKey = null,
+ CancellationToken ct = default,
+ bool throwOnError = false)
+ => ExecuteGrpcAsync(
+ host,
+ port,
+ apiKey,
+ "Update agent runner",
+ throwOnError,
+ ct,
+ (client, headers, token) => client.UpdateAgentRunnerAsync(new UpdateAgentRunnerRequest
+ {
+ RunnerId = runnerId,
+ Command = command ?? "",
+ Arguments = arguments ?? "",
+ MaxConcurrentSessions = maxConcurrentSessions,
+ SetAsDefault = setAsDefault
+ }, headers, deadline: null, cancellationToken: token).ResponseAsync);
+}
public sealed record SessionCapacitySnapshot(
bool CanCreateSession,
diff --git a/src/RemoteAgent.App.Logic/ServerProfile.cs b/src/RemoteAgent.App.Logic/ServerProfile.cs
new file mode 100644
index 0000000..081e8dd
--- /dev/null
+++ b/src/RemoteAgent.App.Logic/ServerProfile.cs
@@ -0,0 +1,45 @@
+namespace RemoteAgent.App.Logic;
+
+///
+/// A saved server profile keyed by Host + Port.
+/// Stores per-server configuration that applies to new connections and sessions.
+///
+public sealed class ServerProfile
+{
+ /// Server hostname or IP address.
+ public string Host { get; set; } = "";
+
+ /// Server gRPC port.
+ public int Port { get; set; } = 5244;
+
+ /// API key for authentication (may be empty if not required).
+ public string ApiKey { get; set; } = "";
+
+ /// Optional user-friendly display name for the server.
+ public string DisplayName { get; set; } = "";
+
+ /// Text prepended to every chat message sent to the agent.
+ public string PerRequestContext { get; set; } = "";
+
+ /// Default context seeded into new sessions on this server.
+ public string DefaultSessionContext { get; set; } = "";
+}
+
+///
+/// Persistence for saved server profiles. Implementations should treat
+/// Host + Port as the unique key.
+///
+public interface IServerProfileStore
+{
+ /// Return all saved profiles.
+ IReadOnlyList GetAll();
+
+ /// Find a profile by host and port, or null if not saved.
+ ServerProfile? GetByHostPort(string host, int port);
+
+ /// Insert or update a profile (matched by Host + Port).
+ void Upsert(ServerProfile profile);
+
+ /// Delete a profile by host and port.
+ bool Delete(string host, int port);
+}
diff --git a/src/RemoteAgent.App.Logic/ViewModels/AppShellViewModel.cs b/src/RemoteAgent.App.Logic/ViewModels/AppShellViewModel.cs
index 3329b3e..1891681 100644
--- a/src/RemoteAgent.App.Logic/ViewModels/AppShellViewModel.cs
+++ b/src/RemoteAgent.App.Logic/ViewModels/AppShellViewModel.cs
@@ -28,6 +28,12 @@ public AppShellViewModel(
NavigateToAccountCommand = new RelayCommand(() => _ = NavigateAsync("//AccountManagementPage"));
OpenSessionsCommand = new RelayCommand(() => _ = OpenSessionsAsync());
+ _sessionBus.ConnectionStateChanged += () =>
+ {
+ OnPropertyChanged(nameof(IsConnected));
+ RefreshSessions();
+ };
+
RefreshSessions();
}
@@ -35,6 +41,8 @@ public AppShellViewModel(
public ObservableCollection SessionItems { get; } = [];
+ public bool IsConnected => _sessionBus.IsConnected;
+
public ICommand StartSessionCommand { get; }
public ICommand SelectSessionCommand { get; }
public ICommand TerminateSessionCommand { get; }
diff --git a/src/RemoteAgent.App/AppShell.xaml b/src/RemoteAgent.App/AppShell.xaml
index 66f550d..139252d 100644
--- a/src/RemoteAgent.App/AppShell.xaml
+++ b/src/RemoteAgent.App/AppShell.xaml
@@ -11,8 +11,8 @@
-
-
+
+
@@ -26,9 +26,9 @@
-
-
-
+
+
+
diff --git a/src/RemoteAgent.App/AppShell.xaml.cs b/src/RemoteAgent.App/AppShell.xaml.cs
index 07e2913..7ff3caa 100644
--- a/src/RemoteAgent.App/AppShell.xaml.cs
+++ b/src/RemoteAgent.App/AppShell.xaml.cs
@@ -24,27 +24,41 @@ public AppShell(
Route = "MainPage",
Content = mainPage
});
- Items.Add(new ShellContent
+
+ var mcpTab = new ShellContent
{
Title = "MCP Registry",
Route = "McpRegistryPage",
- Content = mcpRegistryPage
- });
+ Content = mcpRegistryPage,
+ IsVisible = _vm.IsConnected
+ };
+ Items.Add(mcpTab);
+
Items.Add(new ShellContent
{
Title = "Settings",
Route = "SettingsPage",
Content = settingsPage,
- FlyoutItemIsVisible = false
+ FlyoutItemIsVisible = false,
+ IsVisible = false
});
Items.Add(new ShellContent
{
Title = "Account Management",
Route = "AccountManagementPage",
Content = accountManagementPage,
- FlyoutItemIsVisible = false
+ FlyoutItemIsVisible = false,
+ IsVisible = false
});
+ _vm.PropertyChanged += (_, args) =>
+ {
+ if (args.PropertyName == nameof(AppShellViewModel.IsConnected))
+ {
+ mcpTab.IsVisible = _vm.IsConnected;
+ }
+ };
+
PropertyChanged += (_, args) =>
{
if (args.PropertyName == nameof(FlyoutIsPresented) && FlyoutIsPresented)
diff --git a/src/RemoteAgent.App/Handlers/ClearServerApiKeyHandler.cs b/src/RemoteAgent.App/Handlers/ClearServerApiKeyHandler.cs
new file mode 100644
index 0000000..5b74e70
--- /dev/null
+++ b/src/RemoteAgent.App/Handlers/ClearServerApiKeyHandler.cs
@@ -0,0 +1,23 @@
+using RemoteAgent.App.Logic;
+using RemoteAgent.App.Logic.Cqrs;
+using RemoteAgent.App.Requests;
+
+namespace RemoteAgent.App.Handlers;
+
+public sealed class ClearServerApiKeyHandler(IServerProfileStore profileStore)
+ : IRequestHandler
+{
+ public Task HandleAsync(ClearServerApiKeyRequest request, CancellationToken ct = default)
+ {
+ var workspace = request.Workspace;
+ var profile = workspace.SelectedProfile;
+ if (profile == null)
+ return Task.FromResult(CommandResult.Fail("No server selected."));
+
+ profile.ApiKey = "";
+ profileStore.Upsert(profile);
+ workspace.HasApiKey = false;
+
+ return Task.FromResult(CommandResult.Ok());
+ }
+}
diff --git a/src/RemoteAgent.App/Handlers/ConnectMobileSessionHandler.cs b/src/RemoteAgent.App/Handlers/ConnectMobileSessionHandler.cs
index c56856b..d2694dc 100644
--- a/src/RemoteAgent.App/Handlers/ConnectMobileSessionHandler.cs
+++ b/src/RemoteAgent.App/Handlers/ConnectMobileSessionHandler.cs
@@ -9,37 +9,26 @@ public sealed class ConnectMobileSessionHandler(
IAgentGatewayClient gateway,
ISessionStore sessionStore,
IServerApiClient apiClient,
- IConnectionModeSelector connectionModeSelector,
IAgentSelector agentSelector,
- IAppPreferences preferences)
+ IAppPreferences preferences,
+ IServerProfileStore profileStore)
: IRequestHandler
{
private const string PrefServerHost = "ServerHost";
private const string PrefServerPort = "ServerPort";
+ private const string PrefApiKey = "ApiKey";
private const string DefaultPort = "5243";
public async Task HandleAsync(ConnectMobileSessionRequest request, CancellationToken ct = default)
{
var workspace = request.Workspace;
- var selectedMode = await connectionModeSelector.SelectAsync();
- if (string.IsNullOrWhiteSpace(selectedMode))
- {
- workspace.Status = "Connect cancelled.";
- return CommandResult.Fail("Connect cancelled.");
- }
-
var host = (workspace.Host ?? "").Trim();
var portText = (workspace.Port ?? DefaultPort).Trim();
if (string.IsNullOrWhiteSpace(host))
{
- if (string.Equals(selectedMode, "direct", StringComparison.OrdinalIgnoreCase))
- host = "127.0.0.1";
- else
- {
- workspace.Status = "Enter a host.";
- return CommandResult.Fail("Enter a host.");
- }
+ workspace.Status = "Enter a host.";
+ return CommandResult.Fail("Enter a host.");
}
if (!int.TryParse(portText, out var port) || port <= 0 || port > 65535)
@@ -48,6 +37,11 @@ public async Task HandleAsync(ConnectMobileSessionRequest request
return CommandResult.Fail("Enter a valid port (1-65535).");
}
+ // Load saved profile for this server (if any) and apply per-server config
+ var profile = profileStore.GetByHostPort(host, port);
+ if (profile != null && string.IsNullOrWhiteSpace(workspace.PerRequestContext))
+ workspace.PerRequestContext = profile.PerRequestContext;
+
SessionItem? sessionToConnect = workspace.CurrentSession;
if (sessionToConnect == null)
{
@@ -55,7 +49,7 @@ public async Task HandleAsync(ConnectMobileSessionRequest request
{
SessionId = Guid.NewGuid().ToString("N")[..12],
Title = "New chat",
- ConnectionMode = selectedMode
+ ConnectionMode = "server"
};
workspace.Sessions.Insert(0, sessionToConnect);
sessionStore.Add(sessionToConnect);
@@ -64,64 +58,66 @@ public async Task HandleAsync(ConnectMobileSessionRequest request
if (string.IsNullOrWhiteSpace(sessionToConnect.AgentId))
{
- if (string.Equals(selectedMode, "server", StringComparison.OrdinalIgnoreCase))
+ workspace.Status = "Getting server info...";
+ var serverInfo = await apiClient.GetServerInfoAsync(host, port, apiKey: workspace.ApiKey, ct: ct);
+ if (serverInfo == null)
{
- workspace.Status = "Getting server info...";
- var serverInfo = await apiClient.GetServerInfoAsync(host, port, ct: ct);
- if (serverInfo == null)
- {
- workspace.Status = "Could not reach server.";
- return CommandResult.Fail("Could not reach server.");
- }
-
- var agentId = await agentSelector.SelectAsync(serverInfo);
- if (agentId == null)
- {
- workspace.Status = "Connect cancelled.";
- return CommandResult.Fail("Connect cancelled.");
- }
-
- sessionToConnect.AgentId = agentId;
+ workspace.Status = "Could not reach server.";
+ return CommandResult.Fail("Could not reach server.");
}
- else
+
+ var agentId = await agentSelector.SelectAsync(serverInfo);
+ if (agentId == null)
{
- sessionToConnect.AgentId = "process";
+ workspace.Status = "Connect cancelled.";
+ return CommandResult.Fail("Connect cancelled.");
}
+ sessionToConnect.AgentId = agentId;
sessionStore.UpdateAgentId(sessionToConnect.SessionId, sessionToConnect.AgentId);
}
- sessionToConnect.ConnectionMode = selectedMode;
- sessionStore.UpdateConnectionMode(sessionToConnect.SessionId, selectedMode);
+ sessionToConnect.ConnectionMode = "server";
+ sessionStore.UpdateConnectionMode(sessionToConnect.SessionId, "server");
- if (string.Equals(selectedMode, "server", StringComparison.OrdinalIgnoreCase))
+ var capacity = await apiClient.GetSessionCapacityAsync(host, port, sessionToConnect.AgentId, apiKey: workspace.ApiKey, ct: ct);
+ if (capacity == null)
{
- var capacity = await apiClient.GetSessionCapacityAsync(host, port, sessionToConnect.AgentId, ct: ct);
- if (capacity == null)
- {
- workspace.Status = "Could not verify server session capacity.";
- return CommandResult.Fail("Could not verify server session capacity.");
- }
+ workspace.Status = "Could not verify server session capacity.";
+ return CommandResult.Fail("Could not verify server session capacity.");
+ }
- if (!capacity.CanCreateSession)
- {
- workspace.Status = string.IsNullOrWhiteSpace(capacity.Reason)
- ? "Server session capacity reached."
- : capacity.Reason;
- return CommandResult.Fail(workspace.Status);
- }
+ if (!capacity.CanCreateSession)
+ {
+ workspace.Status = string.IsNullOrWhiteSpace(capacity.Reason)
+ ? "Server session capacity reached."
+ : capacity.Reason;
+ return CommandResult.Fail(workspace.Status);
}
- workspace.Status = $"Connecting ({selectedMode})...";
+ workspace.Status = "Connecting (server)...";
try
{
- await gateway.ConnectAsync(host, port, sessionToConnect.SessionId, sessionToConnect.AgentId, ct: ct);
+ await gateway.ConnectAsync(host, port, sessionToConnect.SessionId, sessionToConnect.AgentId,
+ apiKey: workspace.ApiKey, ct: ct);
preferences.Set(PrefServerHost, host ?? "");
preferences.Set(PrefServerPort, port.ToString());
+ preferences.Set(PrefApiKey, workspace.ApiKey ?? "");
workspace.Host = host ?? "";
workspace.Port = port.ToString();
- workspace.Status = $"Connected ({selectedMode}).";
+ workspace.Status = "Connected (server).";
workspace.NotifyConnectionStateChanged();
+
+ // Auto-save server profile on successful connect
+ profileStore.Upsert(new ServerProfile
+ {
+ Host = host ?? "",
+ Port = port,
+ ApiKey = workspace.ApiKey ?? "",
+ DisplayName = profile?.DisplayName ?? $"{host}:{port}",
+ PerRequestContext = workspace.PerRequestContext ?? profile?.PerRequestContext ?? "",
+ DefaultSessionContext = profile?.DefaultSessionContext ?? ""
+ });
}
catch (Exception ex)
{
diff --git a/src/RemoteAgent.App/Handlers/DeleteServerProfileHandler.cs b/src/RemoteAgent.App/Handlers/DeleteServerProfileHandler.cs
new file mode 100644
index 0000000..9fc6e42
--- /dev/null
+++ b/src/RemoteAgent.App/Handlers/DeleteServerProfileHandler.cs
@@ -0,0 +1,23 @@
+using RemoteAgent.App.Logic;
+using RemoteAgent.App.Logic.Cqrs;
+using RemoteAgent.App.Requests;
+
+namespace RemoteAgent.App.Handlers;
+
+public sealed class DeleteServerProfileHandler(IServerProfileStore profileStore)
+ : IRequestHandler
+{
+ public Task HandleAsync(DeleteServerProfileRequest request, CancellationToken ct = default)
+ {
+ var workspace = request.Workspace;
+ var profile = workspace.SelectedProfile;
+ if (profile == null)
+ return Task.FromResult(CommandResult.Fail("No server selected."));
+
+ profileStore.Delete(profile.Host, profile.Port);
+ workspace.SelectedProfile = null;
+ workspace.RefreshProfiles();
+
+ return Task.FromResult(CommandResult.Ok());
+ }
+}
diff --git a/src/RemoteAgent.App/Handlers/SaveServerProfileHandler.cs b/src/RemoteAgent.App/Handlers/SaveServerProfileHandler.cs
new file mode 100644
index 0000000..c79e113
--- /dev/null
+++ b/src/RemoteAgent.App/Handlers/SaveServerProfileHandler.cs
@@ -0,0 +1,25 @@
+using RemoteAgent.App.Logic;
+using RemoteAgent.App.Logic.Cqrs;
+using RemoteAgent.App.Requests;
+
+namespace RemoteAgent.App.Handlers;
+
+public sealed class SaveServerProfileHandler(IServerProfileStore profileStore)
+ : IRequestHandler
+{
+ public Task HandleAsync(SaveServerProfileRequest request, CancellationToken ct = default)
+ {
+ var workspace = request.Workspace;
+ var profile = workspace.SelectedProfile;
+ if (profile == null)
+ return Task.FromResult(CommandResult.Fail("No server selected."));
+
+ profile.DisplayName = workspace.EditDisplayName;
+ profile.PerRequestContext = workspace.EditPerRequestContext;
+ profile.DefaultSessionContext = workspace.EditDefaultSessionContext;
+ profileStore.Upsert(profile);
+ workspace.RefreshProfiles();
+
+ return Task.FromResult(CommandResult.Ok());
+ }
+}
diff --git a/src/RemoteAgent.App/Handlers/ScanQrCodeHandler.cs b/src/RemoteAgent.App/Handlers/ScanQrCodeHandler.cs
new file mode 100644
index 0000000..930f195
--- /dev/null
+++ b/src/RemoteAgent.App/Handlers/ScanQrCodeHandler.cs
@@ -0,0 +1,109 @@
+using RemoteAgent.App.Logic;
+using RemoteAgent.App.Logic.Cqrs;
+using RemoteAgent.App.Requests;
+using RemoteAgent.App.ViewModels;
+
+namespace RemoteAgent.App.Handlers;
+
+///
+/// Handles QR-code / deep-link pairing.
+///
+/// - If is already set (deep link), it is parsed directly.
+/// - Otherwise the camera scanner is invoked via .
+///
+/// On success the ViewModel's Host, Port, and ApiKey are populated and saved to preferences.
+///
+public sealed class ScanQrCodeHandler(IQrCodeScanner scanner, IAppPreferences preferences)
+ : IRequestHandler
+{
+ private const string PrefServerHost = "ServerHost";
+ private const string PrefServerPort = "ServerPort";
+ private const string PrefApiKey = "ApiKey";
+
+ public async Task HandleAsync(ScanQrCodeRequest request, CancellationToken ct = default)
+ {
+ string? raw;
+ if (!string.IsNullOrWhiteSpace(request.RawUri))
+ {
+ raw = request.RawUri;
+ }
+ else
+ {
+ if (string.IsNullOrWhiteSpace(request.Workspace.Host))
+ {
+ request.Workspace.Status = "Enter a host address before logging in.";
+ return CommandResult.Fail("No host configured.");
+ }
+
+ var webPort = "1" + request.Workspace.Port; // e.g. 5244 → 15244
+ var loginUrl = $"http://{request.Workspace.Host}:{webPort}/pair";
+
+ raw = await scanner.ScanAsync(loginUrl);
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ request.Workspace.Status = "Login cancelled.";
+ return CommandResult.Fail("Login cancelled.");
+ }
+ }
+
+ var result = ParseAndApply(raw.Trim(), request.Workspace);
+ if (result.Success)
+ {
+ preferences.Set(PrefServerHost, request.Workspace.Host);
+ preferences.Set(PrefServerPort, request.Workspace.Port);
+ preferences.Set(PrefApiKey, request.Workspace.ApiKey);
+ }
+ return result;
+ }
+
+ internal static CommandResult ParseAndApply(string raw, MainPageViewModel workspace)
+ {
+ if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri) ||
+ !string.Equals(uri.Scheme, "remoteagent", StringComparison.OrdinalIgnoreCase) ||
+ !string.Equals(uri.Host, "pair", StringComparison.OrdinalIgnoreCase))
+ {
+ workspace.Status = "Invalid pairing URL.";
+ return CommandResult.Fail("Invalid pairing URL.");
+ }
+
+ var query = ParseQuery(uri.Query);
+ var key = query.GetValueOrDefault("key", "");
+ var host = query.GetValueOrDefault("host", "");
+ var port = query.GetValueOrDefault("port", "");
+
+ if (string.IsNullOrWhiteSpace(host))
+ {
+ workspace.Status = "Pairing URL missing host.";
+ return CommandResult.Fail("Pairing URL missing host.");
+ }
+
+ if (!int.TryParse(port, out var portNum) || portNum <= 0 || portNum > 65535)
+ {
+ workspace.Status = "Pairing URL missing or invalid port.";
+ return CommandResult.Fail("Pairing URL missing or invalid port.");
+ }
+
+ workspace.Host = host;
+ workspace.Port = portNum.ToString();
+ workspace.ApiKey = key;
+
+ workspace.Status = "Pairing details loaded. Tap Connect to continue.";
+ return CommandResult.Ok();
+ }
+
+ internal static Dictionary ParseQuery(string query)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var trimmed = query.TrimStart('?');
+ if (string.IsNullOrEmpty(trimmed)) return result;
+ foreach (var part in trimmed.Split('&', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var idx = part.IndexOf('=');
+ if (idx < 0) continue;
+ var k = Uri.UnescapeDataString(part[..idx]);
+ var v = Uri.UnescapeDataString(part[(idx + 1)..]);
+ result[k] = v;
+ }
+ return result;
+ }
+}
diff --git a/src/RemoteAgent.App/MainPage.xaml b/src/RemoteAgent.App/MainPage.xaml
index 2b1fe28..b9a29ab 100644
--- a/src/RemoteAgent.App/MainPage.xaml
+++ b/src/RemoteAgent.App/MainPage.xaml
@@ -2,6 +2,7 @@
-
-
-
@@ -60,15 +58,17 @@
AutomationId="mobile_connect_host"
Text="{Binding Host, Mode=TwoWay}"
PlaceholderColor="{StaticResource OnSurfaceVariantBrush}" />
-
+
-
-
-
+
+
@@ -82,14 +82,14 @@
-
-
-
-
-
+
+
+
+
+
@@ -98,15 +98,16 @@
-
-
-
-
+
+
+
+
-
@@ -124,12 +125,12 @@
-
-
-
+
+
@@ -141,8 +142,10 @@
PlaceholderColor="{StaticResource OnSurfaceVariantBrush}" />
-
+
@@ -254,32 +257,33 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
+
diff --git a/src/RemoteAgent.App/MainPage.xaml.cs b/src/RemoteAgent.App/MainPage.xaml.cs
index 018d967..9d8d029 100644
--- a/src/RemoteAgent.App/MainPage.xaml.cs
+++ b/src/RemoteAgent.App/MainPage.xaml.cs
@@ -6,6 +6,9 @@
using Windows.System;
using Windows.UI.Core;
#endif
+#if ANDROID
+using AndroidX.AppCompat.Widget;
+#endif
namespace RemoteAgent.App;
@@ -15,6 +18,9 @@ public partial class MainPage : ContentPage
#if WINDOWS
private TextBox? _messageTextBox;
#endif
+#if ANDROID
+ private AppCompatEditText? _androidEditText;
+#endif
public MainPage(MainPageViewModel vm)
{
@@ -35,6 +41,9 @@ private void WireMessageInputShortcuts()
{
#if WINDOWS
MessageEditor.HandlerChanged += OnMessageEditorHandlerChanged;
+#endif
+#if ANDROID
+ MessageEditor.HandlerChanged += OnMessageEditorHandlerChangedAndroid;
#endif
}
@@ -65,6 +74,40 @@ private void OnMessageTextBoxKeyDown(object sender, KeyRoutedEventArgs e)
}
#endif
+#if ANDROID
+ private void OnMessageEditorHandlerChangedAndroid(object? sender, EventArgs e)
+ {
+ if (_androidEditText != null)
+ _androidEditText.EditorAction -= OnAndroidEditorAction;
+
+ _androidEditText = MessageEditor?.Handler?.PlatformView as AppCompatEditText;
+ if (_androidEditText != null)
+ {
+ // Multi-line EditText ignores ImeOptions by default (keyboard shows Enter).
+ // SetRawInputType overrides the IME input type without changing the view's
+ // multi-line wrapping behaviour, so the keyboard shows a Send action button.
+ _androidEditText.SetRawInputType(Android.Text.InputTypes.ClassText);
+ _androidEditText.ImeOptions = Android.Views.InputMethods.ImeAction.Send;
+ _androidEditText.SetImeActionLabel("Send", Android.Views.InputMethods.ImeAction.Send);
+ _androidEditText.EditorAction += OnAndroidEditorAction;
+ }
+ }
+
+ private void OnAndroidEditorAction(object? sender, Android.Widget.TextView.EditorActionEventArgs e)
+ {
+ if (e.ActionId == Android.Views.InputMethods.ImeAction.Send ||
+ e.ActionId == Android.Views.InputMethods.ImeAction.Done ||
+ e.ActionId == Android.Views.InputMethods.ImeAction.Unspecified)
+ {
+ e.Handled = true;
+ if (_vm.SendMessageCommand.CanExecute(null))
+ _vm.SendMessageCommand.Execute(null);
+ return;
+ }
+ e.Handled = false;
+ }
+#endif
+
private void CommitSessionTitle()
{
_vm.CommitSessionTitle(SessionTitleEntry.Text ?? "");
diff --git a/src/RemoteAgent.App/MauiProgram.cs b/src/RemoteAgent.App/MauiProgram.cs
index b23d7a5..39049dc 100644
--- a/src/RemoteAgent.App/MauiProgram.cs
+++ b/src/RemoteAgent.App/MauiProgram.cs
@@ -31,12 +31,14 @@ public static MauiApp CreateMauiApp()
var dbPath = Path.Combine(FileSystem.AppDataDirectory, "remote-agent.db");
builder.Services.AddSingleton(_ => new LocalMessageStore(dbPath));
builder.Services.AddSingleton(_ => new LocalSessionStore(dbPath));
+ builder.Services.AddSingleton(_ => new LocalServerProfileStore(dbPath));
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddTransient, ConnectMobileSessionHandler>();
@@ -50,14 +52,16 @@ public static MauiApp CreateMauiApp()
builder.Services.AddTransient, LoadMcpServersHandler>();
builder.Services.AddTransient, SaveMcpServerHandler>();
builder.Services.AddTransient, DeleteMcpServerHandler>();
+ builder.Services.AddTransient, ScanQrCodeHandler>();
+ builder.Services.AddTransient, SaveServerProfileHandler>();
+ builder.Services.AddTransient, DeleteServerProfileHandler>();
+ builder.Services.AddTransient, ClearServerApiKeyHandler>();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton(sp => sp.GetRequiredService());
builder.Services.AddSingleton();
builder.Services.AddSingleton();
- builder.Services.AddSingleton(sp =>
- new MauiConnectionModeSelector(() => sp.GetService()));
builder.Services.AddSingleton(sp =>
new MauiAgentSelector(() => sp.GetService()));
builder.Services.AddSingleton(sp =>
@@ -66,6 +70,7 @@ public static MauiApp CreateMauiApp()
new MauiPromptVariableProvider(() => sp.GetService()));
builder.Services.AddSingleton(sp =>
new MauiSessionTerminationConfirmation(() => sp.GetService()));
+ builder.Services.AddSingleton();
builder.Services.AddSingleton(sp =>
new McpRegistryPageViewModel(
@@ -74,6 +79,7 @@ public static MauiApp CreateMauiApp()
new MauiDeleteMcpServerConfirmation(() => sp.GetService()),
sp.GetRequiredService()));
builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
diff --git a/src/RemoteAgent.App/PairLoginPage.xaml b/src/RemoteAgent.App/PairLoginPage.xaml
new file mode 100644
index 0000000..cb53a3a
--- /dev/null
+++ b/src/RemoteAgent.App/PairLoginPage.xaml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/src/RemoteAgent.App/PairLoginPage.xaml.cs b/src/RemoteAgent.App/PairLoginPage.xaml.cs
new file mode 100644
index 0000000..addba24
--- /dev/null
+++ b/src/RemoteAgent.App/PairLoginPage.xaml.cs
@@ -0,0 +1,48 @@
+namespace RemoteAgent.App;
+
+public partial class PairLoginPage : ContentPage
+{
+ private readonly TaskCompletionSource _tcs = new();
+ private bool _handled;
+
+ public PairLoginPage(string loginUrl)
+ {
+ InitializeComponent();
+ LoginWebView.Source = new UrlWebViewSource { Url = loginUrl };
+ }
+
+ public Task ResultTask => _tcs.Task;
+
+ private async void OnNavigated(object? sender, WebNavigatedEventArgs e)
+ {
+ if (_handled) return;
+ if (!e.Url.Contains("/pair/key", StringComparison.OrdinalIgnoreCase)) return;
+
+ // Extract the deep link href from the "Open in Remote Agent App" anchor.
+ // EvaluateJavaScriptAsync may return a JSON-quoted string on Android; strip quotes if present.
+ var href = await LoginWebView.EvaluateJavaScriptAsync(
+ "document.querySelector('a.btn')?.getAttribute('href')");
+
+ if (string.IsNullOrWhiteSpace(href) || string.Equals(href, "null", StringComparison.Ordinal))
+ return;
+
+ if (href.Length >= 2 && href[0] == '"' && href[^1] == '"')
+ href = href[1..^1];
+
+ if (string.IsNullOrWhiteSpace(href)) return;
+
+ _handled = true;
+ MainThread.BeginInvokeOnMainThread(async () =>
+ {
+ _tcs.TrySetResult(href);
+ await Navigation.PopModalAsync();
+ });
+ }
+
+ private async void OnCancelClicked(object? sender, EventArgs e)
+ {
+ _handled = true;
+ _tcs.TrySetResult(null);
+ await Navigation.PopModalAsync();
+ }
+}
diff --git a/src/RemoteAgent.App/Platforms/Android/AndroidManifest.xml b/src/RemoteAgent.App/Platforms/Android/AndroidManifest.xml
index b2076b0..402a357 100644
--- a/src/RemoteAgent.App/Platforms/Android/AndroidManifest.xml
+++ b/src/RemoteAgent.App/Platforms/Android/AndroidManifest.xml
@@ -4,4 +4,6 @@
+
+
\ No newline at end of file
diff --git a/src/RemoteAgent.App/Platforms/Android/MainActivity.cs b/src/RemoteAgent.App/Platforms/Android/MainActivity.cs
index 68df076..3be4a86 100644
--- a/src/RemoteAgent.App/Platforms/Android/MainActivity.cs
+++ b/src/RemoteAgent.App/Platforms/Android/MainActivity.cs
@@ -1,10 +1,41 @@
using Android.App;
+using Android.Content;
using Android.Content.PM;
using Android.OS;
+using Microsoft.Extensions.DependencyInjection;
+using RemoteAgent.App.Logic;
namespace RemoteAgent.App;
-[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
+[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop,
+ ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode |
+ ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
+[IntentFilter(
+ new[] { Intent.ActionView },
+ Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
+ DataScheme = "remoteagent",
+ DataHost = "pair")]
public class MainActivity : MauiAppCompatActivity
{
+ protected override void OnCreate(Bundle? savedInstanceState)
+ {
+ base.OnCreate(savedInstanceState);
+ HandleDeepLinkIntent(Intent);
+ }
+
+ protected override void OnNewIntent(Intent? intent)
+ {
+ base.OnNewIntent(intent);
+ HandleDeepLinkIntent(intent);
+ }
+
+ private static void HandleDeepLinkIntent(Intent? intent)
+ {
+ if (intent?.Action != Intent.ActionView || intent.Data == null) return;
+ var rawUri = intent.Data.ToString();
+ if (string.IsNullOrEmpty(rawUri)) return;
+
+ var services = IPlatformApplication.Current?.Services;
+ services?.GetService()?.Dispatch(rawUri);
+ }
}
diff --git a/src/RemoteAgent.App/Requests/ClearServerApiKeyRequest.cs b/src/RemoteAgent.App/Requests/ClearServerApiKeyRequest.cs
new file mode 100644
index 0000000..ad3223d
--- /dev/null
+++ b/src/RemoteAgent.App/Requests/ClearServerApiKeyRequest.cs
@@ -0,0 +1,12 @@
+using RemoteAgent.App.Logic.Cqrs;
+using RemoteAgent.App.ViewModels;
+
+namespace RemoteAgent.App.Requests;
+
+public sealed record ClearServerApiKeyRequest(
+ Guid CorrelationId,
+ SettingsPageViewModel Workspace) : IRequest
+{
+ public override string ToString() =>
+ $"ClearServerApiKeyRequest {{ CorrelationId = {CorrelationId} }}";
+}
diff --git a/src/RemoteAgent.App/Requests/DeleteServerProfileRequest.cs b/src/RemoteAgent.App/Requests/DeleteServerProfileRequest.cs
new file mode 100644
index 0000000..46dbbc2
--- /dev/null
+++ b/src/RemoteAgent.App/Requests/DeleteServerProfileRequest.cs
@@ -0,0 +1,12 @@
+using RemoteAgent.App.Logic.Cqrs;
+using RemoteAgent.App.ViewModels;
+
+namespace RemoteAgent.App.Requests;
+
+public sealed record DeleteServerProfileRequest(
+ Guid CorrelationId,
+ SettingsPageViewModel Workspace) : IRequest
+{
+ public override string ToString() =>
+ $"DeleteServerProfileRequest {{ CorrelationId = {CorrelationId} }}";
+}
diff --git a/src/RemoteAgent.App/Requests/SaveServerProfileRequest.cs b/src/RemoteAgent.App/Requests/SaveServerProfileRequest.cs
new file mode 100644
index 0000000..8920a6c
--- /dev/null
+++ b/src/RemoteAgent.App/Requests/SaveServerProfileRequest.cs
@@ -0,0 +1,12 @@
+using RemoteAgent.App.Logic.Cqrs;
+using RemoteAgent.App.ViewModels;
+
+namespace RemoteAgent.App.Requests;
+
+public sealed record SaveServerProfileRequest(
+ Guid CorrelationId,
+ SettingsPageViewModel Workspace) : IRequest
+{
+ public override string ToString() =>
+ $"SaveServerProfileRequest {{ CorrelationId = {CorrelationId} }}";
+}
diff --git a/src/RemoteAgent.App/Requests/ScanQrCodeRequest.cs b/src/RemoteAgent.App/Requests/ScanQrCodeRequest.cs
new file mode 100644
index 0000000..0fd817d
--- /dev/null
+++ b/src/RemoteAgent.App/Requests/ScanQrCodeRequest.cs
@@ -0,0 +1,17 @@
+using RemoteAgent.App.Logic.Cqrs;
+using RemoteAgent.App.ViewModels;
+
+namespace RemoteAgent.App.Requests;
+
+///
+/// Triggers QR-code pairing. When is provided (e.g. from a deep link) the
+/// scanner is bypassed and the URI is parsed directly. When it is null the camera scanner is
+/// invoked so the user can point at a QR code.
+///
+public sealed record ScanQrCodeRequest(
+ Guid CorrelationId,
+ MainPageViewModel Workspace) : IRequest
+{
+ /// Optional pre-supplied pairing URI; set by the deep-link handler.
+ public string? RawUri { get; init; }
+}
diff --git a/src/RemoteAgent.App/Resources/Splash/splash.svg b/src/RemoteAgent.App/Resources/Splash/splash.svg
index 62d66d7..2d5c38b 100644
--- a/src/RemoteAgent.App/Resources/Splash/splash.svg
+++ b/src/RemoteAgent.App/Resources/Splash/splash.svg
@@ -1,8 +1,189 @@
-
-
-
\ No newline at end of file
+
+
diff --git a/src/RemoteAgent.App/Resources/Styles/Styles.xaml b/src/RemoteAgent.App/Resources/Styles/Styles.xaml
index 449d260..1202d64 100644
--- a/src/RemoteAgent.App/Resources/Styles/Styles.xaml
+++ b/src/RemoteAgent.App/Resources/Styles/Styles.xaml
@@ -44,9 +44,9 @@
-
-
-
+
+
+
diff --git a/src/RemoteAgent.App/Services/AgentGatewayClientService.cs b/src/RemoteAgent.App/Services/AgentGatewayClientService.cs
index 270f95c..5e45688 100644
--- a/src/RemoteAgent.App/Services/AgentGatewayClientService.cs
+++ b/src/RemoteAgent.App/Services/AgentGatewayClientService.cs
@@ -17,6 +17,7 @@ public AgentGatewayClientService(ILocalMessageStore? store = null)
_sessionClient = new AgentSessionClient(FormatReceivedMedia);
_sessionClient.ConnectionStateChanged += () => ConnectionStateChanged?.Invoke();
_sessionClient.MessageReceived += OnSessionClientMessageReceived;
+ _sessionClient.FileTransferReceived += OnFileTransferReceived;
}
public ObservableCollection Messages { get; } = new();
@@ -109,6 +110,18 @@ private void OnSessionClientMessageReceived(ChatMessage chat)
});
}
+ private static void OnFileTransferReceived(FileTransfer fileTransfer)
+ {
+ try
+ {
+ FileSaveService.SaveFileTransfer(fileTransfer);
+ }
+ catch
+ {
+ // File save failures are non-fatal; the chat message already shows the transfer.
+ }
+ }
+
private static string FormatReceivedMedia(MediaChunk media)
{
try
diff --git a/src/RemoteAgent.App/Services/DeepLinkService.cs b/src/RemoteAgent.App/Services/DeepLinkService.cs
new file mode 100644
index 0000000..1cbd089
--- /dev/null
+++ b/src/RemoteAgent.App/Services/DeepLinkService.cs
@@ -0,0 +1,43 @@
+using RemoteAgent.App.Logic;
+
+namespace RemoteAgent.App.Services;
+
+///
+/// Singleton that routes deep-link URIs (e.g. from remoteagent://pair?…) to subscribers.
+/// If a URI arrives before any subscriber has registered it is held as a pending link and
+/// delivered the moment the first subscriber calls .
+///
+public sealed class DeepLinkService : IDeepLinkService
+{
+ private readonly object _lock = new();
+ private Action? _handlers;
+ private string? _pending;
+
+ public void Subscribe(Action handler)
+ {
+ string? pending;
+ lock (_lock)
+ {
+ _handlers += handler;
+ pending = _pending;
+ _pending = null;
+ }
+ if (pending != null)
+ handler(pending);
+ }
+
+ public void Dispatch(string rawUri)
+ {
+ Action? snapshot;
+ lock (_lock)
+ {
+ snapshot = _handlers;
+ if (snapshot == null)
+ {
+ _pending = rawUri;
+ return;
+ }
+ }
+ snapshot(rawUri);
+ }
+}
diff --git a/src/RemoteAgent.App/Services/FileSaveService.cs b/src/RemoteAgent.App/Services/FileSaveService.cs
new file mode 100644
index 0000000..725e1f9
--- /dev/null
+++ b/src/RemoteAgent.App/Services/FileSaveService.cs
@@ -0,0 +1,41 @@
+using RemoteAgent.Proto;
+
+namespace RemoteAgent.App.Services;
+
+/// Saves files received via to the device, preserving the relative path hierarchy
+/// under the app data directory. On Android, files are stored under AppDataDirectory/RemoteAgent/Files/.
+public static class FileSaveService
+{
+ /// Saves a file transfer to local storage preserving the directory hierarchy from
+ /// . Returns the saved path for display, or null on failure.
+ public static string? SaveFileTransfer(FileTransfer fileTransfer)
+ {
+ if (fileTransfer.Content == null || fileTransfer.Content.Length == 0)
+ return null;
+
+ var relativePath = fileTransfer.RelativePath;
+ if (string.IsNullOrWhiteSpace(relativePath))
+ relativePath = $"file_{DateTime.UtcNow:yyyyMMdd_HHmmss}.bin";
+
+ // Normalize to forward slashes and sanitize
+ relativePath = relativePath.Replace('\\', '/');
+
+ // Build the full path under AppDataDirectory/RemoteAgent/Files/
+ var basePath = Path.Combine(FileSystem.AppDataDirectory, "RemoteAgent", "Files");
+ var fullPath = Path.Combine(basePath, relativePath.Replace('/', Path.DirectorySeparatorChar));
+
+ try
+ {
+ var directory = Path.GetDirectoryName(fullPath);
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+
+ File.WriteAllBytes(fullPath, fileTransfer.Content.ToByteArray());
+ return relativePath;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}
diff --git a/src/RemoteAgent.App/Services/LocalServerProfileStore.cs b/src/RemoteAgent.App/Services/LocalServerProfileStore.cs
new file mode 100644
index 0000000..25061b9
--- /dev/null
+++ b/src/RemoteAgent.App/Services/LocalServerProfileStore.cs
@@ -0,0 +1,123 @@
+using LiteDB;
+using RemoteAgent.App.Logic;
+
+namespace RemoteAgent.App.Services;
+
+/// LiteDB implementation of .
+public sealed class LocalServerProfileStore : IServerProfileStore
+{
+ private readonly string _dbPath;
+ private const string CollectionName = "server_profiles";
+
+ public LocalServerProfileStore(string dbPath)
+ {
+ _dbPath = dbPath;
+ }
+
+ public IReadOnlyList GetAll()
+ {
+ try
+ {
+ using var db = new LiteDatabase(_dbPath);
+ var col = db.GetCollection(CollectionName);
+ return col.FindAll().Select(ToProfile).ToList();
+ }
+ catch
+ {
+ return Array.Empty();
+ }
+ }
+
+ public ServerProfile? GetByHostPort(string host, int port)
+ {
+ if (string.IsNullOrWhiteSpace(host)) return null;
+ try
+ {
+ using var db = new LiteDatabase(_dbPath);
+ var col = db.GetCollection(CollectionName);
+ var key = MakeKey(host, port);
+ var record = col.FindOne(r => r.HostPortKey == key);
+ return record == null ? null : ToProfile(record);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ public void Upsert(ServerProfile profile)
+ {
+ using var db = new LiteDatabase(_dbPath);
+ var col = db.GetCollection(CollectionName);
+ col.EnsureIndex(r => r.HostPortKey, true);
+
+ var key = MakeKey(profile.Host, profile.Port);
+ var existing = col.FindOne(r => r.HostPortKey == key);
+
+ if (existing != null)
+ {
+ existing.Host = (profile.Host ?? "").Trim();
+ existing.Port = profile.Port;
+ existing.ApiKey = profile.ApiKey ?? "";
+ existing.DisplayName = profile.DisplayName ?? "";
+ existing.PerRequestContext = profile.PerRequestContext ?? "";
+ existing.DefaultSessionContext = profile.DefaultSessionContext ?? "";
+ existing.HostPortKey = key;
+ col.Update(existing);
+ }
+ else
+ {
+ col.Insert(new StoredProfile
+ {
+ Host = (profile.Host ?? "").Trim(),
+ Port = profile.Port,
+ ApiKey = profile.ApiKey ?? "",
+ DisplayName = profile.DisplayName ?? "",
+ PerRequestContext = profile.PerRequestContext ?? "",
+ DefaultSessionContext = profile.DefaultSessionContext ?? "",
+ HostPortKey = key
+ });
+ }
+ }
+
+ public bool Delete(string host, int port)
+ {
+ if (string.IsNullOrWhiteSpace(host)) return false;
+ try
+ {
+ using var db = new LiteDatabase(_dbPath);
+ var col = db.GetCollection(CollectionName);
+ var key = MakeKey(host, port);
+ return col.DeleteMany(r => r.HostPortKey == key) > 0;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static string MakeKey(string host, int port) =>
+ $"{(host ?? "").Trim().ToLowerInvariant()}:{port}";
+
+ private static ServerProfile ToProfile(StoredProfile r) => new()
+ {
+ Host = r.Host,
+ Port = r.Port,
+ ApiKey = r.ApiKey,
+ DisplayName = r.DisplayName,
+ PerRequestContext = r.PerRequestContext,
+ DefaultSessionContext = r.DefaultSessionContext
+ };
+
+ private sealed class StoredProfile
+ {
+ public int Id { get; set; }
+ public string HostPortKey { get; set; } = "";
+ public string Host { get; set; } = "";
+ public int Port { get; set; }
+ public string ApiKey { get; set; } = "";
+ public string DisplayName { get; set; } = "";
+ public string PerRequestContext { get; set; } = "";
+ public string DefaultSessionContext { get; set; } = "";
+ }
+}
diff --git a/src/RemoteAgent.App/Services/MauiPlatformServices.cs b/src/RemoteAgent.App/Services/MauiPlatformServices.cs
index 186dd0e..8bb9542 100644
--- a/src/RemoteAgent.App/Services/MauiPlatformServices.cs
+++ b/src/RemoteAgent.App/Services/MauiPlatformServices.cs
@@ -3,20 +3,6 @@
namespace RemoteAgent.App.Services;
-public sealed class MauiConnectionModeSelector(Func pageFactory) : IConnectionModeSelector
-{
- public async Task SelectAsync()
- {
- var page = pageFactory();
- if (page == null) return null;
-
- var choice = await page.DisplayActionSheetAsync("Connection mode", "Cancel", null, "Direct", "Server");
- if (string.IsNullOrWhiteSpace(choice) || string.Equals(choice, "Cancel", StringComparison.OrdinalIgnoreCase))
- return null;
- return string.Equals(choice, "Direct", StringComparison.OrdinalIgnoreCase) ? "direct" : "server";
- }
-}
-
public sealed class MauiAgentSelector(Func pageFactory) : IAgentSelector
{
public async Task SelectAsync(ServerInfoResponse serverInfo)
@@ -136,4 +122,15 @@ public void Show(string title, string body)
PlatformNotificationService.ShowNotification(title, body);
#endif
}
+}
+
+public sealed class MauiQrCodeScanner : IQrCodeScanner
+{
+ public async Task ScanAsync(string loginUrl)
+ {
+ var scanPage = new PairLoginPage(loginUrl);
+ await MainThread.InvokeOnMainThreadAsync(
+ () => Shell.Current.Navigation.PushModalAsync(scanPage, animated: true));
+ return await scanPage.ResultTask;
+ }
}
\ No newline at end of file
diff --git a/src/RemoteAgent.App/SettingsPage.xaml b/src/RemoteAgent.App/SettingsPage.xaml
index c62bde0..b70269e 100644
--- a/src/RemoteAgent.App/SettingsPage.xaml
+++ b/src/RemoteAgent.App/SettingsPage.xaml
@@ -1,13 +1,85 @@
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/RemoteAgent.App/SettingsPage.xaml.cs b/src/RemoteAgent.App/SettingsPage.xaml.cs
index b4332a6..d018da9 100644
--- a/src/RemoteAgent.App/SettingsPage.xaml.cs
+++ b/src/RemoteAgent.App/SettingsPage.xaml.cs
@@ -1,9 +1,21 @@
+using RemoteAgent.App.ViewModels;
+
namespace RemoteAgent.App;
public partial class SettingsPage : ContentPage
{
- public SettingsPage()
+ private readonly SettingsPageViewModel _vm;
+
+ public SettingsPage(SettingsPageViewModel vm)
{
+ _vm = vm;
+ BindingContext = _vm;
InitializeComponent();
}
+
+ protected override void OnAppearing()
+ {
+ base.OnAppearing();
+ _vm.RefreshProfiles();
+ }
}
diff --git a/src/RemoteAgent.App/ViewModels/MainPageViewModel.cs b/src/RemoteAgent.App/ViewModels/MainPageViewModel.cs
index 397eeb2..70c87dd 100644
--- a/src/RemoteAgent.App/ViewModels/MainPageViewModel.cs
+++ b/src/RemoteAgent.App/ViewModels/MainPageViewModel.cs
@@ -15,13 +15,16 @@ public sealed class MainPageViewModel : INotifyPropertyChanged, ISessionCommandB
private const string PrefServerHost = "ServerHost";
private const string PrefServerPort = "ServerPort";
private const string PrefPerRequestContext = "PerRequestContext";
+ private const string PrefApiKey = "ApiKey";
private const string DefaultPort = "5243";
+ /// Well-known ports offered in the port picker (Linux/Docker = 5243, Windows service = 5244).
+ public static readonly IReadOnlyList AvailablePorts = ["5243", "5244"];
+
private readonly ISessionStore _sessionStore;
private readonly IAgentGatewayClient _gateway;
private readonly IServerApiClient _apiClient;
private readonly IAppPreferences _preferences;
- private readonly IConnectionModeSelector _connectionModeSelector;
private readonly IAgentSelector _agentSelector;
private readonly IAttachmentPicker _attachmentPicker;
private readonly IPromptTemplateSelector _promptTemplateSelector;
@@ -34,6 +37,7 @@ public sealed class MainPageViewModel : INotifyPropertyChanged, ISessionCommandB
private string _host = "";
private string _port = DefaultPort;
+ private string _apiKey = "";
private string _status = "Enter host and port, then Connect.";
private string _pendingMessage = "";
private string _perRequestContext = "";
@@ -44,20 +48,19 @@ public MainPageViewModel(
IAgentGatewayClient gateway,
IServerApiClient apiClient,
IAppPreferences preferences,
- IConnectionModeSelector connectionModeSelector,
IAgentSelector agentSelector,
IAttachmentPicker attachmentPicker,
IPromptTemplateSelector promptTemplateSelector,
IPromptVariableProvider promptVariableProvider,
ISessionTerminationConfirmation sessionTerminationConfirmation,
INotificationService notificationService,
- IRequestDispatcher dispatcher)
+ IRequestDispatcher dispatcher,
+ IDeepLinkService deepLinkService)
{
_sessionStore = sessionStore;
_gateway = gateway;
_apiClient = apiClient;
_preferences = preferences;
- _connectionModeSelector = connectionModeSelector;
_agentSelector = agentSelector;
_attachmentPicker = attachmentPicker;
_promptTemplateSelector = promptTemplateSelector;
@@ -66,7 +69,7 @@ public MainPageViewModel(
_notificationService = notificationService;
_dispatcher = dispatcher;
- ConnectCommand = new Command(async () => await RunAsync(new ConnectMobileSessionRequest(Guid.NewGuid(), this)), () => !_gateway.IsConnected);
+ ConnectCommand = new Command(async () => await RunAsync(new ConnectMobileSessionRequest(Guid.NewGuid(), this)), () => !_gateway.IsConnected && HasApiKey);
DisconnectCommand = new Command(async () => await RunAsync(new DisconnectMobileSessionRequest(Guid.NewGuid(), this)), () => _gateway.IsConnected);
NewSessionCommand = new Command(async () => await RunAsync(new CreateMobileSessionRequest(Guid.NewGuid(), this)));
TerminateCurrentSessionCommand = new Command(async () => await RunAsync(new TerminateMobileSessionRequest(Guid.NewGuid(), CurrentSession, this)));
@@ -76,6 +79,7 @@ public MainPageViewModel(
ArchiveMessageCommand = new Command(async msg => await RunAsync(new ArchiveMobileMessageRequest(Guid.NewGuid(), msg, this)));
UsePromptTemplateCommand = new Command(async () => await RunAsync(new UsePromptTemplateRequest(Guid.NewGuid(), this)));
BeginEditTitleCommand = new Command(() => { if (CurrentSession != null) IsEditingTitle = true; });
+ ScanQrCodeCommand = new Command(async () => await RunAsync(new ScanQrCodeRequest(Guid.NewGuid(), this)), () => !HasApiKey && !string.IsNullOrWhiteSpace(_host));
_gateway.ConnectionStateChanged += OnGatewayConnectionStateChanged;
_gateway.MessageReceived += OnGatewayMessageReceived;
@@ -84,6 +88,8 @@ public MainPageViewModel(
LoadSessions();
if (Sessions.Count > 0)
CurrentSession = Sessions[0];
+
+ deepLinkService.Subscribe(uri => _ = RunAsync(new ScanQrCodeRequest(Guid.NewGuid(), this) { RawUri = uri }));
}
public event PropertyChangedEventHandler? PropertyChanged;
@@ -101,11 +107,16 @@ public MainPageViewModel(
public ICommand ArchiveMessageCommand { get; }
public ICommand UsePromptTemplateCommand { get; }
public ICommand BeginEditTitleCommand { get; }
+ public ICommand ScanQrCodeCommand { get; }
public string Host
{
get => _host;
- set => Set(ref _host, value);
+ set
+ {
+ if (Set(ref _host, value))
+ ((Command)ScanQrCodeCommand).ChangeCanExecute();
+ }
}
public string Port
@@ -114,6 +125,23 @@ public string Port
set => Set(ref _port, value);
}
+ public string ApiKey
+ {
+ get => _apiKey;
+ set
+ {
+ if (Set(ref _apiKey, value ?? ""))
+ {
+ _preferences.Set(PrefApiKey, _apiKey);
+ OnPropertyChanged(nameof(HasApiKey));
+ ((Command)ConnectCommand).ChangeCanExecute();
+ ((Command)ScanQrCodeCommand).ChangeCanExecute();
+ }
+ }
+ }
+
+ public bool HasApiKey => !string.IsNullOrWhiteSpace(_apiKey);
+
public string Status
{
get => _status;
@@ -151,7 +179,6 @@ public SessionItem? CurrentSession
else
_gateway.LoadFromStore(null);
OnPropertyChanged(nameof(CurrentSessionTitle));
- OnPropertyChanged(nameof(ConnectionModeLabel));
OnPropertyChanged(nameof(CurrentSessionTitleEditorText));
}
}
@@ -169,9 +196,8 @@ public string CurrentSessionTitleEditorText
}
}
- public string ConnectionModeLabel => $"Mode: {(string.Equals(CurrentSession?.ConnectionMode, "direct", StringComparison.OrdinalIgnoreCase) ? "direct" : "server")}";
-
public bool IsConnected => _gateway.IsConnected;
+ public event Action? ConnectionStateChanged;
public bool IsEditingTitle
{
get => _isEditingTitle;
@@ -206,6 +232,7 @@ private void LoadSavedServerDetails()
Host = _preferences.Get(PrefServerHost, "");
Port = _preferences.Get(PrefServerPort, DefaultPort);
PerRequestContext = _preferences.Get(PrefPerRequestContext, "");
+ _apiKey = _preferences.Get(PrefApiKey, "");
}
private void SaveServerDetails(string host, int port)
@@ -260,6 +287,7 @@ public bool SelectSession(string? sessionId)
private void OnGatewayConnectionStateChanged()
{
OnPropertyChanged(nameof(IsConnected));
+ ConnectionStateChanged?.Invoke();
((Command)ConnectCommand).ChangeCanExecute();
((Command)DisconnectCommand).ChangeCanExecute();
((Command)SendMessageCommand).ChangeCanExecute();
diff --git a/src/RemoteAgent.App/ViewModels/SettingsPageViewModel.cs b/src/RemoteAgent.App/ViewModels/SettingsPageViewModel.cs
new file mode 100644
index 0000000..27d70b4
--- /dev/null
+++ b/src/RemoteAgent.App/ViewModels/SettingsPageViewModel.cs
@@ -0,0 +1,131 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using System.Windows.Input;
+using RemoteAgent.App.Logic;
+using RemoteAgent.App.Logic.Cqrs;
+using RemoteAgent.App.Requests;
+
+namespace RemoteAgent.App.ViewModels;
+
+/// ViewModel for the Settings page — manages saved server profiles.
+public sealed class SettingsPageViewModel : INotifyPropertyChanged
+{
+ private readonly IServerProfileStore _profileStore;
+ private readonly IRequestDispatcher _dispatcher;
+ private ServerProfile? _selectedProfile;
+ private string _editDisplayName = "";
+ private string _editPerRequestContext = "";
+ private string _editDefaultSessionContext = "";
+ private bool _hasApiKey;
+
+ public SettingsPageViewModel(IServerProfileStore profileStore, IRequestDispatcher dispatcher)
+ {
+ _profileStore = profileStore;
+ _dispatcher = dispatcher;
+ SaveCommand = new Command(async () => await RunAsync(new SaveServerProfileRequest(Guid.NewGuid(), this)),
+ () => _selectedProfile != null);
+ DeleteCommand = new Command(async () => await RunAsync(new DeleteServerProfileRequest(Guid.NewGuid(), this)),
+ () => _selectedProfile != null);
+ ClearApiKeyCommand = new Command(async () => await RunAsync(new ClearServerApiKeyRequest(Guid.NewGuid(), this)),
+ () => _selectedProfile != null && _hasApiKey);
+ RefreshProfiles();
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public ObservableCollection Profiles { get; } = [];
+
+ public ServerProfile? SelectedProfile
+ {
+ get => _selectedProfile;
+ set
+ {
+ if (_selectedProfile == value) return;
+ _selectedProfile = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(HasSelection));
+ LoadSelectedProfile();
+ ((Command)SaveCommand).ChangeCanExecute();
+ ((Command)DeleteCommand).ChangeCanExecute();
+ }
+ }
+
+ public bool HasSelection => _selectedProfile != null;
+
+ public bool HasApiKey
+ {
+ get => _hasApiKey;
+ set => Set(ref _hasApiKey, value);
+ }
+
+ public string EditDisplayName
+ {
+ get => _editDisplayName;
+ set => Set(ref _editDisplayName, value);
+ }
+
+ public string EditPerRequestContext
+ {
+ get => _editPerRequestContext;
+ set => Set(ref _editPerRequestContext, value);
+ }
+
+ public string EditDefaultSessionContext
+ {
+ get => _editDefaultSessionContext;
+ set => Set(ref _editDefaultSessionContext, value);
+ }
+
+ public ICommand SaveCommand { get; }
+ public ICommand DeleteCommand { get; }
+ public ICommand ClearApiKeyCommand { get; }
+
+ public void RefreshProfiles()
+ {
+ Profiles.Clear();
+ foreach (var p in _profileStore.GetAll())
+ Profiles.Add(p);
+ }
+
+ private void LoadSelectedProfile()
+ {
+ if (_selectedProfile == null)
+ {
+ EditDisplayName = "";
+ EditPerRequestContext = "";
+ EditDefaultSessionContext = "";
+ HasApiKey = false;
+ ((Command)ClearApiKeyCommand).ChangeCanExecute();
+ return;
+ }
+
+ EditDisplayName = _selectedProfile.DisplayName;
+ EditPerRequestContext = _selectedProfile.PerRequestContext;
+ EditDefaultSessionContext = _selectedProfile.DefaultSessionContext;
+ HasApiKey = !string.IsNullOrEmpty(_selectedProfile.ApiKey);
+ ((Command)ClearApiKeyCommand).ChangeCanExecute();
+ }
+
+ private async Task RunAsync(IRequest request)
+ {
+ try
+ {
+ await _dispatcher.SendAsync(request);
+ }
+ catch
+ {
+ // Best effort — profile operations are local-only.
+ }
+ }
+
+ private void Set(ref T field, T value, [CallerMemberName] string? propertyName = null)
+ {
+ if (EqualityComparer.Default.Equals(field, value)) return;
+ field = value;
+ OnPropertyChanged(propertyName);
+ }
+
+ private void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+}
diff --git a/src/RemoteAgent.Desktop/App.axaml b/src/RemoteAgent.Desktop/App.axaml
index ac582a6..1450294 100644
--- a/src/RemoteAgent.Desktop/App.axaml
+++ b/src/RemoteAgent.Desktop/App.axaml
@@ -41,5 +41,9 @@
+
+
diff --git a/src/RemoteAgent.Desktop/App.axaml.cs b/src/RemoteAgent.Desktop/App.axaml.cs
index 66783b4..139f9c1 100644
--- a/src/RemoteAgent.Desktop/App.axaml.cs
+++ b/src/RemoteAgent.Desktop/App.axaml.cs
@@ -1,6 +1,7 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
+using Avalonia.Media;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using RemoteAgent.App.Logic;
@@ -31,6 +32,18 @@ public override void Initialize()
StartupDiagnostics.LogException("App.Initialize failed during AvaloniaXamlLoader.Load", ex);
throw;
}
+
+ if (OperatingSystem.IsWindows())
+ {
+ Resources["IconFontFamily"] = new FontFamily("Segoe MDL2 Assets");
+ Resources["AppFontFamily"] = new FontFamily("Segoe UI");
+ }
+ else
+ {
+ Resources["IconFontFamily"] = FontFamily.Default;
+ Resources["AppFontFamily"] = FontFamily.Default;
+ }
+
StartupDiagnostics.Log("App.Initialize complete.");
}
@@ -90,7 +103,12 @@ private static void ConfigureServices(IServiceCollection services)
services.AddSingleton();
services.AddLogging(builder => builder.AddDebug().SetMinimumLevel(LogLevel.Debug));
services.AddSingleton();
- services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(sp => new AppLogViewModel(
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ dataDir));
services.AddSingleton();
services.AddTransient();
@@ -126,6 +144,11 @@ private static void ConfigureServices(IServiceCollection services)
services.AddTransient>, StartLogMonitoringHandler>();
services.AddTransient, ClearAppLogHandler>();
services.AddTransient, SaveAppLogHandler>();
+ services.AddTransient, CopyStatusLogHandler>();
+ services.AddTransient, OpenLogsFolderHandler>();
+ services.AddTransient, RefreshAgentsHandler>();
+ services.AddTransient, SetPairingUserHandler>();
+ services.AddSingleton(sp => new AvaloniaPairingUserDialog());
services.AddSingleton();
services.AddSingleton();
diff --git a/src/RemoteAgent.Desktop/Assets/AppIcon/appicon-16.png b/src/RemoteAgent.Desktop/Assets/AppIcon/appicon-16.png
new file mode 100644
index 0000000..e65640d
Binary files /dev/null and b/src/RemoteAgent.Desktop/Assets/AppIcon/appicon-16.png differ
diff --git a/src/RemoteAgent.Desktop/Assets/AppIcon/appicon-32.png b/src/RemoteAgent.Desktop/Assets/AppIcon/appicon-32.png
new file mode 100644
index 0000000..7e9b708
Binary files /dev/null and b/src/RemoteAgent.Desktop/Assets/AppIcon/appicon-32.png differ
diff --git a/src/RemoteAgent.Desktop/Assets/AppIcon/appicon-48.png b/src/RemoteAgent.Desktop/Assets/AppIcon/appicon-48.png
new file mode 100644
index 0000000..2da0398
Binary files /dev/null and b/src/RemoteAgent.Desktop/Assets/AppIcon/appicon-48.png differ
diff --git a/src/RemoteAgent.Desktop/Assets/AppIcon/appicon.png b/src/RemoteAgent.Desktop/Assets/AppIcon/appicon.png
new file mode 100644
index 0000000..614e32a
Binary files /dev/null and b/src/RemoteAgent.Desktop/Assets/AppIcon/appicon.png differ
diff --git a/src/RemoteAgent.Desktop/Handlers/CopyStatusLogHandler.cs b/src/RemoteAgent.Desktop/Handlers/CopyStatusLogHandler.cs
new file mode 100644
index 0000000..b543abb
--- /dev/null
+++ b/src/RemoteAgent.Desktop/Handlers/CopyStatusLogHandler.cs
@@ -0,0 +1,28 @@
+using System.Text;
+using RemoteAgent.App.Logic.Cqrs;
+using RemoteAgent.Desktop.Infrastructure;
+using RemoteAgent.Desktop.Requests;
+
+namespace RemoteAgent.Desktop.Handlers;
+
+/// Formats all status log entries as a Markdown list (oldest-first) and writes them to the clipboard.
+public sealed class CopyStatusLogHandler(IClipboardService clipboard)
+ : IRequestHandler
+{
+ public async Task HandleAsync(
+ CopyStatusLogRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (request.Entries.Count == 0)
+ return CommandResult.Fail("Status log is empty.");
+
+ var sb = new StringBuilder();
+ sb.AppendLine("# Status Log");
+ sb.AppendLine();
+ foreach (var entry in request.Entries.Reverse())
+ sb.AppendLine($"- `{entry.Timestamp:yyyy-MM-dd HH:mm:ss}` {entry.Message}");
+
+ await clipboard.SetTextAsync(sb.ToString());
+ return CommandResult.Ok();
+ }
+}
diff --git a/src/RemoteAgent.Desktop/Handlers/CreateDesktopSessionHandler.cs b/src/RemoteAgent.Desktop/Handlers/CreateDesktopSessionHandler.cs
index 8992766..9b9c10e 100644
--- a/src/RemoteAgent.Desktop/Handlers/CreateDesktopSessionHandler.cs
+++ b/src/RemoteAgent.Desktop/Handlers/CreateDesktopSessionHandler.cs
@@ -41,6 +41,9 @@ public async Task HandleAsync(
var session = sessionFactory.Create(request.Title, request.ConnectionMode, request.AgentId);
session.Messages.Add($"[{DateTimeOffset.UtcNow:HH:mm:ss}] session initialized ({session.ConnectionMode}).");
+ // Register UI message handlers BEFORE connecting so no messages are dropped.
+ request.Workspace.RegisterSessionEvents(session);
+
var connectHost = string.Equals(request.ConnectionMode, "direct", StringComparison.OrdinalIgnoreCase)
? "127.0.0.1"
: request.Host;
diff --git a/src/RemoteAgent.Desktop/Handlers/OpenLogsFolderHandler.cs b/src/RemoteAgent.Desktop/Handlers/OpenLogsFolderHandler.cs
new file mode 100644
index 0000000..8fd0267
--- /dev/null
+++ b/src/RemoteAgent.Desktop/Handlers/OpenLogsFolderHandler.cs
@@ -0,0 +1,21 @@
+using RemoteAgent.App.Logic.Cqrs;
+using RemoteAgent.Desktop.Infrastructure;
+using RemoteAgent.Desktop.Requests;
+
+namespace RemoteAgent.Desktop.Handlers;
+
+/// Opens the application logs folder in the platform file manager.
+public sealed class OpenLogsFolderHandler(IFolderOpenerService folderOpener)
+ : IRequestHandler
+{
+ public Task HandleAsync(
+ OpenLogsFolderRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (!Directory.Exists(request.FolderPath))
+ return Task.FromResult(CommandResult.Fail($"Logs folder not found: {request.FolderPath}"));
+
+ folderOpener.OpenFolder(request.FolderPath);
+ return Task.FromResult(CommandResult.Ok());
+ }
+}
diff --git a/src/RemoteAgent.Desktop/Handlers/OpenNewSessionHandler.cs b/src/RemoteAgent.Desktop/Handlers/OpenNewSessionHandler.cs
index 505403e..525d56b 100644
--- a/src/RemoteAgent.Desktop/Handlers/OpenNewSessionHandler.cs
+++ b/src/RemoteAgent.Desktop/Handlers/OpenNewSessionHandler.cs
@@ -18,6 +18,10 @@ public async Task HandleAsync(
return CommandResult.Fail("No owner window available.");
var workspace = request.Workspace;
+ var availableAgents = workspace.Agents.Agents
+ .Select(a => a.AgentId)
+ .ToList();
+
var defaults = new ConnectionSettingsDefaults(
workspace.Host,
workspace.Port,
@@ -25,7 +29,8 @@ public async Task HandleAsync(
workspace.SelectedAgentId,
workspace.ApiKey,
workspace.PerRequestContext,
- workspace.ConnectionModes);
+ workspace.ConnectionModes,
+ availableAgents);
var result = await dialogService.ShowAsync(ownerWindow, defaults, cancellationToken);
if (result is null)
diff --git a/src/RemoteAgent.Desktop/Handlers/RefreshAgentsHandler.cs b/src/RemoteAgent.Desktop/Handlers/RefreshAgentsHandler.cs
new file mode 100644
index 0000000..c7d9c8f
--- /dev/null
+++ b/src/RemoteAgent.Desktop/Handlers/RefreshAgentsHandler.cs
@@ -0,0 +1,108 @@
+using RemoteAgent.App.Logic;
+using RemoteAgent.App.Logic.Cqrs;
+using RemoteAgent.Desktop.Infrastructure;
+using RemoteAgent.Desktop.Requests;
+
+namespace RemoteAgent.Desktop.Handlers;
+
+public sealed class RefreshAgentsHandler(IServerCapacityClient client)
+ : IRequestHandler
+{
+ public async Task HandleAsync(RefreshAgentsRequest request, CancellationToken cancellationToken = default)
+ {
+ // Try the new ListAgentRunners API first (provides full runner details).
+ try
+ {
+ var runnersResponse = await ServerApiClient.ListAgentRunnersAsync(
+ request.Host, request.Port, request.ApiKey, cancellationToken, throwOnError: true);
+
+ if (runnersResponse != null)
+ {
+ // Also get server version via GetServerInfo.
+ var serverInfo = await client.GetServerInfoAsync(request.Host, request.Port, request.ApiKey, cancellationToken);
+ request.Workspace.ServerVersion = serverInfo?.ServerVersion ?? "";
+
+ var agents = new List();
+ foreach (var runner in runnersResponse.Runners)
+ {
+ var isDefault = string.Equals(runner.RunnerId, request.CurrentDefaultAgentId, StringComparison.OrdinalIgnoreCase)
+ || runner.IsDefault;
+ var remaining = runner.MaxConcurrentSessions > 0
+ ? runner.MaxConcurrentSessions - runner.ActiveSessionCount
+ : (int?)null;
+ agents.Add(new AgentSnapshot(
+ runner.RunnerId,
+ runner.ActiveSessionCount,
+ runner.MaxConcurrentSessions > 0 ? runner.MaxConcurrentSessions : null,
+ remaining,
+ isDefault,
+ runner.RunnerType,
+ runner.Command,
+ runner.Arguments,
+ runner.Description));
+ }
+
+ request.Workspace.Agents.Clear();
+ foreach (var agent in agents)
+ request.Workspace.Agents.Add(agent);
+
+ request.Workspace.SelectedAgent = request.Workspace.Agents.FirstOrDefault(a => a.IsDefault)
+ ?? request.Workspace.Agents.FirstOrDefault();
+
+ request.Workspace.AgentsStatus = $"Loaded {agents.Count} agent runner(s) from server v{request.Workspace.ServerVersion}.";
+ return CommandResult.Ok();
+ }
+ }
+ catch
+ {
+ // Fall through to legacy approach if ListAgentRunners is not available.
+ }
+
+ // Legacy fallback: use GetServerInfo + per-agent CheckSessionCapacity.
+ var info = await client.GetServerInfoAsync(request.Host, request.Port, request.ApiKey, cancellationToken);
+ if (info == null)
+ {
+ request.Workspace.AgentsStatus = "Failed to retrieve server info.";
+ return CommandResult.Fail("Failed to retrieve server info.");
+ }
+
+ request.Workspace.ServerVersion = info.ServerVersion;
+
+ var legacyAgents = new List();
+ foreach (var agentId in info.AvailableAgents)
+ {
+ var isDefault = string.Equals(agentId, request.CurrentDefaultAgentId, StringComparison.OrdinalIgnoreCase);
+ try
+ {
+ var capacity = await client.GetCapacityAsync(request.Host, request.Port, agentId, request.ApiKey, cancellationToken);
+ if (capacity != null)
+ {
+ legacyAgents.Add(new AgentSnapshot(
+ agentId,
+ capacity.AgentActiveSessionCount,
+ capacity.AgentMaxConcurrentSessions,
+ capacity.RemainingAgentCapacity,
+ isDefault));
+ }
+ else
+ {
+ legacyAgents.Add(new AgentSnapshot(agentId, 0, null, null, isDefault));
+ }
+ }
+ catch
+ {
+ legacyAgents.Add(new AgentSnapshot(agentId, 0, null, null, isDefault));
+ }
+ }
+
+ request.Workspace.Agents.Clear();
+ foreach (var agent in legacyAgents)
+ request.Workspace.Agents.Add(agent);
+
+ request.Workspace.SelectedAgent = request.Workspace.Agents.FirstOrDefault(a => a.IsDefault)
+ ?? request.Workspace.Agents.FirstOrDefault();
+
+ request.Workspace.AgentsStatus = $"Loaded {legacyAgents.Count} agent(s) from server v{info.ServerVersion}.";
+ return CommandResult.Ok();
+ }
+}
diff --git a/src/RemoteAgent.Desktop/Handlers/SaveServerRegistrationHandler.cs b/src/RemoteAgent.Desktop/Handlers/SaveServerRegistrationHandler.cs
index d2baaa1..ed566a7 100644
--- a/src/RemoteAgent.Desktop/Handlers/SaveServerRegistrationHandler.cs
+++ b/src/RemoteAgent.Desktop/Handlers/SaveServerRegistrationHandler.cs
@@ -25,7 +25,9 @@ public Task> HandleAsync(
: request.DisplayName.Trim(),
Host = request.Host.Trim(),
Port = request.Port,
- ApiKey = request.ApiKey ?? ""
+ ApiKey = request.ApiKey ?? "",
+ PerRequestContext = request.PerRequestContext ?? "",
+ DefaultSessionContext = request.DefaultSessionContext ?? ""
};
var saved = store.Upsert(registration);
diff --git a/src/RemoteAgent.Desktop/Handlers/SetPairingUserHandler.cs b/src/RemoteAgent.Desktop/Handlers/SetPairingUserHandler.cs
new file mode 100644
index 0000000..fdb7a67
--- /dev/null
+++ b/src/RemoteAgent.Desktop/Handlers/SetPairingUserHandler.cs
@@ -0,0 +1,53 @@
+using RemoteAgent.App.Logic.Cqrs;
+using RemoteAgent.Desktop.Infrastructure;
+using RemoteAgent.Desktop.Requests;
+
+namespace RemoteAgent.Desktop.Handlers;
+
+/// Handles : shows the dialog, then calls the gRPC SetPairingUsers RPC.
+public sealed class SetPairingUserHandler(IServerCapacityClient client, IPairingUserDialog dialog)
+ : IRequestHandler
+{
+ ///
+ public async Task HandleAsync(
+ SetPairingUserRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var ownerWindow = request.OwnerWindowFactory();
+ if (ownerWindow is null)
+ return CommandResult.Fail("No owner window available.");
+
+ var result = await dialog.ShowAsync(ownerWindow, cancellationToken);
+ if (result is null)
+ return CommandResult.Ok(); // user cancelled
+
+ bool success;
+ string generatedApiKey;
+ try
+ {
+ generatedApiKey = await client.SetPairingUsersAsync(
+ request.Host,
+ request.Port,
+ [(result.Username, result.PasswordHash)],
+ replace: false,
+ request.ApiKey,
+ cancellationToken);
+ success = true;
+ }
+ catch (Exception ex)
+ {
+ request.Workspace.StatusText = $"Failed to set pairing user: {ex.Message}";
+ return CommandResult.Fail(ex.Message);
+ }
+
+ if (!success)
+ {
+ request.Workspace.StatusText = "Failed to set pairing user.";
+ return CommandResult.Fail("Failed to set pairing user.");
+ }
+
+ request.Workspace.ApiKey = generatedApiKey;
+ request.Workspace.StatusText = $"Pairing user '{result.Username}' set successfully.";
+ return CommandResult.Ok();
+ }
+}
diff --git a/src/RemoteAgent.Desktop/Handlers/TerminateDesktopSessionHandler.cs b/src/RemoteAgent.Desktop/Handlers/TerminateDesktopSessionHandler.cs
index 47780cc..3120a21 100644
--- a/src/RemoteAgent.Desktop/Handlers/TerminateDesktopSessionHandler.cs
+++ b/src/RemoteAgent.Desktop/Handlers/TerminateDesktopSessionHandler.cs
@@ -1,9 +1,10 @@
using RemoteAgent.App.Logic.Cqrs;
+using RemoteAgent.Desktop.Infrastructure;
using RemoteAgent.Desktop.Requests;
namespace RemoteAgent.Desktop.Handlers;
-public sealed class TerminateDesktopSessionHandler
+public sealed class TerminateDesktopSessionHandler(IServerCapacityClient capacityClient)
: IRequestHandler
{
public async Task HandleAsync(
@@ -14,6 +15,23 @@ public async Task HandleAsync(
if (session is null)
return CommandResult.Fail("No session specified.");
+ var workspace = request.Workspace;
+
+ // Notify the server to end the session before disconnecting locally.
+ if (session.IsConnected &&
+ int.TryParse(workspace.Port, out var port))
+ {
+ try
+ {
+ await capacityClient.TerminateSessionAsync(
+ workspace.Host, port, session.SessionId, workspace.ApiKey, cancellationToken);
+ }
+ catch
+ {
+ // best-effort; still perform local cleanup below
+ }
+ }
+
try
{
if (session.SessionClient.IsConnected)
@@ -26,8 +44,8 @@ public async Task HandleAsync(
session.SessionClient.Disconnect();
- var workspace = request.Workspace;
var title = session.Title;
+ workspace.UnregisterSessionEvents(session);
workspace.Sessions.Remove(session);
if (workspace.SelectedSession == session)
workspace.SelectedSession = workspace.Sessions.FirstOrDefault();
diff --git a/src/RemoteAgent.Desktop/Infrastructure/AvaloniaClipboardService.cs b/src/RemoteAgent.Desktop/Infrastructure/AvaloniaClipboardService.cs
new file mode 100644
index 0000000..d7062e4
--- /dev/null
+++ b/src/RemoteAgent.Desktop/Infrastructure/AvaloniaClipboardService.cs
@@ -0,0 +1,21 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+
+namespace RemoteAgent.Desktop.Infrastructure;
+
+/// Avalonia implementation of using the main window's TopLevel clipboard.
+public sealed class AvaloniaClipboardService : IClipboardService
+{
+ public async Task SetTextAsync(string text)
+ {
+ var lifetime = Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime;
+ var mainWindow = lifetime?.MainWindow;
+ if (mainWindow is null) return;
+
+ var clipboard = TopLevel.GetTopLevel(mainWindow)?.Clipboard;
+ if (clipboard is null) return;
+
+ await clipboard.SetTextAsync(text);
+ }
+}
diff --git a/src/RemoteAgent.Desktop/Infrastructure/AvaloniaPairingUserDialog.cs b/src/RemoteAgent.Desktop/Infrastructure/AvaloniaPairingUserDialog.cs
new file mode 100644
index 0000000..ce5d10a
--- /dev/null
+++ b/src/RemoteAgent.Desktop/Infrastructure/AvaloniaPairingUserDialog.cs
@@ -0,0 +1,32 @@
+using Avalonia.Controls;
+using RemoteAgent.Desktop.ViewModels;
+using RemoteAgent.Desktop.Views;
+
+namespace RemoteAgent.Desktop.Infrastructure;
+
+/// Avalonia implementation of .
+public sealed class AvaloniaPairingUserDialog : IPairingUserDialog
+{
+ ///
+ public async Task ShowAsync(
+ Window ownerWindow,
+ CancellationToken cancellationToken = default)
+ {
+ var viewModel = new PairingUserDialogViewModel();
+ var dialog = new PairingUserDialog(viewModel);
+
+ var accepted = await dialog.ShowDialog(ownerWindow);
+ if (!accepted || !viewModel.IsAccepted)
+ return null;
+
+ var passwordHash = ComputePasswordHash(viewModel.Password);
+ return new PairingUserDialogResult(viewModel.Username.Trim(), passwordHash);
+ }
+
+ private static string ComputePasswordHash(string password)
+ {
+ var bytes = System.Text.Encoding.UTF8.GetBytes(password);
+ var hash = System.Security.Cryptography.SHA256.HashData(bytes);
+ return Convert.ToHexString(hash).ToLowerInvariant();
+ }
+}
diff --git a/src/RemoteAgent.Desktop/Infrastructure/DesktopFileSaveService.cs b/src/RemoteAgent.Desktop/Infrastructure/DesktopFileSaveService.cs
new file mode 100644
index 0000000..8f47d02
--- /dev/null
+++ b/src/RemoteAgent.Desktop/Infrastructure/DesktopFileSaveService.cs
@@ -0,0 +1,47 @@
+using RemoteAgent.Proto;
+
+namespace RemoteAgent.Desktop.Infrastructure;
+
+/// Saves files received via to the local filesystem,
+/// preserving the relative path hierarchy under a dedicated output directory.
+public static class DesktopFileSaveService
+{
+ /// Default base directory for saved file transfers. Uses the user's home directory.
+ private static string DefaultBasePath =>
+ Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ "RemoteAgent",
+ "Files");
+
+ /// Saves a file transfer to local storage preserving the directory hierarchy from
+ /// . Returns the saved path, or null on failure.
+ public static string? SaveFileTransfer(FileTransfer fileTransfer, string? basePath = null)
+ {
+ if (fileTransfer.Content == null || fileTransfer.Content.Length == 0)
+ return null;
+
+ var relativePath = fileTransfer.RelativePath;
+ if (string.IsNullOrWhiteSpace(relativePath))
+ relativePath = $"file_{DateTime.UtcNow:yyyyMMdd_HHmmss}.bin";
+
+ // Normalize to forward slashes
+ relativePath = relativePath.Replace('\\', '/');
+
+ var outputBase = string.IsNullOrWhiteSpace(basePath) ? DefaultBasePath : basePath;
+ var fullPath = Path.Combine(outputBase, relativePath.Replace('/', Path.DirectorySeparatorChar));
+
+ try
+ {
+ var directory = Path.GetDirectoryName(fullPath);
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+
+ File.WriteAllBytes(fullPath, fileTransfer.Content.ToByteArray());
+ return fullPath;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}
diff --git a/src/RemoteAgent.Desktop/Infrastructure/IClipboardService.cs b/src/RemoteAgent.Desktop/Infrastructure/IClipboardService.cs
new file mode 100644
index 0000000..db110c1
--- /dev/null
+++ b/src/RemoteAgent.Desktop/Infrastructure/IClipboardService.cs
@@ -0,0 +1,7 @@
+namespace RemoteAgent.Desktop.Infrastructure;
+
+/// Abstraction for clipboard write access, enabling testability of clipboard-writing operations.
+public interface IClipboardService
+{
+ Task SetTextAsync(string text);
+}
diff --git a/src/RemoteAgent.Desktop/Infrastructure/IConnectionSettingsDialogService.cs b/src/RemoteAgent.Desktop/Infrastructure/IConnectionSettingsDialogService.cs
index b1913a5..f530fc3 100644
--- a/src/RemoteAgent.Desktop/Infrastructure/IConnectionSettingsDialogService.cs
+++ b/src/RemoteAgent.Desktop/Infrastructure/IConnectionSettingsDialogService.cs
@@ -10,7 +10,8 @@ public sealed record ConnectionSettingsDefaults(
string SelectedAgentId,
string ApiKey,
string PerRequestContext,
- IReadOnlyList ConnectionModes);
+ IReadOnlyList ConnectionModes,
+ IReadOnlyList AvailableAgents);
public interface IConnectionSettingsDialogService
{
diff --git a/src/RemoteAgent.Desktop/Infrastructure/IFolderOpenerService.cs b/src/RemoteAgent.Desktop/Infrastructure/IFolderOpenerService.cs
new file mode 100644
index 0000000..b3587db
--- /dev/null
+++ b/src/RemoteAgent.Desktop/Infrastructure/IFolderOpenerService.cs
@@ -0,0 +1,7 @@
+namespace RemoteAgent.Desktop.Infrastructure;
+
+/// Abstracts opening a folder in the platform file manager, enabling testability.
+public interface IFolderOpenerService
+{
+ void OpenFolder(string path);
+}
diff --git a/src/RemoteAgent.Desktop/Infrastructure/IPairingUserDialog.cs b/src/RemoteAgent.Desktop/Infrastructure/IPairingUserDialog.cs
new file mode 100644
index 0000000..7210281
--- /dev/null
+++ b/src/RemoteAgent.Desktop/Infrastructure/IPairingUserDialog.cs
@@ -0,0 +1,13 @@
+using Avalonia.Controls;
+
+namespace RemoteAgent.Desktop.Infrastructure;
+
+/// Result returned by the pairing-user dialog.
+public sealed record PairingUserDialogResult(string Username, string PasswordHash);
+
+/// Dialog service for collecting a pairing-user username and password from the operator.
+public interface IPairingUserDialog
+{
+ /// Shows the dialog and returns the result, or null if the operator cancelled.
+ Task ShowAsync(Window ownerWindow, CancellationToken cancellationToken = default);
+}
diff --git a/src/RemoteAgent.Desktop/Infrastructure/LocalServerManager.cs b/src/RemoteAgent.Desktop/Infrastructure/LocalServerManager.cs
index 8699a5a..43b2bf8 100644
--- a/src/RemoteAgent.Desktop/Infrastructure/LocalServerManager.cs
+++ b/src/RemoteAgent.Desktop/Infrastructure/LocalServerManager.cs
@@ -24,7 +24,7 @@ public sealed class LocalServerManager : ILocalServerManager
{
private readonly object _gate = new();
private Process? _managedProcess;
- private static readonly Uri LocalServiceUri = new("http://127.0.0.1:5243/");
+ private static readonly Uri LocalServiceUri = ServiceDefaults.LocalServiceUri;
public async Task ProbeAsync(CancellationToken cancellationToken = default)
{
@@ -87,7 +87,7 @@ public async Task StartAsync(CancellationToken cancella
process.Start();
var started = await WaitUntilAsync(IsServiceReachableAsync, timeoutMs: 15000, pollMs: 250, cancellationToken);
if (!started)
- return new LocalServerActionResult(false, "Local server did not respond on http://127.0.0.1:5243/ within timeout.");
+ return new LocalServerActionResult(false, $"Local server did not respond on {ServiceDefaults.LocalServiceUri} within timeout.");
return new LocalServerActionResult(true, "Local server started.");
}
diff --git a/src/RemoteAgent.Desktop/Infrastructure/ServerCapacityClient.cs b/src/RemoteAgent.Desktop/Infrastructure/ServerCapacityClient.cs
index 29da591..7d83d7e 100644
--- a/src/RemoteAgent.Desktop/Infrastructure/ServerCapacityClient.cs
+++ b/src/RemoteAgent.Desktop/Infrastructure/ServerCapacityClient.cs
@@ -1,13 +1,18 @@
-using System.Net.Http.Json;
-using System.Text.Json;
+using Grpc.Net.Client;
using RemoteAgent.App.Logic;
using RemoteAgent.Proto;
namespace RemoteAgent.Desktop.Infrastructure;
-public interface IServerCapacityClient
-{
- Task GetCapacityAsync(
+public interface IServerCapacityClient
+{
+ Task GetServerInfoAsync(
+ string host,
+ int port,
+ string? apiKey,
+ CancellationToken cancellationToken = default);
+
+ Task GetCapacityAsync(
string host,
int port,
string? agentId,
@@ -171,39 +176,55 @@ Task SeedSessionContextAsync(
string? correlationId,
string? apiKey,
CancellationToken cancellationToken = default);
-}
-public sealed class ServerCapacityClient : IServerCapacityClient
-{
- private static readonly JsonSerializerOptions JsonOptions = new()
- {
- PropertyNameCaseInsensitive = true
- };
+ Task SetPairingUsersAsync(
+ string host,
+ int port,
+ IEnumerable<(string Username, string PasswordHash)> users,
+ bool replace,
+ string? apiKey,
+ CancellationToken cancellationToken = default);
+}
- public async Task GetCapacityAsync(
+public sealed class ServerCapacityClient : IServerCapacityClient
+{
+ public async Task GetServerInfoAsync(
+ string host,
+ int port,
+ string? apiKey,
+ CancellationToken cancellationToken = default)
+ {
+ var response = await ServerApiClient.GetServerInfoAsync(host, port, clientVersion: null, apiKey, cancellationToken);
+ if (response == null)
+ return null;
+ return new ServerInfoSnapshot(
+ response.ServerVersion,
+ response.Capabilities.ToList(),
+ response.AvailableAgents.ToList(),
+ response.AvailableModels.ToList());
+ }
+
+ public async Task GetCapacityAsync(
string host,
int port,
string? agentId,
string? apiKey,
CancellationToken cancellationToken = default)
{
- var baseUrl = ServerApiClient.BuildBaseUrl(host, port).TrimEnd('/');
- var query = string.IsNullOrWhiteSpace(agentId)
- ? ""
- : $"?agentId={Uri.EscapeDataString(agentId.Trim())}";
- var url = $"{baseUrl}/api/sessions/capacity{query}";
-
- using var client = new HttpClient();
- if (!string.IsNullOrWhiteSpace(apiKey))
- client.DefaultRequestHeaders.Add("x-api-key", apiKey.Trim());
-
- using var response = await client.GetAsync(url, cancellationToken);
- if (!response.IsSuccessStatusCode)
- {
- throw await CreateHttpFailureAsync(response, "Capacity check failed", cancellationToken);
- }
-
- return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken);
+ var baseUrl = ServerApiClient.BuildBaseUrl(host, port);
+ using var channel = GrpcChannel.ForAddress(baseUrl);
+ var client = new AgentGateway.AgentGatewayClient(channel);
+ var response = await client.CheckSessionCapacityAsync(
+ new CheckSessionCapacityRequest { AgentId = agentId?.Trim() ?? "" },
+ headers: ServerApiClient.CreateHeaders(apiKey),
+ cancellationToken: cancellationToken);
+ return new SessionCapacitySnapshot(
+ response.CanCreateSession, response.Reason,
+ response.MaxConcurrentSessions, response.ActiveSessionCount,
+ response.RemainingServerCapacity, response.AgentId,
+ response.HasAgentLimit ? response.AgentMaxConcurrentSessions : null,
+ response.AgentActiveSessionCount,
+ response.HasAgentLimit ? response.RemainingAgentCapacity : null);
}
public async Task> GetOpenSessionsAsync(
@@ -212,18 +233,14 @@ public async Task> GetOpenSessionsAsync
string? apiKey,
CancellationToken cancellationToken = default)
{
- var baseUrl = ServerApiClient.BuildBaseUrl(host, port).TrimEnd('/');
- var url = $"{baseUrl}/api/sessions/open";
- using var client = new HttpClient();
- if (!string.IsNullOrWhiteSpace(apiKey))
- client.DefaultRequestHeaders.Add("x-api-key", apiKey.Trim());
-
- using var response = await client.GetAsync(url, cancellationToken);
- if (!response.IsSuccessStatusCode)
- throw await CreateHttpFailureAsync(response, "Open sessions query failed", cancellationToken);
-
- var rows = await response.Content.ReadFromJsonAsync>(JsonOptions, cancellationToken);
- return rows ?? [];
+ var baseUrl = ServerApiClient.BuildBaseUrl(host, port);
+ using var channel = GrpcChannel.ForAddress(baseUrl);
+ var client = new AgentGateway.AgentGatewayClient(channel);
+ var response = await client.ListOpenSessionsAsync(
+ new ListOpenSessionsRequest(),
+ headers: ServerApiClient.CreateHeaders(apiKey),
+ cancellationToken: cancellationToken);
+ return response.Sessions.Select(s => new OpenServerSessionSnapshot(s.SessionId, s.AgentId, s.CanAcceptInput)).ToList();
}
public async Task TerminateSessionAsync(
@@ -236,20 +253,16 @@ public async Task TerminateSessionAsync(
if (string.IsNullOrWhiteSpace(sessionId))
return false;
- var baseUrl = ServerApiClient.BuildBaseUrl(host, port).TrimEnd('/');
- var url = $"{baseUrl}/api/sessions/{Uri.EscapeDataString(sessionId.Trim())}/terminate";
- using var client = new HttpClient();
- if (!string.IsNullOrWhiteSpace(apiKey))
- client.DefaultRequestHeaders.Add("x-api-key", apiKey.Trim());
-
- using var response = await client.PostAsync(url, content: null, cancellationToken);
- if (!response.IsSuccessStatusCode)
- throw await CreateHttpFailureAsync(response, "Session termination request failed", cancellationToken);
-
- var payload = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken);
- if (payload is { Success: false })
- throw new InvalidOperationException(string.IsNullOrWhiteSpace(payload.Message) ? "Session termination failed." : payload.Message);
- return payload?.Success ?? false;
+ var baseUrl = ServerApiClient.BuildBaseUrl(host, port);
+ using var channel = GrpcChannel.ForAddress(baseUrl);
+ var client = new AgentGateway.AgentGatewayClient(channel);
+ var response = await client.TerminateSessionAsync(
+ new TerminateSessionRequest { SessionId = sessionId.Trim() },
+ headers: ServerApiClient.CreateHeaders(apiKey),
+ cancellationToken: cancellationToken);
+ if (!response.Success)
+ throw new InvalidOperationException(string.IsNullOrWhiteSpace(response.Message) ? "Session termination failed." : response.Message);
+ return response.Success;
}
public async Task> GetAbandonedSessionsAsync(
@@ -258,13 +271,16 @@ public async Task> GetAbandonedSes
string? apiKey,
CancellationToken cancellationToken = default)
{
- var baseUrl = ServerApiClient.BuildBaseUrl(host, port).TrimEnd('/');
- using var client = CreateClient(apiKey);
- using var response = await client.GetAsync($"{baseUrl}/api/sessions/abandoned", cancellationToken);
- if (!response.IsSuccessStatusCode)
- throw await CreateHttpFailureAsync(response, "Abandoned sessions query failed", cancellationToken);
- var rows = await response.Content.ReadFromJsonAsync>(JsonOptions, cancellationToken);
- return rows ?? [];
+ var baseUrl = ServerApiClient.BuildBaseUrl(host, port);
+ using var channel = GrpcChannel.ForAddress(baseUrl);
+ var client = new AgentGateway.AgentGatewayClient(channel);
+ var response = await client.ListAbandonedSessionsAsync(
+ new ListAbandonedSessionsRequest(),
+ headers: ServerApiClient.CreateHeaders(apiKey),
+ cancellationToken: cancellationToken);
+ return response.Sessions.Select(s => new AbandonedServerSessionSnapshot(
+ s.SessionId, s.AgentId, s.Reason,
+ DateTimeOffset.TryParse(s.AbandonedUtc, out var dt) ? dt : DateTimeOffset.UtcNow)).ToList();
}
public async Task> GetConnectedPeersAsync(
@@ -273,13 +289,17 @@ public async Task> GetConnectedPeersAsync(
string? apiKey,
CancellationToken cancellationToken = default)
{
- var baseUrl = ServerApiClient.BuildBaseUrl(host, port).TrimEnd('/');
- using var client = CreateClient(apiKey);
- using var response = await client.GetAsync($"{baseUrl}/api/connections/peers", cancellationToken);
- if (!response.IsSuccessStatusCode)
- throw await CreateHttpFailureAsync(response, "Connected peers query failed", cancellationToken);
- var rows = await response.Content.ReadFromJsonAsync>(JsonOptions, cancellationToken);
- return rows ?? [];
+ var baseUrl = ServerApiClient.BuildBaseUrl(host, port);
+ using var channel = GrpcChannel.ForAddress(baseUrl);
+ var client = new AgentGateway.AgentGatewayClient(channel);
+ var response = await client.ListConnectedPeersAsync(
+ new ListConnectedPeersRequest(),
+ headers: ServerApiClient.CreateHeaders(apiKey),
+ cancellationToken: cancellationToken);
+ return response.Peers.Select(p => new ConnectedPeerSnapshot(
+ p.Peer, p.ActiveConnections, p.IsBlocked,
+ string.IsNullOrEmpty(p.BlockedUntilUtc) ? null : (DateTime.TryParse(p.BlockedUntilUtc, out var bu) ? bu : (DateTime?)null),
+ DateTime.TryParse(p.LastSeenUtc, out var ls) ? ls : DateTime.UtcNow)).ToList();
}
public async Task> GetConnectionHistoryAsync(
@@ -289,13 +309,16 @@ public async Task> GetConnectionHistory
string? apiKey,
CancellationToken cancellationToken = default)
{
- var baseUrl = ServerApiClient.BuildBaseUrl(host, port).TrimEnd('/');
- using var client = CreateClient(apiKey);
- using var response = await client.GetAsync($"{baseUrl}/api/connections/history?limit={Math.Clamp(limit, 1, 5000)}", cancellationToken);
- if (!response.IsSuccessStatusCode)
- throw await CreateHttpFailureAsync(response, "Connection history query failed", cancellationToken);
- var rows = await response.Content.ReadFromJsonAsync>(JsonOptions, cancellationToken);
- return rows ?? [];
+ var baseUrl = ServerApiClient.BuildBaseUrl(host, port);
+ using var channel = GrpcChannel.ForAddress(baseUrl);
+ var client = new AgentGateway.AgentGatewayClient(channel);
+ var response = await client.ListConnectionHistoryAsync(
+ new ListConnectionHistoryRequest { Limit = Math.Clamp(limit, 1, 5000) },
+ headers: ServerApiClient.CreateHeaders(apiKey),
+ cancellationToken: cancellationToken);
+ return response.Entries.Select(e => new ConnectionHistorySnapshot(
+ DateTimeOffset.TryParse(e.TimestampUtc, out var ts) ? ts : DateTimeOffset.UtcNow,
+ e.Peer, e.Action, e.Allowed, string.IsNullOrEmpty(e.Detail) ? null : e.Detail)).ToList();
}
public async Task> GetBannedPeersAsync(
@@ -304,13 +327,16 @@ public async Task> GetBannedPeersAsync(
string? apiKey,
CancellationToken cancellationToken = default)
{
- var baseUrl = ServerApiClient.BuildBaseUrl(host, port).TrimEnd('/');
- using var client = CreateClient(apiKey);
- using var response = await client.GetAsync($"{baseUrl}/api/devices/banned", cancellationToken);
- if (!response.IsSuccessStatusCode)
- throw await CreateHttpFailureAsync(response, "Banned devices query failed", cancellationToken);
- var rows = await response.Content.ReadFromJsonAsync>(JsonOptions, cancellationToken);
- return rows ?? [];
+ var baseUrl = ServerApiClient.BuildBaseUrl(host, port);
+ using var channel = GrpcChannel.ForAddress(baseUrl);
+ var client = new AgentGateway.AgentGatewayClient(channel);
+ var response = await client.ListBannedPeersAsync(
+ new ListBannedPeersRequest(),
+ headers: ServerApiClient.CreateHeaders(apiKey),
+ cancellationToken: cancellationToken);
+ return response.Peers.Select(b => new BannedPeerSnapshot(
+ b.Peer, b.Reason,
+ DateTimeOffset.TryParse(b.BannedUtc, out var bt) ? bt : DateTimeOffset.UtcNow)).ToList();
}
public async Task BanPeerAsync(
@@ -324,18 +350,16 @@ public async Task BanPeerAsync(
if (string.IsNullOrWhiteSpace(peer))
return false;
- var baseUrl = ServerApiClient.BuildBaseUrl(host, port).TrimEnd('/');
- var query = string.IsNullOrWhiteSpace(reason)
- ? ""
- : $"?reason={Uri.EscapeDataString(reason.Trim())}";
- using var client = CreateClient(apiKey);
- using var response = await client.PostAsync($"{baseUrl}/api/devices/{Uri.EscapeDataString(peer.Trim())}/ban{query}", null, cancellationToken);
- if (!response.IsSuccessStatusCode)
- throw await CreateHttpFailureAsync(response, "Peer ban request failed", cancellationToken);
- var payload = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken);
- if (payload is { Success: false })
- throw new InvalidOperationException(string.IsNullOrWhiteSpace(payload.Message) ? "Peer ban failed." : payload.Message);
- return payload?.Success ?? false;
+ var baseUrl = ServerApiClient.BuildBaseUrl(host, port);
+ using var channel = GrpcChannel.ForAddress(baseUrl);
+ var client = new AgentGateway.AgentGatewayClient(channel);
+ var response = await client.BanPeerAsync(
+ new BanPeerRequest { Peer = peer.Trim(), Reason = reason?.Trim() ?? "" },
+ headers: ServerApiClient.CreateHeaders(apiKey),
+ cancellationToken: cancellationToken);
+ if (!response.Success)
+ throw new InvalidOperationException(string.IsNullOrWhiteSpace(response.Message) ? "Peer ban failed." : response.Message);
+ return response.Success;
}
public async Task UnbanPeerAsync(
@@ -348,15 +372,16 @@ public async Task UnbanPeerAsync(
if (string.IsNullOrWhiteSpace(peer))
return false;
- var baseUrl = ServerApiClient.BuildBaseUrl(host, port).TrimEnd('/');
- using var client = CreateClient(apiKey);
- using var response = await client.DeleteAsync($"{baseUrl}/api/devices/{Uri.EscapeDataString(peer.Trim())}/ban", cancellationToken);
- if (!response.IsSuccessStatusCode)
- throw await CreateHttpFailureAsync(response, "Peer unban request failed", cancellationToken);
- var payload = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken);
- if (payload is { Success: false })
- throw new InvalidOperationException(string.IsNullOrWhiteSpace(payload.Message) ? "Peer unban failed." : payload.Message);
- return payload?.Success ?? false;
+ var baseUrl = ServerApiClient.BuildBaseUrl(host, port);
+ using var channel = GrpcChannel.ForAddress(baseUrl);
+ var client = new AgentGateway.AgentGatewayClient(channel);
+ var response = await client.UnbanPeerAsync(
+ new UnbanPeerRequest { Peer = peer.Trim() },
+ headers: ServerApiClient.CreateHeaders(apiKey),
+ cancellationToken: cancellationToken);
+ if (!response.Success)
+ throw new InvalidOperationException(string.IsNullOrWhiteSpace(response.Message) ? "Peer unban failed." : response.Message);
+ return response.Success;
}
public async Task> GetAuthUsersAsync(
@@ -365,13 +390,17 @@ public async Task> GetAuthUsersAsync(
string? apiKey,
CancellationToken cancellationToken = default)
{
- var baseUrl = ServerApiClient.BuildBaseUrl(host, port).TrimEnd('/');
- using var client = CreateClient(apiKey);
- using var response = await client.GetAsync($"{baseUrl}/api/auth/users", cancellationToken);
- if (!response.IsSuccessStatusCode)
- throw await CreateHttpFailureAsync(response, "Auth users query failed", cancellationToken);
- var rows = await response.Content.ReadFromJsonAsync>(JsonOptions, cancellationToken);
- return rows ?? [];
+ var baseUrl = ServerApiClient.BuildBaseUrl(host, port);
+ using var channel = GrpcChannel.ForAddress(baseUrl);
+ var client = new AgentGateway.AgentGatewayClient(channel);
+ var response = await client.ListAuthUsersAsync(
+ new ListAuthUsersRequest(),
+ headers: ServerApiClient.CreateHeaders(apiKey),
+ cancellationToken: cancellationToken);
+ return response.Users.Select(u => new AuthUserSnapshot(
+ u.UserId, u.DisplayName, u.Role, u.Enabled,
+ DateTimeOffset.TryParse(u.CreatedUtc, out var c) ? c : DateTimeOffset.UtcNow,
+ DateTimeOffset.TryParse(u.UpdatedUtc, out var upd) ? upd : DateTimeOffset.UtcNow)).ToList();
}
public async Task> GetPermissionRolesAsync(
@@ -380,13 +409,14 @@ public async Task> GetPermissionRolesAsync(
string? apiKey,
CancellationToken cancellationToken = default)
{
- var baseUrl = ServerApiClient.BuildBaseUrl(host, port).TrimEnd('/');
- using var client = CreateClient(apiKey);
- using var response = await client.GetAsync($"{baseUrl}/api/auth/permissions", cancellationToken);
- if (!response.IsSuccessStatusCode)
- throw await CreateHttpFailureAsync(response, "Permission roles query failed", cancellationToken);
- var rows = await response.Content.ReadFromJsonAsync>(JsonOptions, cancellationToken);
- return rows ?? [];
+ var baseUrl = ServerApiClient.BuildBaseUrl(host, port);
+ using var channel = GrpcChannel.ForAddress(baseUrl);
+ var client = new AgentGateway.AgentGatewayClient(channel);
+ var response = await client.ListPermissionRolesAsync(
+ new ListPermissionRolesRequest(),
+ headers: ServerApiClient.CreateHeaders(apiKey),
+ cancellationToken: cancellationToken);
+ return response.Roles.ToList();
}
public async Task UpsertAuthUserAsync(
@@ -396,12 +426,21 @@ public async Task> GetPermissionRolesAsync(
string? apiKey,
CancellationToken cancellationToken = default)
{
- var baseUrl = ServerApiClient.BuildBaseUrl(host, port).TrimEnd('/');
- using var client = CreateClient(apiKey);
- using var response = await client.PostAsJsonAsync($"{baseUrl}/api/auth/users", user, JsonOptions, cancellationToken);
- if (!response.IsSuccessStatusCode)
- throw await CreateHttpFailureAsync(response, "Auth user save failed", cancellationToken);
- return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken);
+ var baseUrl = ServerApiClient.BuildBaseUrl(host, port);
+ using var channel = GrpcChannel.ForAddress(baseUrl);
+ var client = new AgentGateway.AgentGatewayClient(channel);
+ var entry = new AuthUserEntry { UserId = user.UserId, DisplayName = user.DisplayName, Role = user.Role, Enabled = user.Enabled };
+ var response = await client.UpsertAuthUserAsync(
+ new UpsertAuthUserRequest { User = entry },
+ headers: ServerApiClient.CreateHeaders(apiKey),
+ cancellationToken: cancellationToken);
+ if (response.User == null)
+ return null;
+ var u = response.User;
+ return new AuthUserSnapshot(
+ u.UserId, u.DisplayName, u.Role, u.Enabled,
+ DateTimeOffset.TryParse(u.CreatedUtc, out var c) ? c : DateTimeOffset.UtcNow,
+ DateTimeOffset.TryParse(u.UpdatedUtc, out var upd) ? upd : DateTimeOffset.UtcNow);
}
public async Task DeleteAuthUserAsync(
@@ -414,15 +453,16 @@ public async Task DeleteAuthUserAsync(
if (string.IsNullOrWhiteSpace(userId))
return false;
- var baseUrl = ServerApiClient.BuildBaseUrl(host, port).TrimEnd('/');
- using var client = CreateClient(apiKey);
- using var response = await client.DeleteAsync($"{baseUrl}/api/auth/users/{Uri.EscapeDataString(userId.Trim())}", cancellationToken);
- if (!response.IsSuccessStatusCode)
- throw await CreateHttpFailureAsync(response, "Auth user delete failed", cancellationToken);
- var payload = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken);
- if (payload is { Success: false })
- throw new InvalidOperationException(string.IsNullOrWhiteSpace(payload.Message) ? "Auth user delete failed." : payload.Message);
- return payload?.Success ?? false;
+ var baseUrl = ServerApiClient.BuildBaseUrl(host, port);
+ using var channel = GrpcChannel.ForAddress(baseUrl);
+ var client = new AgentGateway.AgentGatewayClient(channel);
+ var response = await client.DeleteAuthUserAsync(
+ new DeleteAuthUserRequest { UserId = userId.Trim() },
+ headers: ServerApiClient.CreateHeaders(apiKey),
+ cancellationToken: cancellationToken);
+ if (!response.Success)
+ throw new InvalidOperationException(string.IsNullOrWhiteSpace(response.Message) ? "Auth user delete failed." : response.Message);
+ return response.Success;
}
public async Task GetPluginsAsync(
@@ -597,51 +637,29 @@ public async Task SeedSessionContextAsync(
return response?.Success ?? false;
}
- private static HttpClient CreateClient(string? apiKey)
- {
- var client = new HttpClient();
- if (!string.IsNullOrWhiteSpace(apiKey))
- client.DefaultRequestHeaders.Add("x-api-key", apiKey.Trim());
- return client;
- }
-
- private static async Task TryReadErrorDetailAsync(HttpResponseMessage response, CancellationToken cancellationToken)
+ public async Task SetPairingUsersAsync(
+ string host,
+ int port,
+ IEnumerable<(string Username, string PasswordHash)> users,
+ bool replace,
+ string? apiKey,
+ CancellationToken cancellationToken = default)
{
- try
- {
- var body = await response.Content.ReadAsStringAsync(cancellationToken);
- if (string.IsNullOrWhiteSpace(body))
- return null;
-
- using var doc = JsonDocument.Parse(body);
- if (doc.RootElement.ValueKind == JsonValueKind.Object &&
- doc.RootElement.TryGetProperty("message", out var messageNode) &&
- messageNode.ValueKind == JsonValueKind.String)
- {
- return messageNode.GetString();
- }
-
- return body.Length <= 200 ? body : body[..200];
- }
- catch
- {
- return null;
- }
+ var baseUrl = ServerApiClient.BuildBaseUrl(host, port);
+ using var channel = GrpcChannel.ForAddress(baseUrl);
+ var client = new AgentGateway.AgentGatewayClient(channel);
+ var grpcRequest = new SetPairingUsersRequest { Replace = replace };
+ foreach (var (username, passwordHash) in users)
+ grpcRequest.Users.Add(new PairingUserEntry { Username = username, PasswordHash = passwordHash });
+ var response = await client.SetPairingUsersAsync(
+ grpcRequest,
+ headers: ServerApiClient.CreateHeaders(apiKey),
+ cancellationToken: cancellationToken);
+ if (!response.Success)
+ throw new InvalidOperationException(string.IsNullOrWhiteSpace(response.Error) ? "Set pairing users failed." : response.Error);
+ return response.GeneratedApiKey;
}
- private static async Task CreateHttpFailureAsync(
- HttpResponseMessage response,
- string operation,
- CancellationToken cancellationToken)
- {
- var detail = await TryReadErrorDetailAsync(response, cancellationToken);
- var statusCode = (int)response.StatusCode;
- var reason = string.IsNullOrWhiteSpace(response.ReasonPhrase) ? "HTTP error" : response.ReasonPhrase;
- var message = string.IsNullOrWhiteSpace(detail)
- ? $"{operation} ({statusCode} {reason})."
- : $"{operation} ({statusCode} {reason}): {detail}";
- return new InvalidOperationException(message);
- }
}
public sealed record SessionCapacitySnapshot(
@@ -660,10 +678,6 @@ public sealed record OpenServerSessionSnapshot(
string AgentId,
bool CanAcceptInput);
-public sealed record TerminateSessionResponse(
- bool Success,
- string Message);
-
public sealed record AbandonedServerSessionSnapshot(
string SessionId,
string AgentId,
@@ -697,8 +711,25 @@ public sealed record AuthUserSnapshot(
DateTimeOffset CreatedUtc,
DateTimeOffset UpdatedUtc);
-public sealed record PluginConfigurationSnapshot(
- IReadOnlyList ConfiguredAssemblies,
- IReadOnlyList LoadedRunnerIds,
- bool Success,
- string Message);
+public sealed record PluginConfigurationSnapshot(
+ IReadOnlyList ConfiguredAssemblies,
+ IReadOnlyList LoadedRunnerIds,
+ bool Success,
+ string Message);
+
+public sealed record ServerInfoSnapshot(
+ string ServerVersion,
+ IReadOnlyList Capabilities,
+ IReadOnlyList AvailableAgents,
+ IReadOnlyList AvailableModels);
+
+public sealed record AgentSnapshot(
+ string AgentId,
+ int ActiveSessionCount,
+ int? MaxConcurrentSessions,
+ int? RemainingCapacity,
+ bool IsDefault,
+ string RunnerType = "",
+ string Command = "",
+ string Arguments = "",
+ string Description = "");
diff --git a/src/RemoteAgent.Desktop/Infrastructure/ServerRegistration.cs b/src/RemoteAgent.Desktop/Infrastructure/ServerRegistration.cs
index 5c8a193..8f3e2bf 100644
--- a/src/RemoteAgent.Desktop/Infrastructure/ServerRegistration.cs
+++ b/src/RemoteAgent.Desktop/Infrastructure/ServerRegistration.cs
@@ -5,6 +5,10 @@ public sealed class ServerRegistration
public string ServerId { get; set; } = Guid.NewGuid().ToString("N");
public string DisplayName { get; set; } = "";
public string Host { get; set; } = "127.0.0.1";
- public int Port { get; set; } = 5243;
+ public int Port { get; set; } = ServiceDefaults.Port;
public string ApiKey { get; set; } = "";
+ /// Text prepended to every chat message sent to the agent.
+ public string PerRequestContext { get; set; } = "";
+ /// Default context seeded into new sessions on this server.
+ public string DefaultSessionContext { get; set; } = "";
}
diff --git a/src/RemoteAgent.Desktop/Infrastructure/ServerRegistrationStore.cs b/src/RemoteAgent.Desktop/Infrastructure/ServerRegistrationStore.cs
index 35105de..e851a41 100644
--- a/src/RemoteAgent.Desktop/Infrastructure/ServerRegistrationStore.cs
+++ b/src/RemoteAgent.Desktop/Infrastructure/ServerRegistrationStore.cs
@@ -33,8 +33,10 @@ public ServerRegistration Upsert(ServerRegistration registration)
ServerId = string.IsNullOrWhiteSpace(registration.ServerId) ? Guid.NewGuid().ToString("N") : registration.ServerId.Trim(),
DisplayName = registration.DisplayName?.Trim() ?? "",
Host = string.IsNullOrWhiteSpace(registration.Host) ? "127.0.0.1" : registration.Host.Trim(),
- Port = registration.Port <= 0 ? 5243 : registration.Port,
- ApiKey = registration.ApiKey ?? ""
+ Port = registration.Port <= 0 ? ServiceDefaults.Port : registration.Port,
+ ApiKey = registration.ApiKey ?? "",
+ PerRequestContext = registration.PerRequestContext ?? "",
+ DefaultSessionContext = registration.DefaultSessionContext ?? ""
};
if (string.IsNullOrWhiteSpace(copy.DisplayName))
copy.DisplayName = $"{copy.Host}:{copy.Port}";
diff --git a/src/RemoteAgent.Desktop/Infrastructure/ServiceDefaults.cs b/src/RemoteAgent.Desktop/Infrastructure/ServiceDefaults.cs
new file mode 100644
index 0000000..f3f6125
--- /dev/null
+++ b/src/RemoteAgent.Desktop/Infrastructure/ServiceDefaults.cs
@@ -0,0 +1,17 @@
+namespace RemoteAgent.Desktop.Infrastructure;
+
+///
+/// Platform-calculated defaults for connecting to the Remote Agent service.
+/// Windows uses port 5244 (the native Windows service port); all other platforms use 5243.
+///
+public static class ServiceDefaults
+{
+ /// Default service port for the current platform.
+ public static readonly int Port = OperatingSystem.IsWindows() ? 5244 : 5243;
+
+ /// Default service port as a string (for ViewModel binding).
+ public static readonly string PortString = Port.ToString();
+
+ /// Default local service base URI for the current platform.
+ public static readonly Uri LocalServiceUri = new($"http://127.0.0.1:{Port}/");
+}
diff --git a/src/RemoteAgent.Desktop/Infrastructure/SystemFolderOpenerService.cs b/src/RemoteAgent.Desktop/Infrastructure/SystemFolderOpenerService.cs
new file mode 100644
index 0000000..b9d1d0b
--- /dev/null
+++ b/src/RemoteAgent.Desktop/Infrastructure/SystemFolderOpenerService.cs
@@ -0,0 +1,20 @@
+using System.Diagnostics;
+
+namespace RemoteAgent.Desktop.Infrastructure;
+
+/// Opens a folder in the platform file manager (Explorer on Windows, Finder on macOS, xdg-open on Linux).
+public sealed class SystemFolderOpenerService : IFolderOpenerService
+{
+ public void OpenFolder(string path)
+ {
+ if (!Directory.Exists(path))
+ return;
+
+ if (OperatingSystem.IsWindows())
+ Process.Start("explorer.exe", path);
+ else if (OperatingSystem.IsMacOS())
+ Process.Start("open", path);
+ else
+ Process.Start("xdg-open", path);
+ }
+}
diff --git a/src/RemoteAgent.Desktop/RemoteAgent.Desktop.csproj b/src/RemoteAgent.Desktop/RemoteAgent.Desktop.csproj
index 7c4d863..2d6ad75 100644
--- a/src/RemoteAgent.Desktop/RemoteAgent.Desktop.csproj
+++ b/src/RemoteAgent.Desktop/RemoteAgent.Desktop.csproj
@@ -2,6 +2,7 @@
WinExe
net9.0
+ LatestPatch
enable
enable
false
@@ -29,5 +30,9 @@
+
+
+
+
diff --git a/src/RemoteAgent.Desktop/Requests/CopyStatusLogRequest.cs b/src/RemoteAgent.Desktop/Requests/CopyStatusLogRequest.cs
new file mode 100644
index 0000000..f4affd7
--- /dev/null
+++ b/src/RemoteAgent.Desktop/Requests/CopyStatusLogRequest.cs
@@ -0,0 +1,12 @@
+using RemoteAgent.App.Logic.Cqrs;
+using RemoteAgent.Desktop.ViewModels;
+
+namespace RemoteAgent.Desktop.Requests;
+
+public sealed record CopyStatusLogRequest(
+ Guid CorrelationId,
+ IReadOnlyList Entries) : IRequest
+{
+ public override string ToString() =>
+ $"CopyStatusLogRequest {{ CorrelationId = {CorrelationId}, EntryCount = {Entries.Count} }}";
+}
diff --git a/src/RemoteAgent.Desktop/Requests/OpenLogsFolderRequest.cs b/src/RemoteAgent.Desktop/Requests/OpenLogsFolderRequest.cs
new file mode 100644
index 0000000..e49eacd
--- /dev/null
+++ b/src/RemoteAgent.Desktop/Requests/OpenLogsFolderRequest.cs
@@ -0,0 +1,11 @@
+using RemoteAgent.App.Logic.Cqrs;
+
+namespace RemoteAgent.Desktop.Requests;
+
+public sealed record OpenLogsFolderRequest(
+ Guid CorrelationId,
+ string FolderPath) : IRequest
+{
+ public override string ToString() =>
+ $"OpenLogsFolderRequest {{ CorrelationId = {CorrelationId}, FolderPath = {FolderPath} }}";
+}
diff --git a/src/RemoteAgent.Desktop/Requests/RefreshAgentsRequest.cs b/src/RemoteAgent.Desktop/Requests/RefreshAgentsRequest.cs
new file mode 100644
index 0000000..a20d3e6
--- /dev/null
+++ b/src/RemoteAgent.Desktop/Requests/RefreshAgentsRequest.cs
@@ -0,0 +1,16 @@
+using RemoteAgent.App.Logic.Cqrs;
+using RemoteAgent.Desktop.ViewModels;
+
+namespace RemoteAgent.Desktop.Requests;
+
+public sealed record RefreshAgentsRequest(
+ Guid CorrelationId,
+ string Host,
+ int Port,
+ string? ApiKey,
+ string CurrentDefaultAgentId,
+ AgentsViewModel Workspace) : IRequest