feat(platform,auth,app): align identity to companionglp.com and harden OAuth deep-link handoff
Identity: rename the application identity to com.companionglp.glpcompanion on every platform (Android applicationId/namespace + MainActivity package, iOS/macOS bundle IDs incl. RunnerTests, Windows CompanyName/copyright, web manifest id, Supabase OAuth redirect scheme). Deep links: register the custom scheme com.companionglp.glpcompanion:// in iOS/macOS Info.plist (CFBundleURLTypes), the Android VIEW/BROWSABLE intent-filter, and Windows per-user HKCU protocol registration. Server-side steps (Supabase uri_allow-list, Entra optional email claim) are recorded in BUILD_STATE.md. Windows runner: single-instance mutex (Local\GLPCompanionSingleInstance) so login callbacks are delivered deterministically; SendAppLinkToInstance forwarding with foreground/taskbar-flash fallback so protocol activation can never look like a hang; protocol diagnostics appended to TEMP glpcompanion-protocol.log. macOS: add the missing com.apple.security.network.client entitlement so the sandboxed runner can reach Supabase at all. Auth UX: surface deferred OAuth/magic-link handshake failures as snackbars on the login screen; make isAuthenticatedProvider reactive (watches authStateProvider) with a microtask-deferred router refresh so live sign-in and sign-out redirect correctly; add a debug package:logging listener so supabase_flutter diagnostics are visible under flutter run. Field-verified on Windows: Google, Microsoft (Entra ID), and email magic-link sign-in round trips plus sign-out redirect to /login.
This commit is contained in:
parent
ddcdefa596
commit
298cadef32
|
|
@ -112,3 +112,26 @@
|
||||||
- [x] `flutter analyze --fatal-infos` (Zero issues — `No issues found!`)
|
- [x] `flutter analyze --fatal-infos` (Zero issues — `No issues found!`)
|
||||||
- [x] `flutter test` (123/123 passing)
|
- [x] `flutter test` (123/123 passing)
|
||||||
- [x] `flutter build web --no-tree-shake-icons` (conditional import keeps `package:health`/`dart:io` out of the web bundle)
|
- [x] `flutter build web --no-tree-shake-icons` (conditional import keeps `package:health`/`dart:io` out of the web bundle)
|
||||||
|
|
||||||
|
## Post-Phase-7 Additions
|
||||||
|
- [x] Deep-link (OAuth / magic-link callback) registration across platforms (2026-10-17): the custom scheme `com.companionglp.glpcompanion` (mirrors the bundle identifiers behind `kAuthCallbackRedirectUrl`) is now registered in every OS routing table —
|
||||||
|
- iOS: `Runner/Info.plist` `CFBundleURLTypes` (`CFBundleTypeRole=Editor`, scheme `com.companionglp.glpcompanion`).
|
||||||
|
- macOS: `Runner/Info.plist` `CFBundleURLTypes` (same shape).
|
||||||
|
- Android: `VIEW`/`DEFAULT`/`BROWSABLE` intent-filter with `android:scheme="com.companionglp.glpcompanion"` on `.MainActivity` (existing `singleTop` launch mode routes `onNewIntent` to the running activity consumed by `app_links`).
|
||||||
|
- Windows runner: `RegisterAppLinkProtocol()` (windows/runner/utils.cpp) writes the per-user protocol under `HKCU\Software\Classes\com.companionglp.glpcompanion` (`URL Protocol` + `shell\open\command` → `"<exe>" "%1"`, no elevation, re-registered every launch); `wWinMain` calls `SendAppLinkToInstance()` (exported by the `app_links` Windows DLL) before creating a window so a protocol activation while the app is running forwards the URI to the existing instance over `WM_COPYDATA` instead of spawning a second window. Cold-start links continue to be read from the command line by the plugin.
|
||||||
|
- [x] macOS entitlements fix (pre-existing gap, required for the OAuth round trip): `com.apple.security.network.client` added to `DebugProfile.entitlements` and `Release.entitlements` — the sandboxed macOS runner previously had no outbound-network entitlement, so Supabase (including the auth exchange the deep link feeds) was unreachable.
|
||||||
|
- [x] `lib/features/auth/data/supabase_auth_service.dart` doc comment updated to reflect the actual registration points.
|
||||||
|
- [x] Windows OAuth handoff diagnostics + no-hang guarantees (2026-10-17, after field report: chooser appears then "nothing happens"):
|
||||||
|
- Runner logs every protocol-activation step to `%TEMP%\glpcompanion-protocol.log` (and `OutputDebugString`): launch argv, `SendAppLinkToInstance()` outcome, and — in the original instance — every `WM_COPYDATA` receipt with `dwData`/payload (expected `dwData=1026` = `WM_USER+2`, the app_links tag).
|
||||||
|
- Fallback path hardened: when forwarding fails (e.g. UIPI drops `WM_COPYDATA` to an elevated target, or no instance found) the fresh instance now shows + foregrounds its window (taskbar flash fallback) and completes the code exchange itself via the plugin's pending-link path — protocol activation can no longer look like a hang.
|
||||||
|
- Dart: `package:logging` root listener (kDebugMode) added in `lib/main.dart` so supabase_flutter's auth/deeplink diagnostics (`handle deeplink uri`, `Error while getSessionFromUrl`) are visible under `flutter run`; `logging: ^1.3.0` promoted to a direct dependency in `pubspec.yaml`.
|
||||||
|
- [x] Deferred auth-error surfacing (2026-10-17, after Microsoft-provider field failure looked like a "hang"): OAuth/magic-link handshake failures arrive *late* — the browser returns an `?error=...` callback, GoTrue's `notifyException` emits them as stream errors on `onAuthStateChange`, and nothing rendered them. `LoginScreen` now also listens to `authStateProvider` errors (router guard is unaffected — it reads `isAuthenticatedProvider`/live session directly, only bumping its refresh counter) and `_messageFor` translates the known "Error getting user email from external provider" failure into actionable guidance. Field-verified via the protocol log: the Windows WM_COPYDATA handoff (`dwData=1026` forwarded to the running instance) is confirmed working end-to-end.
|
||||||
|
- [x] Microsoft (Entra ID) provider handshake fixed (2026-10-17): the `server_error` / "Error getting user email from external provider" bounced back to the app was resolved by adding the optional `email` ID claim to the Entra app registration (`b8852225-23a1-46ef-a28e-365d8ef1cb3f`) in the Azure portal (Token configuration → Optional claims). Field-verified: the next Microsoft round trip returned a clean `?code=8432da29-...` callback (protocol log, 14:40:59) delivered to the running instance through the app_links handoff.
|
||||||
|
- [x] Single-instance enforcement (2026-10-17): Supabase Auth server logs (`POST /token` grant_type=pkce → 200 at 18:40:59Z, `user_signedup` for `mtkell@kellengineering.com`) proved the Microsoft exchange completed — in the *wrong window*. Two instances were running (protocol log: launches at 14:16 AND 14:37); the PKCE verifier lives in shared secure storage under one fixed key, so the callback was forwarded (Z-order) to the non-initiating instance, which silently completed the exchange server-side while the user's window stayed on the login screen. Fix: `AcquireSingleInstanceMutex()` (`Local\GLPCompanionSingleInstance`) in the Windows runner — duplicate normal launches activate the existing window and exit; protocol activations still forward to the sole window. Callback delivery is now deterministic.
|
||||||
|
- [x] Reactive auth-guard state (2026-10-17): `isAuthenticatedProvider` read the non-reactive `currentSession` getter inside a plain `Provider`, caching its first-read value forever (nobody invalidated it). Booting already-signed-in cached `true`, so sign-out cleared the session (Identity card → "Not signed in", button disabled) but the guard kept passing — no redirect to `/login`; live sign-in navigation was equally broken but masked because every successful entry had been a boot-time session. Fixed by `ref.watch(authStateProvider)` inside the provider so the boolean is re-derived on every GoTrue transition; test overrides (`overrideWithValue`) bypass the body and are unaffected. Hardened further: the router's `refreshListenable` bump is deferred via `Future.microtask` so the redirect guard can never re-read `isAuthenticatedProvider` inside Riverpod's provider-notification cascade (ordering edge where a stale cached value could be observed); field log confirmed the `signedOut` event and local session removal fire correctly, isolating the issue to navigation only.
|
||||||
|
- [x] End-to-end auth field verification (2026-10-17, user-confirmed): all three passwordless modes exercised live on Windows — Google OAuth, Microsoft (Entra ID) OAuth, and email magic link — each completing the custom-scheme round trip (`com.companionglp.glpcompanion://login-callback/`) through the single-instance `WM_COPYDATA` handoff into a persisted session; sign-out now redirects to `/login` and re-authentication re-enters the app without a relaunch.
|
||||||
|
|
||||||
|
## Deep-Link Verification Gates (2026-10-17)
|
||||||
|
- [x] Windows runner handoff — FIELD-PROVEN via `%TEMP%\glpcompanion-protocol.log`: protocol registration → OS chooser → second process launches with the link in argv → `WM_COPYDATA` (dwData=1026, the app_links tag) delivered to the running instance → clean forwarder exit. Observed on both the Google and Microsoft success round trips, plus the earlier `?error=` path (now surfaced on-screen by the login screen). No runner build gate remains — the running binary is the gate.
|
||||||
|
- [ ] `flutter build apk --debug` / `flutter build ios --no-codesign` / `flutter build macos` — manifests/plists parse and build cleanly (needs a machine with the mobile toolchains; also exercises the iOS/macOS `CFBundleURLTypes` and Android intent-filter additions).
|
||||||
|
- [x] Supabase Auth redirect allow-list configured (2026-10-17, via Supabase Management API on project `pyrgjdvqbjzakuwvbpxw` "glp-companion"): `uri_allow_list` now = `io.supabase.glpcompanion://login-callback,http://localhost:3000/**,com.companionglp.glpcompanion://login-callback,com.companionglp.glpcompanion://**` (previous entries preserved; stale `io.supabase.*` scheme kept for rollback). Post-write GET verified no collateral changes. `site_url` intentionally left at `http://localhost:8080` — update to `https://companionglp.com` once the web deployment is live.
|
||||||
|
|
@ -5,7 +5,7 @@ plugins {
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.glpcompanion.glpcompanion"
|
namespace = "com.companionglp.glpcompanion"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ android {
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
applicationId = "com.glpcompanion.glpcompanion"
|
applicationId = "com.companionglp.glpcompanion"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,15 @@
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- OAuth / magic-link return: custom scheme mirroring the
|
||||||
|
applicationId so the OS routes Supabase auth redirects back
|
||||||
|
into the running activity (app_links / supabase_flutter). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="com.companionglp.glpcompanion"/>
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.glpcompanion.glpcompanion
|
package com.companionglp.glpcompanion
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
|
@ -383,7 +383,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.glpcompanion.glpcompanion;
|
PRODUCT_BUNDLE_IDENTIFIER = com.companionglp.glpcompanion;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|
@ -399,7 +399,7 @@
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.glpcompanion.glpcompanion.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.companionglp.glpcompanion.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
|
@ -416,7 +416,7 @@
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.glpcompanion.glpcompanion.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.companionglp.glpcompanion.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
|
|
@ -431,7 +431,7 @@
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.glpcompanion.glpcompanion.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.companionglp.glpcompanion.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
|
|
@ -564,7 +564,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.glpcompanion.glpcompanion;
|
PRODUCT_BUNDLE_IDENTIFIER = com.companionglp.glpcompanion;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
|
@ -586,7 +586,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.glpcompanion.glpcompanion;
|
PRODUCT_BUNDLE_IDENTIFIER = com.companionglp.glpcompanion;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,19 @@
|
||||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Editor</string>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.companionglp.glpcompanion</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>com.companionglp.glpcompanion</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>NSHealthShareUsageDescription</key>
|
<key>NSHealthShareUsageDescription</key>
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,21 @@ final _shellNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
/// Whether the current session satisfies the authenticated guard.
|
/// Whether the current session satisfies the authenticated guard.
|
||||||
///
|
///
|
||||||
/// Reads the live GoTrue session when the SDK is initialized and reports
|
/// Reads the live GoTrue session when the SDK is initialized and reports
|
||||||
/// `false` otherwise. Extracted into a provider so integration tests can
|
/// `false` otherwise. Watches [authStateProvider] so the value is re-derived
|
||||||
/// simulate an authenticated (or unauthenticated) session without booting
|
/// on every GoTrue transition — `currentSession` itself is a plain, non
|
||||||
/// Supabase — they simply override this value.
|
/// reactive getter, so without the watch this provider would cache whatever
|
||||||
|
/// was true at first read (e.g. booting already signed-in) and a later
|
||||||
|
/// sign-out could never redirect to `/login`. Extracted into a provider so
|
||||||
|
/// integration tests can simulate an authenticated (or unauthenticated)
|
||||||
|
/// session without booting Supabase — they simply override this value.
|
||||||
final isAuthenticatedProvider = Provider<bool>(
|
final isAuthenticatedProvider = Provider<bool>(
|
||||||
(ref) => isSupabaseAvailable &&
|
(ref) {
|
||||||
Supabase.instance.client.auth.currentSession != null,
|
// Recompute on every auth transition (signedIn / signedOut /
|
||||||
|
// tokenRefreshed / initialSession).
|
||||||
|
ref.watch(authStateProvider);
|
||||||
|
return isSupabaseAvailable &&
|
||||||
|
Supabase.instance.client.auth.currentSession != null;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Application router with a reactive authentication guard and dev bypass hook.
|
/// Application router with a reactive authentication guard and dev bypass hook.
|
||||||
|
|
@ -38,8 +47,18 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||||
final authRefreshNotifier = ValueNotifier<int>(0);
|
final authRefreshNotifier = ValueNotifier<int>(0);
|
||||||
ref.onDispose(authRefreshNotifier.dispose);
|
ref.onDispose(authRefreshNotifier.dispose);
|
||||||
|
|
||||||
// Trigger re-evaluation on GoTrue auth transitions or dev bypass toggle
|
// Trigger re-evaluation on GoTrue auth transitions or dev bypass toggle.
|
||||||
ref.listen(authStateProvider, (_, __) => authRefreshNotifier.value++);
|
//
|
||||||
|
// The auth bump is deferred by one microtask so Riverpod finishes
|
||||||
|
// propagating the transition to dependent providers (notably
|
||||||
|
// isAuthenticatedProvider, which watches authStateProvider) before the
|
||||||
|
// redirect guard re-reads them; ValueNotifier listeners otherwise run
|
||||||
|
// inside the provider-notification cascade and could observe a stale
|
||||||
|
// cached value (symptom: sign-out clears the session but never navigates
|
||||||
|
// to /login).
|
||||||
|
ref.listen(authStateProvider, (_, __) {
|
||||||
|
Future.microtask(() => authRefreshNotifier.value++);
|
||||||
|
});
|
||||||
ref.listen(devAuthBypassProvider, (_, __) => authRefreshNotifier.value++);
|
ref.listen(devAuthBypassProvider, (_, __) => authRefreshNotifier.value++);
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,15 @@ import '../domain/auth_service.dart';
|
||||||
/// or magic-link round trip on native platforms.
|
/// or magic-link round trip on native platforms.
|
||||||
///
|
///
|
||||||
/// The scheme mirrors the platform bundle identifiers
|
/// The scheme mirrors the platform bundle identifiers
|
||||||
/// (`com.glpcompanion.glpcompanion`) and must be registered in the iOS/macOS
|
/// (`com.companionglp.glpcompanion`) and is registered in the iOS/macOS
|
||||||
/// `Info.plist`, the Android manifest intent filters, and the Windows runner
|
/// `Info.plist` (`CFBundleURLTypes`), the Android manifest intent filters,
|
||||||
/// so the operating system can route the redirect callback back to the app.
|
/// and the Windows runner (per-user protocol registration + single-instance
|
||||||
|
/// forwarding) so the operating system can route the redirect callback back
|
||||||
|
/// to the app.
|
||||||
/// [SupabaseAuth] (bundled with `supabase_flutter`) observes the deep link and
|
/// [SupabaseAuth] (bundled with `supabase_flutter`) observes the deep link and
|
||||||
/// exchanges the returned auth code for a session automatically.
|
/// exchanges the returned auth code for a session automatically.
|
||||||
const kAuthCallbackRedirectUrl =
|
const kAuthCallbackRedirectUrl =
|
||||||
'com.glpcompanion.glpcompanion://login-callback/';
|
'com.companionglp.glpcompanion://login-callback/';
|
||||||
|
|
||||||
/// Resolves the redirect URL embedded in OAuth and magic-link flows.
|
/// Resolves the redirect URL embedded in OAuth and magic-link flows.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||||
_showSnack(_messageFor(error), isError: true);
|
_showSnack(_messageFor(error), isError: true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Deferred failures from a completed OAuth / magic-link round trip: when
|
||||||
|
// the browser returns an ?error=... callback (e.g. a provider handshake
|
||||||
|
// failure), Supabase Auth surfaces it as a stream error on
|
||||||
|
// onAuthStateChange long after the launch button returned. Without this
|
||||||
|
// listener the failure is silent and the screen appears to hang.
|
||||||
|
ref.listen(authStateProvider, (previous, next) {
|
||||||
|
final error = next.error;
|
||||||
|
if (error == null) return;
|
||||||
|
_showSnack(_messageFor(error), isError: true);
|
||||||
|
});
|
||||||
|
|
||||||
final isBusy = ref.watch(authControllerProvider).isLoading;
|
final isBusy = ref.watch(authControllerProvider).isLoading;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -247,7 +258,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
String _messageFor(Object error) {
|
String _messageFor(Object error) {
|
||||||
if (error is AuthException) return error.message;
|
if (error is AuthException) {
|
||||||
|
// Server-side handshake failures arrive late (via the deep-link
|
||||||
|
// callback) with raw upstream text; translate the known ones into
|
||||||
|
// actionable guidance.
|
||||||
|
if (error.message.contains('Error getting user email')) {
|
||||||
|
return 'Sign-in failed: the provider did not return an email '
|
||||||
|
'address for this account. This usually means the provider is '
|
||||||
|
'missing its email claim (e.g. the optional "email" claim in a '
|
||||||
|
'Microsoft Entra app registration). Try another sign-in method.';
|
||||||
|
}
|
||||||
|
if (error.message.isNotEmpty) {
|
||||||
|
return 'Sign-in failed: ${error.message}';
|
||||||
|
}
|
||||||
|
}
|
||||||
return 'Authentication failed. Please try again.';
|
return 'Authentication failed. Please try again.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,27 @@ import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
import 'app/router/app_router.dart';
|
import 'app/router/app_router.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Surface package:logging records (notably supabase_flutter's auth and
|
||||||
|
// deep-link diagnostics, e.g. "handle deeplink uri" or exchange errors
|
||||||
|
// that are otherwise silently swallowed) to the debug console so OAuth
|
||||||
|
// handoff issues are observable under `flutter run`.
|
||||||
|
if (kDebugMode) {
|
||||||
|
Logger.root.level = Level.ALL;
|
||||||
|
Logger.root.onRecord.listen((record) {
|
||||||
|
final suffix =
|
||||||
|
record.error != null ? ' | error: ${record.error}' : '';
|
||||||
|
debugPrint('[${record.loggerName}] ${record.level.name}: '
|
||||||
|
'${record.message}$suffix');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Load environment variables from .env
|
// Load environment variables from .env
|
||||||
await dotenv.load(fileName: '.env');
|
await dotenv.load(fileName: '.env');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -395,7 +395,7 @@
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.glpcompanion.glpcompanion.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.companionglp.glpcompanion.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/glpcompanion.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/glpcompanion";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/glpcompanion.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/glpcompanion";
|
||||||
|
|
@ -409,7 +409,7 @@
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.glpcompanion.glpcompanion.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.companionglp.glpcompanion.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/glpcompanion.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/glpcompanion";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/glpcompanion.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/glpcompanion";
|
||||||
|
|
@ -423,7 +423,7 @@
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.glpcompanion.glpcompanion.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.companionglp.glpcompanion.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/glpcompanion.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/glpcompanion";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/glpcompanion.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/glpcompanion";
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
PRODUCT_NAME = glpcompanion
|
PRODUCT_NAME = glpcompanion
|
||||||
|
|
||||||
// The application's bundle identifier
|
// The application's bundle identifier
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.glpcompanion.glpcompanion
|
PRODUCT_BUNDLE_IDENTIFIER = com.companionglp.glpcompanion
|
||||||
|
|
||||||
// The copyright displayed in application information
|
// The copyright displayed in application information
|
||||||
PRODUCT_COPYRIGHT = Copyright © 2026 com.glpcompanion. All rights reserved.
|
PRODUCT_COPYRIGHT = Copyright © 2026 com.companionglp. All rights reserved.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.cs.allow-jit</key>
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.network.server</key>
|
<key>com.apple.security.network.server</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,19 @@
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Editor</string>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.companionglp.glpcompanion</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>com.companionglp.glpcompanion</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
|
|
||||||
|
|
@ -4,5 +4,7 @@
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -606,7 +606,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
version: "3.0.0"
|
||||||
logging:
|
logging:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: logging
|
name: logging
|
||||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ dependencies:
|
||||||
flutter_local_notifications: ^17.1.0
|
flutter_local_notifications: ^17.1.0
|
||||||
|
|
||||||
flutter_dotenv: ^5.1.0
|
flutter_dotenv: ^5.1.0
|
||||||
|
logging: ^1.3.0
|
||||||
supabase_flutter: ^2.5.0
|
supabase_flutter: ^2.5.0
|
||||||
path: ^1.9.1
|
path: ^1.9.1
|
||||||
path_provider: ^2.1.6
|
path_provider: ^2.1.6
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "GLP-1 Companion",
|
"name": "GLP-1 Companion",
|
||||||
"short_name": "GLP Companion",
|
"short_name": "GLP Companion",
|
||||||
|
"id": "/",
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#D4E4C7",
|
"background_color": "#D4E4C7",
|
||||||
|
|
|
||||||
|
|
@ -89,11 +89,11 @@ BEGIN
|
||||||
BEGIN
|
BEGIN
|
||||||
BLOCK "040904e4"
|
BLOCK "040904e4"
|
||||||
BEGIN
|
BEGIN
|
||||||
VALUE "CompanyName", "com.glpcompanion" "\0"
|
VALUE "CompanyName", "com.companionglp" "\0"
|
||||||
VALUE "FileDescription", "glpcompanion" "\0"
|
VALUE "FileDescription", "glpcompanion" "\0"
|
||||||
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
||||||
VALUE "InternalName", "glpcompanion" "\0"
|
VALUE "InternalName", "glpcompanion" "\0"
|
||||||
VALUE "LegalCopyright", "Copyright (C) 2026 com.glpcompanion. All rights reserved." "\0"
|
VALUE "LegalCopyright", "Copyright (C) 2026 com.companionglp. All rights reserved." "\0"
|
||||||
VALUE "OriginalFilename", "glpcompanion.exe" "\0"
|
VALUE "OriginalFilename", "glpcompanion.exe" "\0"
|
||||||
VALUE "ProductName", "glpcompanion" "\0"
|
VALUE "ProductName", "glpcompanion" "\0"
|
||||||
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
#include "flutter_window.h"
|
#include "flutter_window.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
#include "flutter/generated_plugin_registrant.h"
|
#include "flutter/generated_plugin_registrant.h"
|
||||||
|
#include "utils.h"
|
||||||
|
|
||||||
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
|
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
|
||||||
: project_(project) {}
|
: project_(project) {}
|
||||||
|
|
@ -51,6 +54,24 @@ LRESULT
|
||||||
FlutterWindow::MessageHandler(HWND hwnd, UINT const message,
|
FlutterWindow::MessageHandler(HWND hwnd, UINT const message,
|
||||||
WPARAM const wparam,
|
WPARAM const wparam,
|
||||||
LPARAM const lparam) noexcept {
|
LPARAM const lparam) noexcept {
|
||||||
|
// OAuth handoff diagnostics: the app_links plugin consumes WM_COPYDATA
|
||||||
|
// tagged with its own dwData (WM_USER + 2 == 1026). Log every WM_COPYDATA
|
||||||
|
// so the protocol log shows whether a link reached this instance.
|
||||||
|
if (message == WM_COPYDATA) {
|
||||||
|
const auto* cds = reinterpret_cast<const COPYDATASTRUCT*>(lparam);
|
||||||
|
std::string detail = "WM_COPYDATA received";
|
||||||
|
if (cds != nullptr) {
|
||||||
|
detail += "; dwData=" + std::to_string(cds->dwData);
|
||||||
|
detail += " bytes=" + std::to_string(cds->cbData);
|
||||||
|
if (cds->lpData != nullptr && cds->cbData > 0) {
|
||||||
|
const char* payload = static_cast<const char*>(cds->lpData);
|
||||||
|
detail += " payload=";
|
||||||
|
detail.append(payload, strnlen(payload, cds->cbData));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LogAppLink(detail);
|
||||||
|
}
|
||||||
|
|
||||||
// Give Flutter, including plugins, an opportunity to handle window messages.
|
// Give Flutter, including plugins, an opportunity to handle window messages.
|
||||||
if (flutter_controller_) {
|
if (flutter_controller_) {
|
||||||
std::optional<LRESULT> result =
|
std::optional<LRESULT> result =
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,61 @@
|
||||||
#include <flutter/flutter_view_controller.h>
|
#include <flutter/flutter_view_controller.h>
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
|
|
||||||
|
#include "app_links/app_links_plugin_c_api.h"
|
||||||
#include "flutter_window.h"
|
#include "flutter_window.h"
|
||||||
#include "utils.h"
|
#include "utils.h"
|
||||||
|
|
||||||
int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||||
_In_ wchar_t *command_line, _In_ int show_command) {
|
_In_ wchar_t *command_line, _In_ int show_command) {
|
||||||
|
// Diagnostic breadcrumb: protocol activations carry the link as the only
|
||||||
|
// command-line argument; regular launches have none.
|
||||||
|
int argc = 0;
|
||||||
|
wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc);
|
||||||
|
if (argv != nullptr) {
|
||||||
|
std::string cmdline;
|
||||||
|
for (int i = 0; i < argc; ++i) {
|
||||||
|
cmdline += Utf8FromUtf16(argv[i]);
|
||||||
|
cmdline += ' ';
|
||||||
|
}
|
||||||
|
::LocalFree(argv);
|
||||||
|
LogAppLink("launched with: " + cmdline);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool protocol_launch = HasAppLinkArgument();
|
||||||
|
const bool is_primary = AcquireSingleInstanceMutex();
|
||||||
|
|
||||||
|
// (Re)register the per-user custom scheme so OAuth / magic-link redirects
|
||||||
|
// launched by the browser can activate this app.
|
||||||
|
RegisterAppLinkProtocol();
|
||||||
|
|
||||||
|
// Exactly one instance may own a window: the PKCE code verifier lives in
|
||||||
|
// shared secure storage, so a login callback delivered to a window other
|
||||||
|
// than the one that initiated the sign-in would silently complete the
|
||||||
|
// exchange in the WRONG window while the user keeps staring at a login
|
||||||
|
// screen. Duplicate normal launches surface the existing instance and
|
||||||
|
// exit instead of starting a competing window.
|
||||||
|
if (!protocol_launch && !is_primary) {
|
||||||
|
::SendAppLinkToInstance(); // best-effort: surface the existing window
|
||||||
|
LogAppLink("another instance owns the app; exiting");
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protocol activation while an instance is already running hands the link
|
||||||
|
// to that instance (over WM_COPYDATA, consumed by the app_links plugin)
|
||||||
|
// and exits, instead of spawning a second window.
|
||||||
|
if (::SendAppLinkToInstance()) {
|
||||||
|
LogAppLink("link forwarded to the running instance; exiting");
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (protocol_launch) {
|
||||||
|
LogAppLink(is_primary
|
||||||
|
? "cold start with link; handling it directly in this "
|
||||||
|
"process"
|
||||||
|
: "forwarding failed; handling the link in this secondary "
|
||||||
|
"process");
|
||||||
|
}
|
||||||
|
|
||||||
// Attach to console when present (e.g., 'flutter run') or create a
|
// Attach to console when present (e.g., 'flutter run') or create a
|
||||||
// new console when running with a debugger.
|
// new console when running with a debugger.
|
||||||
if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) {
|
if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) {
|
||||||
|
|
@ -32,6 +82,20 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||||
}
|
}
|
||||||
window.SetQuitOnClose(true);
|
window.SetQuitOnClose(true);
|
||||||
|
|
||||||
|
if (protocol_launch) {
|
||||||
|
// Windows protocol activation must not look like a hang: surface the
|
||||||
|
// window that will complete the exchange (Windows blocks background
|
||||||
|
// processes from stealing focus, so fall back to a taskbar flash).
|
||||||
|
HWND hwnd = window.GetHandle();
|
||||||
|
if (hwnd != nullptr) {
|
||||||
|
::ShowWindow(hwnd, SW_SHOW);
|
||||||
|
if (!::SetForegroundWindow(hwnd)) {
|
||||||
|
FLASHWINFO flash = {sizeof(FLASHWINFO), hwnd, FLASHW_ALL, 5, 0};
|
||||||
|
::FlashWindowEx(&flash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
::MSG msg;
|
::MSG msg;
|
||||||
while (::GetMessage(&msg, nullptr, 0, 0)) {
|
while (::GetMessage(&msg, nullptr, 0, 0)) {
|
||||||
::TranslateMessage(&msg);
|
::TranslateMessage(&msg);
|
||||||
|
|
|
||||||
|
|
@ -67,3 +67,117 @@ std::string Utf8FromUtf16(const wchar_t* utf16_string) {
|
||||||
}
|
}
|
||||||
return utf8_string;
|
return utf8_string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Must match kAuthCallbackRedirectUrl in
|
||||||
|
// lib/features/auth/data/supabase_auth_service.dart.
|
||||||
|
constexpr wchar_t kAppLinkProtocol[] = L"com.companionglp.glpcompanion";
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void RegisterAppLinkProtocol() {
|
||||||
|
wchar_t exe_path[MAX_PATH];
|
||||||
|
const DWORD length = ::GetModuleFileNameW(nullptr, exe_path, MAX_PATH);
|
||||||
|
if (length == 0 || length >= MAX_PATH) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::wstring exe(exe_path, length);
|
||||||
|
// "%1" receives the activated link, e.g.
|
||||||
|
// com.companionglp.glpcompanion://login-callback/?code=...
|
||||||
|
const std::wstring command = L"\"" + exe + L"\" \"%1\"";
|
||||||
|
|
||||||
|
const std::wstring protocol_key =
|
||||||
|
std::wstring(L"Software\\Classes\\") + kAppLinkProtocol;
|
||||||
|
|
||||||
|
HKEY key = nullptr;
|
||||||
|
if (::RegCreateKeyExW(HKEY_CURRENT_USER, protocol_key.c_str(), 0, nullptr,
|
||||||
|
REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, &key,
|
||||||
|
nullptr) == ERROR_SUCCESS) {
|
||||||
|
const std::wstring description = L"URL:GLP-1 Companion Protocol";
|
||||||
|
::RegSetValueExW(
|
||||||
|
key, nullptr, 0, REG_SZ,
|
||||||
|
reinterpret_cast<const BYTE*>(description.c_str()),
|
||||||
|
static_cast<DWORD>((description.size() + 1) * sizeof(wchar_t)));
|
||||||
|
const std::wstring url_protocol = L"";
|
||||||
|
::RegSetValueExW(
|
||||||
|
key, L"URL Protocol", 0, REG_SZ,
|
||||||
|
reinterpret_cast<const BYTE*>(url_protocol.c_str()),
|
||||||
|
static_cast<DWORD>((url_protocol.size() + 1) * sizeof(wchar_t)));
|
||||||
|
::RegCloseKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::wstring command_key = protocol_key + L"\\shell\\open\\command";
|
||||||
|
if (::RegCreateKeyExW(HKEY_CURRENT_USER, command_key.c_str(), 0, nullptr,
|
||||||
|
REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, &key,
|
||||||
|
nullptr) == ERROR_SUCCESS) {
|
||||||
|
::RegSetValueExW(
|
||||||
|
key, nullptr, 0, REG_SZ,
|
||||||
|
reinterpret_cast<const BYTE*>(command.c_str()),
|
||||||
|
static_cast<DWORD>((command.size() + 1) * sizeof(wchar_t)));
|
||||||
|
::RegCloseKey(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void LogAppLink(const std::string& message) {
|
||||||
|
wchar_t temp[MAX_PATH];
|
||||||
|
const DWORD temp_length = ::GetTempPathW(MAX_PATH, temp);
|
||||||
|
if (temp_length == 0 || temp_length >= MAX_PATH) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const std::wstring path =
|
||||||
|
std::wstring(temp, temp_length) + L"glpcompanion-protocol.log";
|
||||||
|
|
||||||
|
SYSTEMTIME stamp_source = {};
|
||||||
|
::GetLocalTime(&stamp_source);
|
||||||
|
char stamp[32] = {};
|
||||||
|
snprintf(stamp, sizeof(stamp), "[%02u:%02u:%02u.%03u] ",
|
||||||
|
stamp_source.wHour, stamp_source.wMinute, stamp_source.wSecond,
|
||||||
|
stamp_source.wMilliseconds);
|
||||||
|
|
||||||
|
FILE* log_file = nullptr;
|
||||||
|
if (_wfopen_s(&log_file, path.c_str(), L"a") == 0 && log_file != nullptr) {
|
||||||
|
fprintf(log_file, "%s%s\n", stamp, message.c_str());
|
||||||
|
fclose(log_file);
|
||||||
|
}
|
||||||
|
::OutputDebugStringA((std::string(stamp) + message + "\n").c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HasAppLinkArgument() {
|
||||||
|
int argc = 0;
|
||||||
|
wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc);
|
||||||
|
if (argv == nullptr) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Mirrors applinks::AppLinksPlugin::GetLink(): exactly one extra argument
|
||||||
|
// carrying a URI scheme counts as a protocol activation.
|
||||||
|
bool has_link = false;
|
||||||
|
if (argc == 2) {
|
||||||
|
const std::wstring arg(argv[1]);
|
||||||
|
has_link = arg.find(L"://") != std::wstring::npos;
|
||||||
|
}
|
||||||
|
::LocalFree(argv);
|
||||||
|
return has_link;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Held for the process lifetime once claimed; the OS releases it on exit.
|
||||||
|
HANDLE g_single_instance_mutex = nullptr;
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool AcquireSingleInstanceMutex() {
|
||||||
|
if (g_single_instance_mutex != nullptr) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
g_single_instance_mutex =
|
||||||
|
::CreateMutexW(nullptr, TRUE, L"Local\\GLPCompanionSingleInstance");
|
||||||
|
if (g_single_instance_mutex == nullptr) {
|
||||||
|
// Fail open: never block launch on a mutex problem.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (::GetLastError() == ERROR_ALREADY_EXISTS) {
|
||||||
|
::CloseHandle(g_single_instance_mutex);
|
||||||
|
g_single_instance_mutex = nullptr;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,23 @@ std::string Utf8FromUtf16(const wchar_t* utf16_string);
|
||||||
// encoded in UTF-8. Returns an empty std::vector<std::string> on failure.
|
// encoded in UTF-8. Returns an empty std::vector<std::string> on failure.
|
||||||
std::vector<std::string> GetCommandLineArguments();
|
std::vector<std::string> GetCommandLineArguments();
|
||||||
|
|
||||||
|
// Registers the per-user URL protocol (HKCU\Software\Classes) for OAuth /
|
||||||
|
// magic-link callbacks so Windows routes com.companionglp.glpcompanion://
|
||||||
|
// links to this executable. Idempotent and requires no elevation.
|
||||||
|
void RegisterAppLinkProtocol();
|
||||||
|
|
||||||
|
// Appends a timestamped line to %TEMP%\glpcompanion-protocol.log and the
|
||||||
|
// debugger output stream. Used to diagnose the OAuth custom-scheme handoff.
|
||||||
|
void LogAppLink(const std::string& message);
|
||||||
|
|
||||||
|
// True when this process was started by Windows protocol activation, i.e.
|
||||||
|
// its command line carries a single URI argument such as
|
||||||
|
// com.companionglp.glpcompanion://login-callback/?code=...
|
||||||
|
bool HasAppLinkArgument();
|
||||||
|
|
||||||
|
// Claims the per-session single-instance mutex for this process. Returns
|
||||||
|
// false when a previous instance already owns it (callers should then
|
||||||
|
// activate that instance and exit instead of starting a competing window).
|
||||||
|
bool AcquireSingleInstanceMutex();
|
||||||
|
|
||||||
#endif // RUNNER_UTILS_H_
|
#endif // RUNNER_UTILS_H_
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue