From 298cadef322e736be968a9e45488ab8661bdfb96 Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sat, 17 Oct 2026 15:28:22 -0400 Subject: [PATCH] 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. --- BUILD_STATE.md | 25 +++- android/app/build.gradle.kts | 4 +- android/app/src/main/AndroidManifest.xml | 9 ++ .../glpcompanion/MainActivity.kt | 2 +- ios/Runner.xcodeproj/project.pbxproj | 12 +- ios/Runner/Info.plist | 13 ++ lib/app/router/app_router.dart | 33 +++-- .../auth/data/supabase_auth_service.dart | 10 +- .../presentation/screens/login_screen.dart | 26 +++- lib/main.dart | 15 +++ macos/Runner.xcodeproj/project.pbxproj | 6 +- macos/Runner/Configs/AppInfo.xcconfig | 4 +- macos/Runner/DebugProfile.entitlements | 2 + macos/Runner/Info.plist | 13 ++ macos/Runner/Release.entitlements | 2 + pubspec.lock | 2 +- pubspec.yaml | 1 + web/manifest.json | 1 + windows/runner/Runner.rc | 4 +- windows/runner/flutter_window.cpp | 21 ++++ windows/runner/main.cpp | 64 ++++++++++ windows/runner/utils.cpp | 114 ++++++++++++++++++ windows/runner/utils.h | 19 +++ 23 files changed, 372 insertions(+), 30 deletions(-) rename android/app/src/main/kotlin/com/{glpcompanion => companionglp}/glpcompanion/MainActivity.kt (70%) diff --git a/BUILD_STATE.md b/BUILD_STATE.md index 4b9c285..67ff6c3 100644 --- a/BUILD_STATE.md +++ b/BUILD_STATE.md @@ -111,4 +111,27 @@ - [x] `dart run build_runner build --delete-conflicting-outputs` (44 outputs written) - [x] `flutter analyze --fatal-infos` (Zero issues — `No issues found!`) - [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) \ No newline at end of file +- [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` → `"" "%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. \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 16aa914..752b5c2 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } android { - namespace = "com.glpcompanion.glpcompanion" + namespace = "com.companionglp.glpcompanion" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -20,7 +20,7 @@ android { defaultConfig { // 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. // For more information, see: https://flutter.dev/to/review-gradle-config. // diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 26fc02a..9c7fef2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -37,6 +37,15 @@ + + + + + + + diff --git a/android/app/src/main/kotlin/com/glpcompanion/glpcompanion/MainActivity.kt b/android/app/src/main/kotlin/com/companionglp/glpcompanion/MainActivity.kt similarity index 70% rename from android/app/src/main/kotlin/com/glpcompanion/glpcompanion/MainActivity.kt rename to android/app/src/main/kotlin/com/companionglp/glpcompanion/MainActivity.kt index b8d4148..0fc4af5 100644 --- a/android/app/src/main/kotlin/com/glpcompanion/glpcompanion/MainActivity.kt +++ b/android/app/src/main/kotlin/com/companionglp/glpcompanion/MainActivity.kt @@ -1,4 +1,4 @@ -package com.glpcompanion.glpcompanion +package com.companionglp.glpcompanion import io.flutter.embedding.android.FlutterActivity diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 1f1918a..89e101a 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -383,7 +383,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.glpcompanion.glpcompanion; + PRODUCT_BUNDLE_IDENTIFIER = com.companionglp.glpcompanion; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -399,7 +399,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.glpcompanion.glpcompanion.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.companionglp.glpcompanion.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -416,7 +416,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.glpcompanion.glpcompanion.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.companionglp.glpcompanion.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -431,7 +431,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.glpcompanion.glpcompanion.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.companionglp.glpcompanion.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -564,7 +564,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.glpcompanion.glpcompanion; + PRODUCT_BUNDLE_IDENTIFIER = com.companionglp.glpcompanion; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -586,7 +586,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.glpcompanion.glpcompanion; + PRODUCT_BUNDLE_IDENTIFIER = com.companionglp.glpcompanion; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 4f50da2..3335023 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -22,6 +22,19 @@ $(FLUTTER_BUILD_NAME) CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.companionglp.glpcompanion + CFBundleURLSchemes + + com.companionglp.glpcompanion + + + CFBundleVersion $(FLUTTER_BUILD_NUMBER) NSHealthShareUsageDescription diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 662e8ed..3155c16 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -25,12 +25,21 @@ final _shellNavigatorKey = GlobalKey(); /// Whether the current session satisfies the authenticated guard. /// /// Reads the live GoTrue session when the SDK is initialized and reports -/// `false` otherwise. Extracted into a provider so integration tests can -/// simulate an authenticated (or unauthenticated) session without booting -/// Supabase — they simply override this value. +/// `false` otherwise. Watches [authStateProvider] so the value is re-derived +/// on every GoTrue transition — `currentSession` itself is a plain, non +/// 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( - (ref) => isSupabaseAvailable && - Supabase.instance.client.auth.currentSession != null, + (ref) { + // 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. @@ -38,8 +47,18 @@ final routerProvider = Provider((ref) { final authRefreshNotifier = ValueNotifier(0); ref.onDispose(authRefreshNotifier.dispose); - // Trigger re-evaluation on GoTrue auth transitions or dev bypass toggle - ref.listen(authStateProvider, (_, __) => authRefreshNotifier.value++); + // Trigger re-evaluation on GoTrue auth transitions or dev bypass toggle. + // + // 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++); return GoRouter( diff --git a/lib/features/auth/data/supabase_auth_service.dart b/lib/features/auth/data/supabase_auth_service.dart index 0efae08..4dc599f 100644 --- a/lib/features/auth/data/supabase_auth_service.dart +++ b/lib/features/auth/data/supabase_auth_service.dart @@ -7,13 +7,15 @@ import '../domain/auth_service.dart'; /// or magic-link round trip on native platforms. /// /// The scheme mirrors the platform bundle identifiers -/// (`com.glpcompanion.glpcompanion`) and must be registered in the iOS/macOS -/// `Info.plist`, the Android manifest intent filters, and the Windows runner -/// so the operating system can route the redirect callback back to the app. +/// (`com.companionglp.glpcompanion`) and is registered in the iOS/macOS +/// `Info.plist` (`CFBundleURLTypes`), the Android manifest intent filters, +/// 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 /// exchanges the returned auth code for a session automatically. const kAuthCallbackRedirectUrl = - 'com.glpcompanion.glpcompanion://login-callback/'; + 'com.companionglp.glpcompanion://login-callback/'; /// Resolves the redirect URL embedded in OAuth and magic-link flows. /// diff --git a/lib/features/auth/presentation/screens/login_screen.dart b/lib/features/auth/presentation/screens/login_screen.dart index cc5fb67..fea7a35 100644 --- a/lib/features/auth/presentation/screens/login_screen.dart +++ b/lib/features/auth/presentation/screens/login_screen.dart @@ -46,6 +46,17 @@ class _LoginScreenState extends ConsumerState { _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; return Scaffold( @@ -247,7 +258,20 @@ class _LoginScreenState extends ConsumerState { } 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.'; } diff --git a/lib/main.dart b/lib/main.dart index 395cb4b..8031b2b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,12 +4,27 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'app/router/app_router.dart'; Future main() async { 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 await dotenv.load(fileName: '.env'); diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 587263c..5f5a53e 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -395,7 +395,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.glpcompanion.glpcompanion.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.companionglp.glpcompanion.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/glpcompanion.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/glpcompanion"; @@ -409,7 +409,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.glpcompanion.glpcompanion.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.companionglp.glpcompanion.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/glpcompanion.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/glpcompanion"; @@ -423,7 +423,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.glpcompanion.glpcompanion.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.companionglp.glpcompanion.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/glpcompanion.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/glpcompanion"; diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index 9e273fe..4fe4be7 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -8,7 +8,7 @@ PRODUCT_NAME = glpcompanion // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.glpcompanion.glpcompanion +PRODUCT_BUNDLE_IDENTIFIER = com.companionglp.glpcompanion // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2026 com.glpcompanion. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2026 com.companionglp. All rights reserved. diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index dddb8a3..3ba6c12 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -6,6 +6,8 @@ com.apple.security.cs.allow-jit + com.apple.security.network.client + com.apple.security.network.server diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index 4789daa..d24058c 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -18,6 +18,19 @@ APPL CFBundleShortVersionString $(FLUTTER_BUILD_NAME) + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.companionglp.glpcompanion + CFBundleURLSchemes + + com.companionglp.glpcompanion + + + CFBundleVersion $(FLUTTER_BUILD_NUMBER) LSMinimumSystemVersion diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 852fa1a..ee95ab7 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -4,5 +4,7 @@ com.apple.security.app-sandbox + com.apple.security.network.client + diff --git a/pubspec.lock b/pubspec.lock index 2ae1e89..f48b15b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -606,7 +606,7 @@ packages: source: hosted version: "3.0.0" logging: - dependency: transitive + dependency: "direct main" description: name: logging sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 diff --git a/pubspec.yaml b/pubspec.yaml index b2a9978..c3c7ec4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: flutter_local_notifications: ^17.1.0 flutter_dotenv: ^5.1.0 + logging: ^1.3.0 supabase_flutter: ^2.5.0 path: ^1.9.1 path_provider: ^2.1.6 diff --git a/web/manifest.json b/web/manifest.json index f2a77ab..8d02e63 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -1,6 +1,7 @@ { "name": "GLP-1 Companion", "short_name": "GLP Companion", + "id": "/", "start_url": ".", "display": "standalone", "background_color": "#D4E4C7", diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 0b329c2..bede63d 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -89,11 +89,11 @@ BEGIN BEGIN BLOCK "040904e4" BEGIN - VALUE "CompanyName", "com.glpcompanion" "\0" + VALUE "CompanyName", "com.companionglp" "\0" VALUE "FileDescription", "glpcompanion" "\0" VALUE "FileVersion", VERSION_AS_STRING "\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 "ProductName", "glpcompanion" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index 955ee30..5f657eb 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -1,8 +1,11 @@ #include "flutter_window.h" +#include #include +#include #include "flutter/generated_plugin_registrant.h" +#include "utils.h" FlutterWindow::FlutterWindow(const flutter::DartProject& project) : project_(project) {} @@ -51,6 +54,24 @@ LRESULT FlutterWindow::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, 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(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(cds->lpData); + detail += " payload="; + detail.append(payload, strnlen(payload, cds->cbData)); + } + } + LogAppLink(detail); + } + // Give Flutter, including plugins, an opportunity to handle window messages. if (flutter_controller_) { std::optional result = diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index 0fbc733..19bc7cb 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -2,11 +2,61 @@ #include #include +#include "app_links/app_links_plugin_c_api.h" #include "flutter_window.h" #include "utils.h" int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, _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 // new console when running with a debugger. if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { @@ -32,6 +82,20 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, } 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; while (::GetMessage(&msg, nullptr, 0, 0)) { ::TranslateMessage(&msg); diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp index 3cb7146..7e7c69b 100644 --- a/windows/runner/utils.cpp +++ b/windows/runner/utils.cpp @@ -67,3 +67,117 @@ std::string Utf8FromUtf16(const wchar_t* utf16_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(description.c_str()), + static_cast((description.size() + 1) * sizeof(wchar_t))); + const std::wstring url_protocol = L""; + ::RegSetValueExW( + key, L"URL Protocol", 0, REG_SZ, + reinterpret_cast(url_protocol.c_str()), + static_cast((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(command.c_str()), + static_cast((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; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h index 3879d54..f1a3b9e 100644 --- a/windows/runner/utils.h +++ b/windows/runner/utils.h @@ -16,4 +16,23 @@ std::string Utf8FromUtf16(const wchar_t* utf16_string); // encoded in UTF-8. Returns an empty std::vector on failure. std::vector 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_