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:
Mike Kell 2026-10-17 15:28:22 -04:00
parent ddcdefa596
commit 298cadef32
23 changed files with 372 additions and 30 deletions

View File

@ -111,4 +111,27 @@
- [x] `dart run build_runner build --delete-conflicting-outputs` (44 outputs written) - [x] `dart run build_runner build --delete-conflicting-outputs` (44 outputs written)
- [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.

View File

@ -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.
// //

View File

@ -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 -->

View File

@ -1,4 +1,4 @@
package com.glpcompanion.glpcompanion package com.companionglp.glpcompanion
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity

View File

@ -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;

View File

@ -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>

View File

@ -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(

View File

@ -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.
/// ///

View File

@ -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.';
} }

View File

@ -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');

View File

@ -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";

View File

@ -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.

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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"

View File

@ -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 =

View File

@ -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);

View File

@ -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;
}

View File

@ -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_