diff --git a/README.md b/README.md index 1762bdf5..1e60c652 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # FrameRelay +## Discord one-click sharing + +The standalone Go bot provides `/framerelay share` and `/framerelay watch `. Share opens this desktop app with a short-lived, single-use launch link; the user selects a capture target and starts the normal screen-share flow. When the session is ready, the bot posts a watch button in the originating channel. Watch opens this app directly into the selected session. The app registers the `framerelay://` protocol for the current Windows user and forwards links to its existing process when one is already running. + +RelayControl owns the launch capabilities and the normal session authorization, device pairing, capacity, and join checks. See the bot repository's README for its environment and container setup. + FrameRelay is a Windows desktop screen-sharing client built on .NET 10 and Avalonia. The current desktop project names still use the legacy `SonicDesktopRelay.*` namespace; this migration does not rename them. diff --git a/docs/superpowers/plans/2026-09-24-discord-share-watch.md b/docs/superpowers/plans/2026-09-24-discord-share-watch.md new file mode 100644 index 00000000..19c9adb4 --- /dev/null +++ b/docs/superpowers/plans/2026-09-24-discord-share-watch.md @@ -0,0 +1,132 @@ +# Discord Share and Watch Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the issue #3 Discord share/watch flow across FrameRelay, RelayControl, and a separate local Go bot repository. + +**Architecture:** RelayControl persists opaque launch capabilities and enforces ownership, expiry, replay, pairing, and viewer limits. FrameRelay activates through a registered custom URI and forwards activations to one running process before calling its existing authenticated session runtime. A separate Go/DisGo service provides Discord commands and recovers pending work through the backend API. + +**Tech Stack:** .NET 10, Avalonia, ASP.NET Core, EF Core/PostgreSQL, Go 1.27, `github.com/disgoorg/disgo`, Docker. + +**Spec:** `docs/superpowers/specs/2026-09-24-discord-share-watch-design.md`; requirements originate in GitHub issue #3. + +## Global Constraints + +- The Go bot is a separate project/repository at `H:\Script\SonicRelay\go_discord_FrameRelay`, not a subfolder of either .NET repository. +- Use Go 1.27+ and pin a tagged `github.com/disgoorg/disgo` version. +- Do not request Message Content, presence, or other privileged Gateway intents; do not use voice APIs or self-bot behavior. +- Discord and RelayControl credentials come from environment/secret storage and are never logged or committed. +- Bot state is recovered from RelayControl; do not add a bot database. +- Share launch is short-lived and single-use; watch capabilities are session-scoped and cannot bypass pairing or viewer limits. +- Continue using the existing desktop authenticated identity, screen capture, session, WebRTC, signaling, and TURN paths. +- Public launch URLs use HTTPS and opaque tokens; never put session codes or reusable credentials in URLs. +- Normal automated tests use fakes/`httptest.Server`, never live Discord or RelayControl. + +## Review Focus + +1. Two concurrent requests consume the same publisher token: exactly one succeeds and at most one desktop session can be associated. +2. A different device completes an intent it did not consume: reject it without disclosing session or device details. +3. Expired/replayed share or ended/full/unpaired watch target: return a safe error and preserve existing authorization/capacity rules. +4. A second app process receives a launch URI while the first is starting or shutting down: forward once or fail cleanly without launching a competing media session. +5. Discord or RelayControl disconnect/restart during a pending share: readiness reflects the dependency and a restarted bot recovers without duplicate public messages. + +## Component map + +- **RelayControl:** `services/SonicRelay.Api/Authorization/*` for the launch service auth scheme/policies; `Endpoints/LaunchIntentEndpoints.cs`, `Contracts/LaunchIntentContracts.cs`, `Services/LaunchIntentService.cs`, and options for API behavior; `src/SonicRelay.Domain/...` for persistent entities; `src/SonicRelay.Infrastructure/Persistence/AppDbContext.cs` and a migration for storage; `Program.cs` for registration; API integration tests for persistence, authorization, lifecycle, and races; deploy config/docs for secret provisioning. +- **FrameRelay:** `src/SonicDesktopRelay.App/Program.cs`, `App.axaml.cs`, `Views/MainWindow.axaml.cs`, and focused protocol/single-instance/activation classes; `src/SonicDesktopRelay.ApiClient` for typed authenticated launch calls; `Shell.cs`/`AppComposition.cs` for dispatch into existing Share/Watch flows; app and API-client tests; `app.manifest` or installer/protocol registration artifact for the `framerelay` scheme; README for installation/activation behavior. +- **Go bot:** module root `H:\Script\SonicRelay\go_discord_FrameRelay`; `cmd/framerelay-bot/main.go`; `internal/config`, `internal/discord`, `internal/relaycontrol`, `internal/launch`, `internal/health`, and `internal/observability`; Dockerfile and CI workflow; package-local unit and HTTP tests. + +## Tasks + +### Task 1: RelayControl launch-intent persistence and contracts + +**Files:** +- Create domain entity and constants under `src/SonicRelay.Domain/LaunchIntents/`. +- Modify `src/SonicRelay.Infrastructure/Persistence/AppDbContext.cs` and add the EF migration/snapshot update. +- Create `services/SonicRelay.Api/Contracts/LaunchIntentContracts.cs`, `Services/LaunchIntentOptions.cs`, and `Services/LaunchIntentService.cs`. +- Test in `tests/SonicRelay.Api.IntegrationTests/LaunchIntentTests.cs`. + +**Interfaces:** Service operations create share, consume share, complete share, query/recover bot intents, create watch capability, and resolve watch capability. Share tokens are random opaque values; only a cryptographic hash is persisted. All state transitions use conditional database updates/transactions so single-use consumption is race-safe. + +- [ ] Test that share intent creation stores correlation metadata and only a token hash, defaults expiry to 10 minutes, rejects invalid TTLs, and returns a token once. +- [ ] Test that concurrent consume attempts have exactly one winner and that replay, expiry, and completion by a different source device fail. +- [ ] Test that recovery returns only this integration's pending/ready intents and completion associates only a real session owned by the consuming device. +- [ ] Test watch token scope, expiry, multiple valid resolutions, normalization, and existing pairing/capacity enforcement through the normal join path. +- [ ] Implement entity, migration, service, and typed HTTP contracts to satisfy these tests. +- [ ] Add the bot-only scoped service authentication/policy and device-authenticated desktop endpoints; test missing, invalid, and insufficiently scoped credentials. +- [ ] Register endpoints and services in `Program.cs`, then update API/deployment documentation with secret setup and routes. + +### Task 2: FrameRelay API client and activation model + +**Files:** +- Create typed launch request/response/client contracts in `src/SonicDesktopRelay.ApiClient/`. +- Create protocol parsing, URI validation, activation forwarding, and single-instance coordination classes in `src/SonicDesktopRelay.App/`. +- Modify `Program.cs`, `App.axaml.cs`, `Views/MainWindow.axaml.cs`, `Shell.cs`, and `AppComposition.cs`. +- Add focused tests under `tests/SonicDesktopRelay.ApiClient.Tests/` and `tests/SonicDesktopRelay.Presentation.Tests/` (or a Windows-gated App test project if the existing test targets cannot load the app assembly). +- Modify `src/SonicDesktopRelay.App/app.manifest` or the existing packaging/installer files to register `framerelay://`. + +**Interfaces:** Parse only `framerelay://open/share/{opaque-token}` and `framerelay://open/watch/{opaque-token}`. Activation dispatch calls authenticated typed API operations, foregrounds the current main window, then calls the existing Shell share/watch methods. A named per-user mutex and local named pipe serialize process ownership and forward URI messages. + +- [ ] Test valid share/watch URIs, malformed URI rejection, empty/oversized token rejection, and unrelated command-line argument preservation. +- [ ] Test activation forwarding protocol framing, cancellation, duplicate delivery suppression, and receiver lifecycle without starting capture or WebRTC. +- [ ] Add API-client tests for consume, complete, resolve-watch routes, bearer auth, and safe upstream error mapping. +- [ ] Connect share activation to the existing `ShareAsync` flow and complete the consumed intent with the actual session ID after successful creation. +- [ ] Connect watch activation to the existing authenticated join flow; navigate to Share/Watch and foreground UI before dispatch. +- [ ] Register protocol activation and ensure capture begins only as a result of the user's local launch click; when source selection is missing, show Share without starting capture. +- [ ] Ensure a second process forwards activation to the existing instance and exits; test races during startup/shutdown. +- [ ] Document per-user protocol installation and fallback behavior. + +### Task 3: Public HTTPS launch landing behavior + +**Files:** +- Add `/open/share/{token}` and `/open/watch/{token}` public routes in RelayControl (or its existing public web host if source is found during implementation). +- Add route/response integration tests and short user-facing install guidance. + +**Interfaces:** The landing page validates the capability without consuming it, attempts `framerelay://open/{mode}/{token}`, and presents install guidance if the protocol handler is unavailable. It never renders or redirects to session codes, bearer tokens, or service credentials. + +- [ ] Test share/watch path handling, HTML escaping, malformed token handling, no-store/referrer policy headers, and install fallback content. +- [ ] Implement the minimal landing route using the project's hosting conventions and document the public HTTPS origin contract. + +### Task 4: Go bot core and RelayControl client + +**Files:** +- Initialize the standalone module at `H:\Script\SonicRelay\go_discord_FrameRelay` on local branch `codex/issue-3-discord-bot`. +- Create `go.mod`, `internal/config/config.go`, `internal/relaycontrol/{client,models}.go`, `internal/launch/watcher.go`, `internal/health/server.go`, and `internal/observability/logging.go` with package tests. + +**Interfaces:** Config validates Discord token/application ID, RelayControl URL/service token, public base URL, address, log level, TTLs, and polling interval. The RelayControl client uses context-aware `net/http`, maps public error codes, and redacts authorization and capability tokens. + +- [ ] Test fail-fast configuration, duration/default parsing, URL validation, and secret-safe errors. +- [ ] Test create/list/status/watch RelayControl calls with `httptest.Server`, including 401/403, 429/5xx, timeout, cancellation, malformed JSON, and URL escaping. +- [ ] Implement health/readiness handlers and context-aware pending-intent polling/recovery; test readiness transitions and cancellation. +- [ ] Implement JSON `slog` setup and tests that verify credential/token redaction. + +### Task 5: DisGo adapter and command flows + +**Files:** +- Create `cmd/framerelay-bot/main.go` and `internal/discord/{bot,commands,responses}.go` plus tests. +- Pin the chosen DisGo tag in `go.mod`/`go.sum`. + +**Interfaces:** A small adapter owns DisGo types, registers/upserts `/framerelay share` and `/framerelay watch code`, and handles only the interactions needed. Share replies are ephemeral; successful readiness posts exactly one public watch button; watch command normalizes code and returns an ephemeral link or safe error. + +- [ ] Test command definitions, normalization, ephemeral/public response shapes, link buttons, and failure mapping without a Gateway connection. +- [ ] Implement DisGo adapter with only minimum required intents and Gateway reconnect/resume lifecycle. +- [ ] Implement share create/respond and pending status publication/recovery with duplicate-message suppression based on persisted intent state. +- [ ] Implement watch command and clear ephemeral mapping for invalid/expired/full/unavailable cases. +- [ ] Implement startup validation/order, signal-driven graceful shutdown, and readiness only after Gateway plus RelayControl are usable. + +### Task 6: Container, CI, documentation, and independent pull requests + +**Files:** +- Create bot `Dockerfile`, CI workflow, `.dockerignore`, and README/config example with placeholders only. +- Update RelayControl deployment docs/config templates and FrameRelay user docs. + +- [ ] Add multi-stage Go 1.27 Docker build with non-root runtime, healthcheck, read-only-compatible filesystem, and graceful SIGTERM. +- [ ] Add CI for `gofmt`, `go vet`, unit tests, race tests, build, and `govulncheck`; ensure no secrets or live dependencies are needed. +- [ ] Document manual Discord guild smoke test and public-domain/protocol installation setup. +- [ ] Review each repository's complete diff and prepare separate PRs for FrameRelay and RelayControl; leave the Go repo local until its remote is supplied. + +## Self-review + +- Spec coverage: intents, auth scopes, persistence/recovery, desktop single-instance activation, consent, watch flow, bot commands/Gateway, logging/health, deployment, CI, and manual smoke test are assigned above. +- Interface consistency: bot API client consumes the RelayControl create/list/status/watch routes; the desktop API client consumes authenticated share-consume/share-complete/watch-resolve routes; RelayControl binds a share completion to the consuming device and created source session. +- Remaining external dependency: the public domain/HTTPS host's deployment target must be confirmed in the relevant hosting repository during Task 3; the launch page itself can be served by RelayControl if no existing public web host is present. diff --git a/docs/superpowers/specs/2026-09-24-discord-share-watch-design.md b/docs/superpowers/specs/2026-09-24-discord-share-watch-design.md new file mode 100644 index 00000000..bed79434 --- /dev/null +++ b/docs/superpowers/specs/2026-09-24-discord-share-watch-design.md @@ -0,0 +1,39 @@ +# Discord Share and Watch Integration Design + +**Issue:** [#3 — Go + DisGo bot for one-click FrameRelay share/watch](https://github.com/vitorhugo-dotnet/dotnet_FrameRelay/issues/3) + +## Goal + +Let Discord users launch the existing FrameRelay desktop share and watch flows using short-lived HTTPS links, while keeping media, session ownership, identity, pairing, and viewer limits in the existing desktop and RelayControl systems. + +## Components and boundaries + +1. **RelayControl (`dotnet_SonicRelay`)** is the source of truth for persistent launch intents and watch capabilities. A dedicated bot credential can create intents, inspect/recover its pending share intents, and issue watch links. Desktop operations use the existing device bearer. A share intent is single-use and can be completed only by the device that consumed it, after that device creates its ordinary `screen_share` session. A watch capability is opaque, short-lived, session-scoped, and does not bypass the existing pairing, authorization, or viewer-capacity checks. +2. **FrameRelay (`dotnet_FrameRelay`)** accepts `framerelay://open/share/` and `framerelay://open/watch/` activation. It resolves intents through its authenticated device API, then uses the existing share/watch runtime. A local activation click is required before capture. A single-instance forwarder sends subsequent activations to the running app. +3. **Discord bot (`go_discord_FrameRelay`)** is a separate Go module and local Git repository in the parent folder. It uses Go 1.27+, a pinned DisGo release, no privileged Gateway intents, and no database. It registers `/framerelay share` and `/framerelay watch `, polls/reconciles pending share intents through RelayControl, serves health/readiness endpoints, and runs in a non-root container. + +## HTTP contract + +- Bot service authentication is a dedicated rotatable secret with only launch-intent permissions. It is never logged or returned. Desktop endpoints require the existing device bearer. +- `POST /api/launch-intents/share` creates an expiring intent and returns an opaque public HTTPS launch URL. Correlation data is limited to the Discord guild, channel, and requesting user IDs. +- Desktop consume is atomic and single-use. Completion accepts the resulting session ID only when the authenticated device is that session's source device. Replay and cross-device completion fail. +- Bot list/status operations let a restarted bot recover unresolved intents and discover ready sessions. Ready state yields an opaque watch launch URL. +- `POST /api/launch-intents/watch` normalizes a supplied code using trim + uppercase, validates the active session and creates an opaque capability. Desktop resolution returns only the data needed to join through its normal authenticated session API; pairing and max-viewer enforcement remain authoritative. +- HTTPS `/open/share/{token}` and `/open/watch/{token}` pages attempt the corresponding custom URI and show installation guidance if activation is unavailable. Public URLs contain no device credentials, session codes, or reusable service credentials. + +## Desktop activation + +The executable parses protocol activation at startup. If another instance owns the per-user activation endpoint, the new process forwards the URI and exits. The active instance foregrounds its window and dispatches activation on the UI thread. Share activation consumes the intent before navigating to Share and invokes the current capture/session pipeline only after the user's local activation; watch activation resolves the capability and runs the existing join/WebRTC flow. Missing capture selection leaves the user on Share for a local choice. No parallel media or signaling stack is introduced. + +## Safety and recovery + +- Share intents have a short default lifetime (10 minutes), are consumed once, and cannot create a session by themselves. +- Watch capabilities are bounded by their session lifetime and existing join authorization/capacity rules. +- Tokens are stored hashed server-side and redacted from logs. The bot never receives device credentials or access JWTs. +- The backend retains enough bot correlation state to recover unresolved work after process restart; the bot uses no database. +- Discord publication is public only after the publisher session is ready. Initial launch responses and errors are ephemeral. +- Bot readiness requires Discord Gateway and RelayControl availability. Shutdown cancels watchers, closes Gateway, and stops HTTP cleanly. + +## Delivery boundaries + +Changes are maintained on independent branches and pull requests for FrameRelay and RelayControl. The Go module is developed as a separate local repository until its remote repository is specified. Existing WebRTC, signaling, TURN, capture, and device identity behavior remains the implementation used by these flows. diff --git a/src/SonicDesktopRelay.ApiClient/LaunchIntentApiClient.cs b/src/SonicDesktopRelay.ApiClient/LaunchIntentApiClient.cs new file mode 100644 index 00000000..ff329b1d --- /dev/null +++ b/src/SonicDesktopRelay.ApiClient/LaunchIntentApiClient.cs @@ -0,0 +1,33 @@ +using System.Net.Http.Json; + +namespace SonicDesktopRelay.ApiClient; + +public sealed record ConsumeShareIntentRequest(string Token); +public sealed record ConsumeShareIntentResponse(Guid Id, DateTimeOffset ExpiresAt); +public sealed record CompleteShareIntentRequest(Guid SessionId); +public sealed record ResolveWatchIntentRequest(string Token); +public sealed record ResolveWatchIntentResponse(Guid SessionId); + +public sealed class LaunchIntentApiClient(HttpClient http) +{ + public async Task ConsumeShareAsync(string token, CancellationToken ct) + { + var response = await http.PostAsJsonAsync("/api/launch-intents/share/consume", + new ConsumeShareIntentRequest(token), ct); + return await ApiResponse.ReadAsync(response, ct); + } + + public async Task CompleteShareAsync(Guid intentId, Guid sessionId, CancellationToken ct) + { + var response = await http.PostAsJsonAsync($"/api/launch-intents/share/{intentId}/complete", + new CompleteShareIntentRequest(sessionId), ct); + await ApiResponse.EnsureSuccessAsync(response, ct); + } + + public async Task ResolveWatchAsync(string token, CancellationToken ct) + { + var response = await http.PostAsJsonAsync("/api/launch-intents/watch/resolve", + new ResolveWatchIntentRequest(token), ct); + return (await ApiResponse.ReadAsync(response, ct)).SessionId; + } +} diff --git a/src/SonicDesktopRelay.ApiClient/SessionApiClient.cs b/src/SonicDesktopRelay.ApiClient/SessionApiClient.cs index 96d8c481..9e45742e 100644 --- a/src/SonicDesktopRelay.ApiClient/SessionApiClient.cs +++ b/src/SonicDesktopRelay.ApiClient/SessionApiClient.cs @@ -23,6 +23,12 @@ public async Task JoinAsync(string code, CancellationToken ct) return await ApiResponse.ReadAsync(response, ct); } + public async Task JoinByIdAsync(Guid sessionId, CancellationToken ct) + { + var response = await http.PostAsync($"/api/sessions/{sessionId}/join", content: null, ct); + return await ApiResponse.ReadAsync(response, ct); + } + public async Task GetParticipantsAsync(Guid sessionId, CancellationToken ct) { var response = await http.GetAsync($"/api/sessions/{sessionId}/participants", ct); diff --git a/src/SonicDesktopRelay.App/AppComposition.cs b/src/SonicDesktopRelay.App/AppComposition.cs index 8417ca43..ba19ae0b 100644 --- a/src/SonicDesktopRelay.App/AppComposition.cs +++ b/src/SonicDesktopRelay.App/AppComposition.cs @@ -34,6 +34,7 @@ public AppComposition(BackendSettings settings, string deviceName) { BaseAddress = settings.BaseAddress }; + LaunchIntents = new LaunchIntentApiClient(sessionHttp); // Both media roles need the very connection the runtime is using — offers, answers and // ICE all travel over the same socket — so the factory hands out the decorated current @@ -72,6 +73,8 @@ public AppComposition(BackendSettings settings, string deviceName) public SessionRuntime Runtime { get; } + public LaunchIntentApiClient LaunchIntents { get; } + public RtcVideoPublishHost PublishHost { get; } public RtcVideoWatchHost WatchHost { get; } @@ -131,6 +134,18 @@ public async Task JoinAsync(string code, CancellationToken ct) } } + public async Task JoinByIdAsync(Guid sessionId, CancellationToken ct) + { + try + { + return (await client.JoinByIdAsync(sessionId, ct)).Id; + } + catch (ApiException e) + { + throw Translate(e); + } + } + public async Task EndAsync(Guid sessionId, CancellationToken ct) { try diff --git a/src/SonicDesktopRelay.App/FrameRelayProtocolRegistration.cs b/src/SonicDesktopRelay.App/FrameRelayProtocolRegistration.cs new file mode 100644 index 00000000..b7ad3b02 --- /dev/null +++ b/src/SonicDesktopRelay.App/FrameRelayProtocolRegistration.cs @@ -0,0 +1,30 @@ +using Microsoft.Win32; +using Microsoft.Extensions.Logging; + +namespace SonicDesktopRelay.App; + +internal static class FrameRelayProtocolRegistration +{ + public static void EnsureRegistered(ILogger logger) + { + var executable = Environment.ProcessPath; + if (string.IsNullOrWhiteSpace(executable)) + { + logger.LogWarning("Could not register the FrameRelay link protocol because the executable path is unavailable."); + return; + } + + try + { + using var protocol = Registry.CurrentUser.CreateSubKey("Software\\Classes\\framerelay", writable: true); + protocol.SetValue(string.Empty, "URL:FrameRelay Protocol", RegistryValueKind.String); + protocol.SetValue("URL Protocol", string.Empty, RegistryValueKind.String); + using var command = protocol.CreateSubKey("shell\\open\\command", writable: true); + command.SetValue(string.Empty, $"\"{executable}\" \"%1\"", RegistryValueKind.String); + } + catch (Exception exception) when (exception is UnauthorizedAccessException or IOException or System.Security.SecurityException) + { + logger.LogWarning("Could not register the FrameRelay link protocol. type={ExceptionType}", exception.GetType().Name); + } + } +} diff --git a/src/SonicDesktopRelay.App/LaunchActivationCoordinator.cs b/src/SonicDesktopRelay.App/LaunchActivationCoordinator.cs new file mode 100644 index 00000000..d463e5de --- /dev/null +++ b/src/SonicDesktopRelay.App/LaunchActivationCoordinator.cs @@ -0,0 +1,139 @@ +using System.IO.Pipes; +using System.Security.Principal; +using System.Text; +using Microsoft.Extensions.Logging; +using SonicDesktopRelay.Core; + +namespace SonicDesktopRelay.App; + +internal sealed class LaunchActivationCoordinator : IDisposable +{ + private const string MutexPrefix = "Local\\FrameRelay.Activation."; + private const string PipePrefix = "FrameRelay.Activation."; + private readonly Mutex _mutex; + private readonly CancellationTokenSource _shutdown = new(); + private readonly ILogger _logger; + private readonly string _pipeName; + private readonly Task _serverTask; + private bool _ownsMutex; + + private LaunchActivationCoordinator(ILogger logger) + { + _logger = logger; + var userId = WindowsIdentity.GetCurrent().User?.Value ?? Environment.UserName; + var safeUserId = string.Concat(userId.Select(c => char.IsAsciiLetterOrDigit(c) ? c : '_')); + _pipeName = PipePrefix + safeUserId; + _mutex = new Mutex(false, MutexPrefix + safeUserId); + try + { + _ownsMutex = _mutex.WaitOne(0); + } + catch (AbandonedMutexException) + { + _ownsMutex = true; + } + + if (_ownsMutex) _serverTask = RunServerAsync(_shutdown.Token); + else _serverTask = Task.CompletedTask; + } + + public event Action? Activated; + + public static async Task<(LaunchActivationCoordinator? Coordinator, string[] Arguments, LaunchActivation? Initial)> + StartAsync(string[] arguments, ILogger logger, CancellationToken ct) + { + var remaining = LaunchActivationParser.RemoveActivationArgument(arguments, out var initial); + var coordinator = new LaunchActivationCoordinator(logger); + if (coordinator._ownsMutex) + { + return (coordinator, remaining, initial); + } + + if (initial is not null) + { + var forwarded = await TryForwardAsync(initial, coordinator._pipeName, ct); + if (!forwarded) logger.LogWarning("Could not forward FrameRelay activation to the running instance."); + } + coordinator.Dispose(); + return (null, remaining, initial); + } + + public void Dispose() + { + _shutdown.Cancel(); + try { _serverTask.Wait(TimeSpan.FromSeconds(2)); } + catch (AggregateException) { } + if (_ownsMutex) + { + _mutex.ReleaseMutex(); + _ownsMutex = false; + } + _mutex.Dispose(); + _shutdown.Dispose(); + } + + private async Task RunServerAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try + { + await using var pipe = new NamedPipeServerStream(_pipeName, PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + await pipe.WaitForConnectionAsync(ct); + using var reader = new StreamReader(pipe, Encoding.UTF8, false, 1024, leaveOpen: true); + await using var writer = new StreamWriter(pipe, Encoding.UTF8, 1024, leaveOpen: true) { AutoFlush = true }; + var line = await reader.ReadLineAsync(ct); + if (LaunchActivationParser.TryParse(line, out var activation)) + { + Dispatch(activation!); + await writer.WriteLineAsync("OK"); + } + else + { + await writer.WriteLineAsync("REJECTED"); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + catch (IOException) + { + if (!ct.IsCancellationRequested) _logger.LogDebug("FrameRelay activation pipe client disconnected."); + } + catch (Exception exception) + { + _logger.LogWarning("FrameRelay activation pipe failed. type={ExceptionType}", exception.GetType().Name); + } + } + } + + private void Dispatch(LaunchActivation activation) + { + try { Activated?.Invoke(activation); } + catch (Exception exception) + { + _logger.LogWarning("FrameRelay activation could not be dispatched. type={ExceptionType}", exception.GetType().Name); + } + } + + private static async Task TryForwardAsync(LaunchActivation activation, string pipeName, CancellationToken ct) + { + using var pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); + try + { + await pipe.ConnectAsync(4000, ct); + using var reader = new StreamReader(pipe, Encoding.UTF8, false, 1024, leaveOpen: true); + await using var writer = new StreamWriter(pipe, Encoding.UTF8, 1024, leaveOpen: true) { AutoFlush = true }; + var mode = activation.Kind == LaunchActivationKind.Share ? "share" : "watch"; + await writer.WriteLineAsync($"framerelay://open/{mode}/{activation.Token}"); + return await reader.ReadLineAsync(ct) == "OK"; + } + catch (Exception exception) when (exception is IOException or TimeoutException or OperationCanceledException) + { + return false; + } + } +} diff --git a/src/SonicDesktopRelay.App/LaunchActivationRouter.cs b/src/SonicDesktopRelay.App/LaunchActivationRouter.cs new file mode 100644 index 00000000..5af306bb --- /dev/null +++ b/src/SonicDesktopRelay.App/LaunchActivationRouter.cs @@ -0,0 +1,51 @@ +using SonicDesktopRelay.Core; + +namespace SonicDesktopRelay.App; + +internal static class LaunchActivationRouter +{ + private static readonly object Gate = new(); + private static Action? _handler; + private static LaunchActivation? _pending; + + public static void SetInitial(LaunchActivation? activation) + { + if (activation is null) return; + Action? handler; + lock (Gate) + { + handler = _handler; + if (handler is null) _pending = activation; + } + handler?.Invoke(activation); + } + + public static void Dispatch(LaunchActivation activation) + { + Action? handler; + lock (Gate) + { + handler = _handler; + if (handler is null) _pending = activation; + } + handler?.Invoke(activation); + } + + public static void Register(Action handler) + { + LaunchActivation? pending; + lock (Gate) + { + _handler = handler; + pending = _pending; + _pending = null; + } + if (pending is not null) handler(pending); + } + + public static void Unregister(Action handler) + { + lock (Gate) + if (_handler == handler) _handler = null; + } +} diff --git a/src/SonicDesktopRelay.App/Program.cs b/src/SonicDesktopRelay.App/Program.cs index 4ef083d7..0fb5abf8 100644 --- a/src/SonicDesktopRelay.App/Program.cs +++ b/src/SonicDesktopRelay.App/Program.cs @@ -11,6 +11,7 @@ public static void Main(string[] args) { using var logging = FrameRelayLogging.InitializeDefault(); var logger = logging.LoggerFactory.CreateLogger("FrameRelay.Program"); + FrameRelayProtocolRegistration.EnsureRegistered(logger); AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) => { @@ -43,7 +44,15 @@ public static void Main(string[] args) try { - BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + var startup = LaunchActivationCoordinator.StartAsync(args, logger, CancellationToken.None) + .GetAwaiter().GetResult(); + if (startup.Coordinator is null) return; + using (startup.Coordinator) + { + startup.Coordinator.Activated += LaunchActivationRouter.Dispatch; + LaunchActivationRouter.SetInitial(startup.Initial); + BuildAvaloniaApp().StartWithClassicDesktopLifetime(startup.Arguments); + } } catch (Exception exception) { diff --git a/src/SonicDesktopRelay.App/Shell.cs b/src/SonicDesktopRelay.App/Shell.cs index 53acecd1..4c243fb8 100644 --- a/src/SonicDesktopRelay.App/Shell.cs +++ b/src/SonicDesktopRelay.App/Shell.cs @@ -1,10 +1,13 @@ using System.Collections.ObjectModel; using System.ComponentModel; +using System.Net; +using System.Net.Http; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using Avalonia.Threading; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using SonicDesktopRelay.ApiClient; using SonicDesktopRelay.Core; using SonicDesktopRelay.Media; using SonicDesktopRelay.Media.Windows; @@ -44,6 +47,7 @@ public sealed class Shell : INotifyPropertyChanged private ShareQualityOption? _selectedShareQuality; private ShareFrameRateOption? _selectedShareFrameRate; private bool _isVideoFullScreen; + private Guid? _pendingShareIntentId; private long _uiFramesDelivered; private long _lastUiFrameUtcTicks; @@ -448,6 +452,93 @@ public async Task ShareAsync(CancellationToken ct) var profile = new VideoPublishProfile(quality.MaxHeight, frameRate.FramesPerSecond); await GuardAsync(() => runtime.StartSharingAsync(target, profile, DefaultMaxViewers, ct)); + if (_pendingShareIntentId is { } intentId + && runtime.Snapshot.Phase == SessionPhase.Sharing + && runtime.Snapshot.SessionId is { } sessionId) + { + try + { + await CompleteShareWithRetryAsync(intentId, sessionId, ct); + _pendingShareIntentId = null; + } + catch (ApiException exception) + { + _pendingShareIntentId = null; + _logger.LogWarning("Could not complete share launch intent. intent={IntentId} code={ErrorCode}", + intentId, exception.ErrorCode); + ShellError = "The share started, but Discord could not be notified. The session remains available in FrameRelay."; + } + catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException) + { + if (ct.IsCancellationRequested) throw; + _pendingShareIntentId = null; + _logger.LogWarning("Could not complete share launch intent. intent={IntentId} type={ExceptionType}", + intentId, exception.GetType().Name); + ShellError = "The share started, but Discord could not be notified. The session remains available in FrameRelay."; + } + } + } + + private async Task CompleteShareWithRetryAsync(Guid intentId, Guid sessionId, CancellationToken ct) + { + var delays = new[] { TimeSpan.Zero, TimeSpan.FromMilliseconds(500), TimeSpan.FromSeconds(1) }; + for (var attempt = 0; attempt < delays.Length; attempt++) + { + if (delays[attempt] > TimeSpan.Zero) await Task.Delay(delays[attempt], ct); + try + { + await _composition!.LaunchIntents.CompleteShareAsync(intentId, sessionId, ct); + return; + } + catch (ApiException exception) when (attempt < delays.Length - 1 + && (exception.StatusCode == HttpStatusCode.TooManyRequests + || (int)exception.StatusCode >= 500)) + { + // A lost successful response is safe to retry because RelayControl completion is idempotent. + } + catch (HttpRequestException) when (attempt < delays.Length - 1) + { + // Retry transient network failures while leaving media streaming active. + } + catch (TaskCanceledException) when (!ct.IsCancellationRequested && attempt < delays.Length - 1) + { + // The API client timed out before confirming the launch intent. + } + } + throw new InvalidOperationException("Share launch confirmation attempts were exhausted."); + } + + public async Task ActivateLaunchAsync(LaunchActivation activation, CancellationToken ct) + { + var runtime = TryGetRuntime(); + if (runtime is null || _composition is null) return; + ShellError = null; + try + { + if (activation.Kind == LaunchActivationKind.Share) + { + var intent = await _composition.LaunchIntents.ConsumeShareAsync(activation.Token, ct); + _pendingShareIntentId = intent.Id; + ViewModel.CurrentPage = Page.Share; + if (SelectedCaptureTarget is not null) await ShareAsync(ct); + return; + } + + var sessionId = await _composition.LaunchIntents.ResolveWatchAsync(activation.Token, ct); + ViewModel.CurrentPage = Page.Watch; + await GuardAsync(() => runtime.StartWatchingSessionAsync(sessionId, ct)); + } + catch (ApiException exception) + { + _logger.LogWarning("Launch activation was refused. kind={Kind} code={ErrorCode} status={StatusCode}", + activation.Kind, exception.ErrorCode, exception.StatusCode); + ShellError = exception.ErrorCode switch + { + "invalid_or_expired_intent" => "This FrameRelay launch link has expired or was already used.", + "invalid_or_expired_watch_link" or "session_unavailable" => "This FrameRelay share has ended or the link has expired.", + _ => "FrameRelay could not open that launch link. Try again from Discord." + }; + } } private static bool SameWindowIdentity(WindowInfo left, WindowInfo right) => diff --git a/src/SonicDesktopRelay.App/Views/MainWindow.axaml.cs b/src/SonicDesktopRelay.App/Views/MainWindow.axaml.cs index 67c71184..49125d06 100644 --- a/src/SonicDesktopRelay.App/Views/MainWindow.axaml.cs +++ b/src/SonicDesktopRelay.App/Views/MainWindow.axaml.cs @@ -1,6 +1,9 @@ using System.Runtime.Versioning; using Avalonia.Controls; using Avalonia.Interactivity; +using Avalonia.Threading; +using Microsoft.Extensions.Logging; +using SonicDesktopRelay.Core; using AppPage = SonicDesktopRelay.Presentation.Page; namespace SonicDesktopRelay.App.Views; @@ -8,12 +11,38 @@ namespace SonicDesktopRelay.App.Views; [SupportedOSPlatform("windows10.0.19041.0")] public partial class MainWindow : Window { + private readonly Action _activationHandler; + private readonly ILogger _logger; + public MainWindow() { InitializeComponent(); DataContext = new Shell(); + _logger = FrameRelayLogging.Current?.LoggerFactory.CreateLogger("FrameRelay.LaunchActivation") + ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + _activationHandler = OnLaunchActivation; + LaunchActivationRouter.Register(_activationHandler); + Closed += (_, _) => LaunchActivationRouter.Unregister(_activationHandler); } + private void OnLaunchActivation(LaunchActivation activation) => + Dispatcher.UIThread.Post(async () => + { + if (!IsVisible) Show(); + if (WindowState == WindowState.Minimized) WindowState = WindowState.Normal; + Activate(); + try + { + if (DataContext is Shell shell) + await shell.ActivateLaunchAsync(activation, CancellationToken.None); + } + catch (Exception exception) + { + _logger.LogError("Could not process FrameRelay launch activation. kind={Kind} type={ExceptionType}", + activation.Kind, exception.GetType().Name); + } + }); + private void OnNavigate(object? sender, RoutedEventArgs e) { if (sender is Button { Tag: string tag } diff --git a/src/SonicDesktopRelay.Core/LaunchActivation.cs b/src/SonicDesktopRelay.Core/LaunchActivation.cs new file mode 100644 index 00000000..12c35e0c --- /dev/null +++ b/src/SonicDesktopRelay.Core/LaunchActivation.cs @@ -0,0 +1,48 @@ +namespace SonicDesktopRelay.Core; + +public enum LaunchActivationKind +{ + Share, + Watch +} + +public sealed record LaunchActivation(LaunchActivationKind Kind, string Token); + +public static class LaunchActivationParser +{ + public static bool TryParse(string? value, out LaunchActivation? activation) + { + activation = null; + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) + || uri.Scheme != "framerelay" || uri.Host != "open" + || uri.Query.Length != 0 || uri.Fragment.Length != 0) + return false; + + var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length != 2 || segments[1].Length != 43 + || segments[1].Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '-' or '_'))) + return false; + + var kind = segments[0] switch + { + "share" => LaunchActivationKind.Share, + "watch" => LaunchActivationKind.Watch, + _ => (LaunchActivationKind?)null + }; + if (kind is null) return false; + activation = new LaunchActivation(kind.Value, segments[1]); + return true; + } + + public static string[] RemoveActivationArgument(IReadOnlyList arguments, out LaunchActivation? activation) + { + activation = null; + var remaining = new List(arguments.Count); + foreach (var argument in arguments) + { + if (TryParse(argument, out var parsed)) activation = parsed; + else remaining.Add(argument); + } + return [.. remaining]; + } +} diff --git a/src/SonicDesktopRelay.Presentation/SessionRuntime.cs b/src/SonicDesktopRelay.Presentation/SessionRuntime.cs index c3711ac1..67855c77 100644 --- a/src/SonicDesktopRelay.Presentation/SessionRuntime.cs +++ b/src/SonicDesktopRelay.Presentation/SessionRuntime.cs @@ -123,13 +123,19 @@ public async Task StartSharingAsync( } } - public async Task StartWatchingAsync(string code, CancellationToken ct) + public Task StartWatchingAsync(string code, CancellationToken ct) => + StartWatchingCoreAsync(token => api.JoinAsync(code, token), ct); + + public Task StartWatchingSessionAsync(Guid sessionId, CancellationToken ct) => + StartWatchingCoreAsync(token => api.JoinByIdAsync(sessionId, token), ct); + + private async Task StartWatchingCoreAsync(Func> join, CancellationToken ct) { RequireIdle(); Publish(Snapshot with { Phase = SessionPhase.Joining, Error = null }); try { - var sessionId = await api.JoinAsync(code, ct); + var sessionId = await join(ct); _isOwner = false; await AttachAsync(sessionId, ct); diff --git a/src/SonicDesktopRelay.Presentation/SessionSnapshot.cs b/src/SonicDesktopRelay.Presentation/SessionSnapshot.cs index 5cd690d3..443085d5 100644 --- a/src/SonicDesktopRelay.Presentation/SessionSnapshot.cs +++ b/src/SonicDesktopRelay.Presentation/SessionSnapshot.cs @@ -61,6 +61,8 @@ public interface ISessionApi Task JoinAsync(string code, CancellationToken ct); + Task JoinByIdAsync(Guid sessionId, CancellationToken ct); + Task EndAsync(Guid sessionId, CancellationToken ct); } diff --git a/tests/SonicDesktopRelay.ApiClient.Tests/LaunchIntentApiClientTests.cs b/tests/SonicDesktopRelay.ApiClient.Tests/LaunchIntentApiClientTests.cs new file mode 100644 index 00000000..5b8337a5 --- /dev/null +++ b/tests/SonicDesktopRelay.ApiClient.Tests/LaunchIntentApiClientTests.cs @@ -0,0 +1,44 @@ +using System.Net; +using Xunit; + +namespace SonicDesktopRelay.ApiClient.Tests; + +public sealed class LaunchIntentApiClientTests +{ + [Fact] + public async Task Share_consume_and_complete_use_device_authenticated_launch_routes() + { + var handler = new StubHttpMessageHandler() + .Respond(HttpStatusCode.OK, "{\"id\":\"6f9619ff-8b86-d011-b42d-00cf4fc964ff\",\"expiresAt\":\"2026-09-24T12:00:00Z\"}") + .Respond(HttpStatusCode.NoContent, string.Empty); + var client = new LaunchIntentApiClient(HttpClientFor(handler)); + var intentId = Guid.Parse("b06d9b89-4980-4163-92de-9ee77960c485"); + var sessionId = Guid.Parse("6f9619ff-8b86-d011-b42d-00cf4fc964ff"); + + var consumed = await client.ConsumeShareAsync("opaque-token", CancellationToken.None); + await client.CompleteShareAsync(intentId, sessionId, CancellationToken.None); + + Assert.Equal("/api/launch-intents/share/consume", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Contains("opaque-token", handler.RequestBodies[0]); + Assert.Equal($"/api/launch-intents/share/{intentId}/complete", handler.Requests[1].RequestUri!.AbsolutePath); + Assert.Contains(sessionId.ToString(), handler.RequestBodies[1]); + Assert.Equal(sessionId, consumed.Id); + } + + [Fact] + public async Task Watch_resolution_returns_the_session_id_without_a_code() + { + var handler = new StubHttpMessageHandler().Respond(HttpStatusCode.OK, + "{\"sessionId\":\"6f9619ff-8b86-d011-b42d-00cf4fc964ff\"}"); + var client = new LaunchIntentApiClient(HttpClientFor(handler)); + + var sessionId = await client.ResolveWatchAsync("opaque-watch-token", CancellationToken.None); + + Assert.Equal("/api/launch-intents/watch/resolve", handler.Requests[0].RequestUri!.AbsolutePath); + Assert.Contains("opaque-watch-token", handler.RequestBodies[0]); + Assert.Equal(Guid.Parse("6f9619ff-8b86-d011-b42d-00cf4fc964ff"), sessionId); + } + + private static HttpClient HttpClientFor(StubHttpMessageHandler handler) => + new(handler) { BaseAddress = new Uri("https://relay.example.com") }; +} diff --git a/tests/SonicDesktopRelay.ApiClient.Tests/SessionApiClientTests.cs b/tests/SonicDesktopRelay.ApiClient.Tests/SessionApiClientTests.cs index 7ecc8748..c5b54523 100644 --- a/tests/SonicDesktopRelay.ApiClient.Tests/SessionApiClientTests.cs +++ b/tests/SonicDesktopRelay.ApiClient.Tests/SessionApiClientTests.cs @@ -38,6 +38,18 @@ public async Task Join_uppercases_and_trims_the_code_before_sending_it() Assert.Contains("\"code\":\"AB12CD\"", handler.RequestBodies[0]); } + [Fact] + public async Task Join_by_id_uses_existing_authenticated_session_join_route() + { + var handler = new StubHttpMessageHandler().Respond(HttpStatusCode.OK, CreatedBody); + var client = new SessionApiClient(HttpClientFor(handler)); + var sessionId = Guid.Parse("6f9619ff-8b86-d011-b42d-00cf4fc964ff"); + + await client.JoinByIdAsync(sessionId, CancellationToken.None); + + Assert.Equal($"/api/sessions/{sessionId}/join", handler.Requests[0].RequestUri!.AbsolutePath); + } + [Fact] public async Task Join_surfaces_device_type_not_allowed_as_the_error_code() { diff --git a/tests/SonicDesktopRelay.Core.Tests/LaunchActivationTests.cs b/tests/SonicDesktopRelay.Core.Tests/LaunchActivationTests.cs new file mode 100644 index 00000000..0ec2e08e --- /dev/null +++ b/tests/SonicDesktopRelay.Core.Tests/LaunchActivationTests.cs @@ -0,0 +1,38 @@ +using SonicDesktopRelay.Core; +using Xunit; + +namespace SonicDesktopRelay.Core.Tests; + +public sealed class LaunchActivationTests +{ + [Theory] + [InlineData("framerelay://open/share/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", LaunchActivationKind.Share)] + [InlineData("framerelay://open/watch/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", LaunchActivationKind.Watch)] + public void Parses_only_supported_activation_uris(string value, LaunchActivationKind expectedKind) + { + Assert.True(LaunchActivationParser.TryParse(value, out var activation)); + Assert.Equal(expectedKind, activation!.Kind); + Assert.Equal(new string('a', 43), activation.Token); + } + + [Theory] + [InlineData("https://framerelay.hugojava.dev/open/share/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] + [InlineData("framerelay://open/admin/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] + [InlineData("framerelay://open/share/short")] + [InlineData("framerelay://open/share/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?x=1")] + [InlineData("framerelay://open/share/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa#token")] + [InlineData("framerelay://open/share/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!")] + public void Rejects_invalid_or_non_custom_uris(string value) => + Assert.False(LaunchActivationParser.TryParse(value, out _)); + + [Fact] + public void Removes_activation_arguments_and_preserves_regular_arguments() + { + var remaining = LaunchActivationParser.RemoveActivationArgument( + ["--diagnostics", "framerelay://open/watch/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + out var activation); + + Assert.Equal(["--diagnostics"], remaining); + Assert.Equal(LaunchActivationKind.Watch, activation!.Kind); + } +} diff --git a/tests/SonicDesktopRelay.Presentation.Tests/SessionRuntimeTests.cs b/tests/SonicDesktopRelay.Presentation.Tests/SessionRuntimeTests.cs index 82a189f7..2dc6cf93 100644 --- a/tests/SonicDesktopRelay.Presentation.Tests/SessionRuntimeTests.cs +++ b/tests/SonicDesktopRelay.Presentation.Tests/SessionRuntimeTests.cs @@ -61,6 +61,19 @@ public async Task Watching_joins_with_the_code_and_reaches_watching() Assert.Equal("ab12cd", api.JoinedWithCode); } + [Fact] + public async Task Watching_joins_by_resolved_session_id_through_existing_runtime() + { + var api = new FakeSessionApi(); + var runtime = new SessionRuntime(api, () => new FakeConnection()); + + await runtime.StartWatchingSessionAsync(SessionId, CancellationToken.None); + + Assert.Equal(SessionPhase.Watching, runtime.Snapshot.Phase); + Assert.Equal(SessionId, api.JoinedById); + Assert.Equal(SessionId, runtime.Snapshot.SessionId); + } + [Fact] public async Task A_join_failure_lands_in_failed_with_the_error_code() { @@ -658,6 +671,8 @@ private sealed class FakeSessionApi : ISessionApi public string? JoinedWithCode { get; private set; } + public Guid? JoinedById { get; private set; } + public string? JoinFailureCode { get; init; } public Task CreateScreenShareAsync(int maxViewers, CancellationToken ct) @@ -675,6 +690,14 @@ public Task JoinAsync(string code, CancellationToken ct) return Task.FromResult(SessionId); } + public Task JoinByIdAsync(Guid sessionId, CancellationToken ct) + { + JoinedById = sessionId; + if (JoinFailureCode is not null) + throw new SessionApiFailure(JoinFailureCode, "Join refused."); + return Task.FromResult(sessionId); + } + public Task EndAsync(Guid sessionId, CancellationToken ct) { EndCalls++; diff --git a/tests/SonicDesktopRelay.Presentation.Tests/SignalingDiagnosticsTests.cs b/tests/SonicDesktopRelay.Presentation.Tests/SignalingDiagnosticsTests.cs index 95c44101..32f4392e 100644 --- a/tests/SonicDesktopRelay.Presentation.Tests/SignalingDiagnosticsTests.cs +++ b/tests/SonicDesktopRelay.Presentation.Tests/SignalingDiagnosticsTests.cs @@ -113,6 +113,8 @@ public Task CreateScreenShareAsync(int maxViewers, CancellationT public Task JoinAsync(string code, CancellationToken ct) => Task.FromResult(SessionId); + public Task JoinByIdAsync(Guid sessionId, CancellationToken ct) => Task.FromResult(sessionId); + public Task EndAsync(Guid sessionId, CancellationToken ct) => Task.CompletedTask; }