AuthenticateWithBrowserAsync()
+ {
+ if (_apiClient == null)
+ return false;
+
+ const int callbackPort = 54321;
+ var redirectUri = $"http://127.0.0.1:{callbackPort}/callback";
+
+ AuthConfigResult config;
+ try
+ {
+ config = await _apiClient.GetAuthConfigAsync();
+ }
+ catch
+ {
+ return false;
+ }
+
+ if (string.IsNullOrEmpty(config.DiscordClientId))
+ return false;
+
+ var state = PkceHelper.GenerateState();
+ var codeVerifier = PkceHelper.GenerateVerifier();
+ var codeChallenge = PkceHelper.GenerateChallenge(codeVerifier);
+
+ var authUrl =
+ "https://discord.com/api/oauth2/authorize?" +
+ $"client_id={Uri.EscapeDataString(config.DiscordClientId)}" +
+ $"&redirect_uri={Uri.EscapeDataString(redirectUri)}" +
+ "&response_type=code" +
+ "&scope=identify" +
+ $"&state={Uri.EscapeDataString(state)}" +
+ $"&code_challenge={Uri.EscapeDataString(codeChallenge)}" +
+ "&code_challenge_method=S256";
+
+ var listener = new HttpListener();
+ listener.Prefixes.Add($"http://127.0.0.1:{callbackPort}/callback/");
+ listener.Start();
+
+ try
+ {
+ Process.Start(new ProcessStartInfo(authUrl) { UseShellExecute = true });
+
+ var getContextTask = listener.GetContextAsync();
+ var delayTask = Task.Delay(TimeSpan.FromMinutes(5));
+ var completed = await Task.WhenAny(getContextTask, delayTask).ConfigureAwait(false);
+
+ if (completed == delayTask)
+ {
+ listener.Stop();
+ return false;
+ }
+
+ var context = await getContextTask.ConfigureAwait(false);
+ var query = context.Request.QueryString;
+ var receivedState = query["state"];
+ var code = query["code"];
+
+ var responseHtml = "WinPodiumsSuccess, you can close this window.
";
+ var responseBytes = System.Text.Encoding.UTF8.GetBytes(responseHtml);
+ context.Response.ContentType = "text/html; charset=utf-8";
+ context.Response.ContentLength64 = responseBytes.Length;
+ await context.Response.OutputStream.WriteAsync(responseBytes, 0, responseBytes.Length).ConfigureAwait(false);
+ context.Response.OutputStream.Close();
+
+ if (receivedState != state || string.IsNullOrEmpty(code))
+ {
+ listener.Stop();
+ return false;
+ }
+
+ listener.Stop();
+
+ var result = await _apiClient.DiscordExchangeAsync(code, codeVerifier, redirectUri).ConfigureAwait(false);
+ if (string.IsNullOrEmpty(result.AccessToken) || string.IsNullOrEmpty(result.DiscordId))
+ return false;
+
+ TokenStorage.Save(result.AccessToken!, result.DiscordId!);
+ return true;
+ }
+ catch
+ {
+ try { listener.Stop(); } catch { }
+ return false;
+ }
+ }
+
+ ///
+ /// Authenticate using a one-time token from the website (manual flow). Debug only, feature-flagged.
/// Call after user pastes token from https://winpodiums.com/auth/token.
///
/// The 8-character token from the website.
@@ -133,5 +229,13 @@ public void Logout()
{
TokenStorage.Clear();
}
+
+ ///
+ /// Return the WPF settings control for SimHub (Link to Discord, Send heartbeat, status). TP-SPOC-004.
+ ///
+ public Control GetWPFSettingsControl(PluginManager pluginManager)
+ {
+ return new WinPodiumsSettingsControl(this);
+ }
}
}
diff --git a/apps/plugin/WinPodiums.Plugin/Services/ApiClient.cs b/apps/plugin/WinPodiums.Plugin/Services/ApiClient.cs
index 8845e6f..84db611 100644
--- a/apps/plugin/WinPodiums.Plugin/Services/ApiClient.cs
+++ b/apps/plugin/WinPodiums.Plugin/Services/ApiClient.cs
@@ -24,7 +24,62 @@ public ApiClient(string baseUrl = "https://winpodiums.com")
}
///
- /// Exchange a one-time manual token for Discord ID and access token.
+ /// Get public auth config (e.g. Discord client ID for PKCE authorize URL). No auth required.
+ ///
+ public async Task GetAuthConfigAsync()
+ {
+ var url = $"{_baseUrl}/api/auth/config";
+ var res = await _http.GetAsync(url);
+ var json = await res.Content.ReadAsStringAsync();
+ if (!res.IsSuccessStatusCode)
+ {
+ var err = TryParseError(json);
+ throw new ApiException(err ?? $"HTTP {(int)res.StatusCode}", (int)res.StatusCode);
+ }
+ var obj = JObject.Parse(json);
+ var data = obj["data"];
+ if (data == null)
+ throw new ApiException("Invalid response: missing data", 200);
+ return new AuthConfigResult
+ {
+ DiscordClientId = data["discordClientId"]?.ToString() ?? ""
+ };
+ }
+
+ ///
+ /// Exchange PKCE authorization code for Discord ID and access token (plugin browser flow).
+ ///
+ public async Task DiscordExchangeAsync(string code, string codeVerifier, string redirectUri)
+ {
+ var url = $"{_baseUrl}/api/auth/discord/exchange";
+ var body = new JObject
+ {
+ ["code"] = code?.Trim(),
+ ["code_verifier"] = codeVerifier?.Trim(),
+ ["redirect_uri"] = redirectUri?.Trim()
+ };
+ var content = new StringContent(body.ToString(), Encoding.UTF8, "application/json");
+ var res = await _http.PostAsync(url, content);
+ var json = await res.Content.ReadAsStringAsync();
+ if (!res.IsSuccessStatusCode)
+ {
+ var err = TryParseError(json);
+ throw new ApiException(err ?? $"HTTP {(int)res.StatusCode}", (int)res.StatusCode);
+ }
+ var obj = JObject.Parse(json);
+ var data = obj["data"];
+ if (data == null)
+ throw new ApiException("Invalid response: missing data", 200);
+ return new TokenExchangeResult
+ {
+ DiscordId = data["discordId"]?.ToString(),
+ AccessToken = data["access_token"]?.ToString(),
+ ExpiresIn = data["expires_in"]?.Value() ?? 0
+ };
+ }
+
+ ///
+ /// Exchange a one-time manual token for Discord ID and access token (debug only).
///
public async Task TokenExchangeAsync(string tokenCode)
{
@@ -85,6 +140,11 @@ public async Task HeartbeatAsync(string accessToken, string pluginVersion = "1.0
}
}
+ public class AuthConfigResult
+ {
+ public string DiscordClientId { get; set; } = "";
+ }
+
public class TokenExchangeResult
{
public string? DiscordId { get; set; }
diff --git a/apps/plugin/WinPodiums.Plugin/UI/WinPodiumsSettingsControl.xaml b/apps/plugin/WinPodiums.Plugin/UI/WinPodiumsSettingsControl.xaml
new file mode 100644
index 0000000..734dfec
--- /dev/null
+++ b/apps/plugin/WinPodiums.Plugin/UI/WinPodiumsSettingsControl.xaml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/plugin/WinPodiums.Plugin/UI/WinPodiumsSettingsControl.xaml.cs b/apps/plugin/WinPodiums.Plugin/UI/WinPodiumsSettingsControl.xaml.cs
new file mode 100644
index 0000000..6a51263
--- /dev/null
+++ b/apps/plugin/WinPodiums.Plugin/UI/WinPodiumsSettingsControl.xaml.cs
@@ -0,0 +1,87 @@
+using System;
+using System.Windows;
+using System.Windows.Controls;
+using WinPodiums.Plugin.Core;
+
+namespace WinPodiums.Plugin.UI
+{
+ ///
+ /// Minimal SimHub settings panel: Link to Discord, Send heartbeat, status (TP-SPOC-004).
+ ///
+ public partial class WinPodiumsSettingsControl : UserControl
+ {
+ private readonly PluginMain _plugin;
+
+ public WinPodiumsSettingsControl(PluginMain plugin)
+ {
+ InitializeComponent();
+ _plugin = plugin ?? throw new ArgumentNullException(nameof(plugin));
+ Loaded += (_, __) => RefreshStatus();
+ }
+
+ private void RefreshStatus()
+ {
+ var linked = _plugin.IsAuthenticated;
+ AuthStatusText.Text = linked ? $"Linked" + (string.IsNullOrEmpty(_plugin.DiscordId) ? "" : $" ({_plugin.DiscordId})") : "Not linked";
+ LinkButton.Visibility = linked ? Visibility.Collapsed : Visibility.Visible;
+ UnlinkButton.Visibility = linked ? Visibility.Visible : Visibility.Collapsed;
+ UnlinkButton.IsEnabled = linked;
+ HeartbeatButton.IsEnabled = linked;
+ }
+
+ private async void LinkButton_Click(object sender, RoutedEventArgs e)
+ {
+ LinkButton.IsEnabled = false;
+ AuthStatusText.Text = "Linking…";
+ try
+ {
+ var ok = await _plugin.AuthenticateWithBrowserAsync().ConfigureAwait(true);
+ if (ok)
+ {
+ AuthStatusText.Text = "Linked" + (string.IsNullOrEmpty(_plugin.DiscordId) ? "" : $" ({_plugin.DiscordId})");
+ LinkButton.Visibility = Visibility.Collapsed;
+ UnlinkButton.Visibility = Visibility.Visible;
+ UnlinkButton.IsEnabled = true;
+ HeartbeatButton.IsEnabled = true;
+ }
+ else
+ {
+ AuthStatusText.Text = "Link failed";
+ }
+ }
+ catch
+ {
+ AuthStatusText.Text = "Link failed";
+ }
+ finally
+ {
+ LinkButton.IsEnabled = true;
+ }
+ }
+
+ private void UnlinkButton_Click(object sender, RoutedEventArgs e)
+ {
+ _plugin.Logout();
+ RefreshStatus();
+ }
+
+ private async void HeartbeatButton_Click(object sender, RoutedEventArgs e)
+ {
+ HeartbeatButton.IsEnabled = false;
+ HeartbeatStatusText.Text = "Sending…";
+ try
+ {
+ var ok = await _plugin.SendHeartbeatAsync("1.0.0").ConfigureAwait(true);
+ HeartbeatStatusText.Text = ok ? "Heartbeat OK" : "Heartbeat failed";
+ }
+ catch
+ {
+ HeartbeatStatusText.Text = "Heartbeat failed";
+ }
+ finally
+ {
+ HeartbeatButton.IsEnabled = true;
+ }
+ }
+ }
+}
diff --git a/apps/plugin/WinPodiums.Plugin/WinPodiums.Plugin.csproj b/apps/plugin/WinPodiums.Plugin/WinPodiums.Plugin.csproj
index 8c1f6de..79653b0 100644
--- a/apps/plugin/WinPodiums.Plugin/WinPodiums.Plugin.csproj
+++ b/apps/plugin/WinPodiums.Plugin/WinPodiums.Plugin.csproj
@@ -6,6 +6,7 @@
Library
latest
enable
+ true
diff --git a/docs/architecture/next-steps.md b/docs/architecture/next-steps.md
index c1a5a19..d37ab5c 100644
--- a/docs/architecture/next-steps.md
+++ b/docs/architecture/next-steps.md
@@ -17,7 +17,7 @@
| **Create D1 tables** | `apps/api` | Run the initial schema (empty tables): `npx wrangler d1 migrations apply winpodiums-dev-db --local` (or `--remote` when deploying). SQL in `apps/api/migrations/0001_initial_schema.sql`. |
| **Configure Discord + secrets** | `apps/api` | Copy `.dev.vars.example` to `.dev.vars`; set `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `SESSION_SECRET`. In Discord Developer Portal, add redirect URI `http://localhost:8787/auth/callback` (and production URL when you deploy). |
| **Test Worker locally** | Repo root + `apps/api` | Run API: `docker compose up`. Run tests against Docker: `docker compose up -d && cd apps/api && npm test`. Hit `/`, `/auth/discord`, `/auth/token`, `/api/health`, `/api/profile/me` (after login). |
-| **Test plugin** | `apps/plugin` | Build plugin; deploy DLL to `C:\Program Files (x86)\SimHub\Plugins`. Primary auth: browser (PKCE). For debug only (feature-flagged): generate token at `/auth/token`, then in plugin call `AuthenticateWithManualTokenAsync(token)` and `SendHeartbeatAsync()`. |
+| **Test plugin** | `apps/plugin` | Build plugin; deploy DLL to `C:\Program Files (x86)\SimHub\Plugins`. Primary auth: browser (PKCE). For debug only (feature-flagged): generate token at `/auth/token`, then in plugin call `AuthenticateWithManualTokenAsync(token)` and `SendHeartbeatAsync()`. Manual E2E steps: see [Development guide — Manual E2E (SimHub Plugin POC)](../guides/development.md#manual-e2e--simhub-plugin-poc). |
| **Deploy** | When ready | Create D1/KV/R2 in Cloudflare if needed; set bindings in `wrangler.toml`; apply D1 schema `--remote`; then `wrangler deploy`. See [Deployment Guide](../guides/deployment.md). |
**Implemented in Phase 1 (Step 4):**
diff --git a/docs/guides/development.md b/docs/guides/development.md
index ddc7669..418490d 100644
--- a/docs/guides/development.md
+++ b/docs/guides/development.md
@@ -114,6 +114,25 @@ The plugin targets .NET Framework 4.8 and SimHub on Windows. Build and run on th
**Implementation order:** TP-SPOC-001 (skeleton, SDK, config) → 002 (auth PKCE, token storage) → 003 (API client, heartbeat) → 004 (minimal SimHub UI) → 005 (testing, POC completion). Manual E2E and minimum automated tests per TP-005 define “POC complete.”
+### Manual E2E — SimHub Plugin POC
+
+To confirm POC completion, run the full flow from the SimHub UI only (no code changes). Documented per [TP-SPOC-005](../tech-plans/simhub-plugin-poc/005-poc-testing-completion.md).
+
+**Prerequisites:**
+
+- For browser PKCE, the **Discord app** must have redirect URI `http://127.0.0.1:54321/callback` (canonical port). Add it in the Discord Developer Portal under your application → OAuth2 → Redirects.
+- For heartbeat to succeed, the API must be running (e.g. `docker compose up`). For local testing, use `http://localhost:8787`; the plugin uses the default API base URL unless you set it (e.g. via `SetApiBaseUrl` programmatically; UI for base URL is optional for POC).
+
+**Steps:**
+
+1. **Build plugin DLL** — From repo root: `dotnet build apps/plugin/WinPodiums.Plugin/WinPodiums.Plugin.csproj --configuration Release`. Output: `apps/plugin/WinPodiums.Plugin/bin/Release/net48/WinPodiums.Plugin.dll`.
+2. **Install in SimHub** — Stop SimHub if running. Copy `WinPodiums.Plugin.dll` (and `Newtonsoft.Json.dll` from the same output folder if SimHub reports a missing assembly) to `C:\Program Files (x86)\SimHub\Plugins`. Start SimHub.
+3. **Open plugin settings** — In SimHub, open the plugin list/settings and select WinPodiums. Open the plugin settings panel (WPF control with "Link to Discord", "Send heartbeat", status).
+4. **Confirm initial state** — You should see "Link to Discord" and status "Not linked".
+5. **Link to Discord** — Click "Link to Discord". A browser opens to Discord OAuth; sign in and authorize. After redirect, the plugin shows "Linked" (and optionally your Discord ID).
+6. **Send heartbeat** — Click "Send heartbeat". Status should show "Heartbeat OK" (or "Heartbeat failed" if the API is down or unreachable).
+7. **Optional (local API)** — To test against a local API: ensure the API is running (`docker compose up`), set the plugin API base URL to `http://localhost:8787` if exposed (e.g. in a future UI or via programmatic `SetApiBaseUrl`), then repeat steps 5–6.
+
## Wrangler bindings (D1, R2, KV)
- **D1 and R2**: `apps/api/wrangler.toml` defines bindings for local dev (`winpodiums-dev-db`, `winpodiums-dev-storage`). Docker runs Wrangler with the same config.
diff --git a/package-lock.json b/package-lock.json
index 54da906..a4c90a8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,5 +1,5 @@
{
- "name": "snu",
+ "name": "srq",
"lockfileVersion": 3,
"requires": true,
"packages": {}