kasy-cli 1.32.0 → 1.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/kasy.js +66 -2
- package/docs/cli-reference.md +7 -7
- package/lib/commands/apple-web.js +222 -0
- package/lib/commands/configure.js +3 -91
- package/lib/commands/doctor.js +20 -0
- package/lib/commands/facebook.js +189 -0
- package/lib/commands/new.js +61 -11
- package/lib/commands/release-version.js +234 -0
- package/lib/commands/update.js +27 -0
- package/lib/scaffold/CHANGELOG.json +27 -0
- package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +24 -0
- package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api_interface.dart +15 -0
- package/lib/scaffold/backends/api/patch/lib/features/authentication/repositories/authentication_repository.dart +27 -0
- package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/entities/subscription_entity.dart +1 -0
- package/lib/scaffold/backends/firebase/setup-from-scratch.js +199 -21
- package/lib/scaffold/backends/patch-base-hashes.json +66 -0
- package/lib/scaffold/backends/supabase/deploy.js +92 -0
- package/lib/scaffold/backends/supabase/edge-functions/stripe-webhook/index.ts +2 -0
- package/lib/scaffold/backends/supabase/migrations/20240101000007_welcome_notification.sql +36 -23
- package/lib/scaffold/backends/supabase/migrations/20240101000014_subscription_trial_end.sql +6 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +92 -3
- package/lib/scaffold/backends/supabase/patch/lib/features/authentication/repositories/authentication_repository.dart +36 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/entities/subscription_entity.dart +1 -0
- package/lib/scaffold/generate.js +53 -4
- package/lib/scaffold/shared/generator-utils.js +18 -6
- package/lib/utils/apple-web.js +147 -0
- package/lib/utils/facebook.js +162 -0
- package/lib/utils/i18n/messages-en.js +85 -0
- package/lib/utils/i18n/messages-es.js +85 -0
- package/lib/utils/i18n/messages-pt.js +85 -0
- package/package.json +5 -2
- package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +73 -0
- package/templates/firebase/AGENTS.md +170 -0
- package/templates/firebase/CLAUDE.md +16 -0
- package/templates/firebase/DESIGN_SYSTEM.md +269 -0
- package/templates/firebase/docs/auth-setup.en.md +4 -2
- package/templates/firebase/docs/auth-setup.es.md +4 -2
- package/templates/firebase/docs/auth-setup.pt.md +4 -2
- package/templates/firebase/firebase.json +56 -1
- package/templates/firebase/functions/src/authentication/triggers.ts +10 -6
- package/templates/firebase/functions/src/core/data/entities/subscription_entity.ts +5 -0
- package/templates/firebase/functions/src/core/data/entities/user_entity.ts +9 -1
- package/templates/firebase/functions/src/core/data/repositories/user_repository.ts +27 -0
- package/templates/firebase/functions/src/subscriptions/models/subscriptions.ts +3 -0
- package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +4 -0
- package/templates/firebase/lib/components/components.dart +1 -0
- package/templates/firebase/lib/components/kasy_alert.dart +0 -1
- package/templates/firebase/lib/components/kasy_app_bar.dart +35 -17
- package/templates/firebase/lib/components/kasy_bottom_sheet.dart +0 -1
- package/templates/firebase/lib/components/kasy_date_picker.dart +0 -5
- package/templates/firebase/lib/components/kasy_dialog.dart +0 -1
- package/templates/firebase/lib/components/kasy_otp_verification_bottom_sheet.dart +1 -1
- package/templates/firebase/lib/components/kasy_screen.dart +114 -0
- package/templates/firebase/lib/components/kasy_sidebar.dart +189 -120
- package/templates/firebase/lib/components/kasy_text_area.dart +0 -1
- package/templates/firebase/lib/components/kasy_text_field.dart +0 -1
- package/templates/firebase/lib/components/kasy_text_field_otp.dart +0 -1
- package/templates/firebase/lib/components/kasy_toast.dart +108 -73
- package/templates/firebase/lib/core/app_update/app_update_repository.dart +54 -0
- package/templates/firebase/lib/core/app_update/app_update_status.dart +14 -0
- package/templates/firebase/lib/core/app_update/update_available_sheet.dart +70 -0
- package/templates/firebase/lib/core/bottom_menu/active_tab_notifier.dart +40 -3
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +82 -34
- package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +6 -6
- package/templates/firebase/lib/core/bottom_menu/web_url.dart +20 -0
- package/templates/firebase/lib/core/chrome/chrome_visibility.dart +22 -0
- package/templates/firebase/lib/core/config/features.dart +5 -0
- package/templates/firebase/lib/core/data/api/remote_config_api.dart +38 -1
- package/templates/firebase/lib/core/guards/guard.dart +16 -2
- package/templates/firebase/lib/core/icons/kasy_icons.dart +3 -0
- package/templates/firebase/lib/core/rating/widgets/rate_banner.dart +2 -5
- package/templates/firebase/lib/core/rating/widgets/review_popup.dart +48 -124
- package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +14 -0
- package/templates/firebase/lib/core/states/components/maybe_show_update_available.dart +32 -0
- package/templates/firebase/lib/core/states/logout_action.dart +5 -1
- package/templates/firebase/lib/core/states/user_state_notifier.dart +85 -14
- package/templates/firebase/lib/core/theme/responsive_text_theme.dart +69 -0
- package/templates/firebase/lib/core/theme/texts.dart +90 -57
- package/templates/firebase/lib/core/theme/type_scale.dart +77 -0
- package/templates/firebase/lib/core/theme/web_background_sync_web.dart +20 -6
- package/templates/firebase/lib/core/utils/image_bytes_loader.dart +12 -0
- package/templates/firebase/lib/core/utils/image_bytes_loader_io.dart +28 -0
- package/templates/firebase/lib/core/utils/image_bytes_loader_web.dart +56 -0
- package/templates/firebase/lib/core/web_screen_width.dart +15 -0
- package/templates/firebase/lib/core/web_screen_width_io.dart +3 -0
- package/templates/firebase/lib/core/web_screen_width_web.dart +10 -0
- package/templates/firebase/lib/core/web_viewport_scale.dart +61 -25
- package/templates/firebase/lib/core/widgets/focus_visibility.dart +89 -0
- package/templates/firebase/lib/core/widgets/update_bottom_sheet.dart +29 -126
- package/templates/firebase/lib/features/ai_chat/ai_chat_page.dart +11 -8
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +21 -0
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +1 -2
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +2 -3
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +1 -2
- package/templates/firebase/lib/features/authentication/api/auth_web_support.dart +12 -0
- package/templates/firebase/lib/features/authentication/api/auth_web_support_web.dart +25 -0
- package/templates/firebase/lib/features/authentication/api/authentication_api.dart +266 -0
- package/templates/firebase/lib/features/authentication/api/authentication_api_interface.dart +22 -0
- package/templates/firebase/lib/features/authentication/navigation/post_login_navigation.dart +32 -0
- package/templates/firebase/lib/features/authentication/providers/phone_auth_notifier.dart +7 -7
- package/templates/firebase/lib/features/authentication/providers/signin_state_provider.dart +34 -10
- package/templates/firebase/lib/features/authentication/providers/signup_state_provider.dart +2 -2
- package/templates/firebase/lib/features/authentication/repositories/authentication_repository.dart +37 -0
- package/templates/firebase/lib/features/authentication/repositories/exceptions/authentication_exceptions.dart +8 -0
- package/templates/firebase/lib/features/authentication/ui/components/otp_verification.dart +2 -2
- package/templates/firebase/lib/features/authentication/ui/components/phone_input.dart +2 -2
- package/templates/firebase/lib/features/authentication/ui/signin_page.dart +80 -15
- package/templates/firebase/lib/features/authentication/ui/signup_page.dart +20 -14
- package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +2 -2
- package/templates/firebase/lib/features/authentication/ui/widgets/recover_password_result.dart +2 -2
- package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -1
- package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +1 -2
- package/templates/firebase/lib/features/home/design_system_page.dart +134 -67
- package/templates/firebase/lib/features/home/home_components_page.dart +8 -1
- package/templates/firebase/lib/features/home/home_components_preview_page.dart +1 -3
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +186 -56
- package/templates/firebase/lib/features/home/home_page.dart +4 -0
- package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +169 -208
- package/templates/firebase/lib/features/notifications/providers/notifications_provider.dart +10 -0
- package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +2 -2
- package/templates/firebase/lib/features/notifications/ui/components/notification_tile.dart +21 -8
- package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +2 -2
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -4
- package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +84 -128
- package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +11 -0
- package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +30 -3
- package/templates/firebase/lib/features/onboarding/ui/components/onboarding_loader.dart +13 -4
- package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_illustration_scaffold.dart +3 -4
- package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +2 -4
- package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +2 -1
- package/templates/firebase/lib/features/settings/settings_page.dart +152 -11
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +58 -21
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +12 -12
- package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +1 -4
- package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +2 -2
- package/templates/firebase/lib/features/settings/ui/components/create_password_sheet.dart +141 -0
- package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +0 -1
- package/templates/firebase/lib/features/settings/ui/widgets/admin_card.dart +42 -38
- package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +17 -2
- package/templates/firebase/lib/features/subscriptions/api/entities/subscription_entity.dart +3 -0
- package/templates/firebase/lib/features/subscriptions/api/stripe_product.dart +12 -11
- package/templates/firebase/lib/features/subscriptions/repositories/subscription_repository.dart +25 -2
- package/templates/firebase/lib/features/subscriptions/ui/component/active_premium_content.dart +319 -143
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_minimal.dart +1 -5
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +1 -5
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_with_switch.dart +2 -5
- package/templates/firebase/lib/features/subscriptions/ui/component/premium_content.dart +1 -4
- package/templates/firebase/lib/features/subscriptions/ui/widgets/feature_line.dart +1 -2
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_banner.dart +2 -7
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_feature.dart +2 -4
- package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +1 -4
- package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +0 -3
- package/templates/firebase/lib/i18n/en.i18n.json +54 -7
- package/templates/firebase/lib/i18n/es.i18n.json +54 -7
- package/templates/firebase/lib/i18n/pt.i18n.json +54 -7
- package/templates/firebase/lib/main.dart +11 -2
- package/templates/firebase/lib/router.dart +94 -13
- package/templates/firebase/pubspec.yaml +1 -1
- package/templates/firebase/test/core/data/api/fake_remote_config_api.dart +14 -0
- package/templates/firebase/test/core/states/user_state_notifier_test.dart +47 -3
- package/templates/firebase/test/core/web_viewport_scale_test.dart +68 -0
- package/templates/firebase/test/features/authentication/data/api/auth_api_fake.dart +15 -0
- package/templates/firebase/tool/design_check.dart +152 -0
- package/templates/firebase/web/index.html +162 -14
- package/templates/firebase/assets/images/review.png +0 -0
- package/templates/firebase/assets/images/update.png +0 -0
- package/templates/firebase/lib/core/guards/user_info_guard.dart +0 -61
- package/templates/firebase/lib/features/notifications/ui/components/notifications_header.dart +0 -32
package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import 'dart:async';
|
|
1
2
|
import 'dart:convert';
|
|
2
3
|
import 'package:crypto/crypto.dart';
|
|
3
4
|
import 'package:firebase_auth/firebase_auth.dart' as fb_auth;
|
|
@@ -11,6 +12,8 @@ import 'package:kasy_kit/core/data/api/base_api_exceptions.dart';
|
|
|
11
12
|
import 'package:kasy_kit/core/data/entities/user_entity.dart';
|
|
12
13
|
import 'package:kasy_kit/environments.dart';
|
|
13
14
|
import 'package:kasy_kit/google_auth_options.dart';
|
|
15
|
+
import 'package:kasy_kit/features/authentication/api/auth_web_support.dart'
|
|
16
|
+
if (dart.library.js_interop) 'package:kasy_kit/features/authentication/api/auth_web_support_web.dart';
|
|
14
17
|
import 'package:kasy_kit/features/authentication/api/authentication_api_interface.dart';
|
|
15
18
|
import 'package:kasy_kit/features/authentication/repositories/exceptions/authentication_exceptions.dart';
|
|
16
19
|
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
|
@@ -34,7 +37,41 @@ class SupabaseAuthenticationApi implements AuthenticationApi {
|
|
|
34
37
|
});
|
|
35
38
|
|
|
36
39
|
@override
|
|
37
|
-
Future<void> init() async {
|
|
40
|
+
Future<void> init() async {
|
|
41
|
+
if (!kIsWeb) return;
|
|
42
|
+
// Complete a pending mobile-web Google redirect sign-in. Firebase handled the
|
|
43
|
+
// full-page redirect (we reuse its web OAuth client to mint the Google ID
|
|
44
|
+
// token — see [signinWithGoogle]), so on return we exchange that token for a
|
|
45
|
+
// Supabase session. No-op when no redirect is pending or a session already
|
|
46
|
+
// exists.
|
|
47
|
+
try {
|
|
48
|
+
final result = await fb_auth.FirebaseAuth.instance.getRedirectResult();
|
|
49
|
+
final idToken = (result.credential as fb_auth.OAuthCredential?)?.idToken;
|
|
50
|
+
if (idToken != null && client.auth.currentUser == null) {
|
|
51
|
+
await client.auth.signInWithIdToken(
|
|
52
|
+
provider: OAuthProvider.google,
|
|
53
|
+
idToken: idToken,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
} catch (e) {
|
|
57
|
+
_logger.w('Google redirect sign-in completion failed: $e');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Mobile-web Google sign-in: start a full-page Firebase redirect instead of a
|
|
62
|
+
/// popup. Mobile browsers handle popups unreliably (the result is often lost
|
|
63
|
+
/// when the browser reclaims the backgrounded opener tab), leaving the app
|
|
64
|
+
/// stuck "signing in". The Supabase session is established at startup by [init]
|
|
65
|
+
/// via getRedirectResult. The page is navigating away as soon as
|
|
66
|
+
/// [signInWithRedirect] resolves, so this future intentionally never completes
|
|
67
|
+
/// — the caller stays in its loading state until the browser leaves.
|
|
68
|
+
/// See [isMobileWebBrowser].
|
|
69
|
+
Future<Credentials> _googleSignInWithRedirectWeb() async {
|
|
70
|
+
await fb_auth.FirebaseAuth.instance.signInWithRedirect(
|
|
71
|
+
fb_auth.GoogleAuthProvider(),
|
|
72
|
+
);
|
|
73
|
+
return Completer<Credentials>().future;
|
|
74
|
+
}
|
|
38
75
|
|
|
39
76
|
@override
|
|
40
77
|
Future<void> recoverPassword(String email) {
|
|
@@ -192,6 +229,11 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
|
|
|
192
229
|
|
|
193
230
|
@override
|
|
194
231
|
Future<Credentials> signinWithFacebook() async {
|
|
232
|
+
// Facebook on web for Supabase is not wired yet (roadmap); the button is hidden
|
|
233
|
+
// on web, so this is a defensive guard.
|
|
234
|
+
if (kIsWeb) {
|
|
235
|
+
throw ApiError(code: 501, message: 'Facebook sign-in on web is not supported on Supabase.');
|
|
236
|
+
}
|
|
195
237
|
final loginResult = await FacebookAuth.instance.login(
|
|
196
238
|
permissions: ['email', 'public_profile'],
|
|
197
239
|
);
|
|
@@ -220,6 +262,7 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
|
|
|
220
262
|
@override
|
|
221
263
|
Future<Credentials> signinWithGoogle() async {
|
|
222
264
|
if (kIsWeb) {
|
|
265
|
+
if (isMobileWebBrowser()) return _googleSignInWithRedirectWeb();
|
|
223
266
|
// google_sign_in v7 can't do imperative auth on web. Get the Google ID token
|
|
224
267
|
// via Firebase's popup (zero manual config — reuses the Firebase web OAuth
|
|
225
268
|
// client + authorized domains, which the kit already sets up) and sign into
|
|
@@ -363,9 +406,21 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
|
|
|
363
406
|
@override
|
|
364
407
|
Future<Credentials> signupFromAnonymousWithGoogle() async {
|
|
365
408
|
if (kIsWeb) {
|
|
366
|
-
|
|
367
|
-
//
|
|
409
|
+
if (isMobileWebBrowser()) return _googleSignInWithRedirectWeb();
|
|
410
|
+
// Web: get the Google ID token via Firebase's popup (see signinWithGoogle).
|
|
368
411
|
final idToken = await _googleIdTokenFromWebPopup();
|
|
412
|
+
// On web the app runs in authRequired mode, so there is usually no anonymous
|
|
413
|
+
// Supabase session to link to (the user state is just a local placeholder).
|
|
414
|
+
// Linking without a session sends the anon key, which has no `sub`, and Supabase
|
|
415
|
+
// rejects it with "missing sub claim". When there's no session, sign in normally
|
|
416
|
+
// — it creates the account from the Google id_token. Mirrors the Firebase backend.
|
|
417
|
+
if (client.auth.currentUser == null) {
|
|
418
|
+
final res = await client.auth.signInWithIdToken(
|
|
419
|
+
provider: OAuthProvider.google,
|
|
420
|
+
idToken: idToken,
|
|
421
|
+
);
|
|
422
|
+
return Credentials(id: res.user!.id, token: res.session?.accessToken ?? '');
|
|
423
|
+
}
|
|
369
424
|
try {
|
|
370
425
|
final response = await client.auth.linkIdentityWithIdToken(
|
|
371
426
|
provider: OAuthProvider.google,
|
|
@@ -439,6 +494,11 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
|
|
|
439
494
|
|
|
440
495
|
@override
|
|
441
496
|
Future<Credentials> signupFromAnonymousWithFacebook() async {
|
|
497
|
+
// Facebook on web for Supabase is not wired yet (roadmap); the button is hidden
|
|
498
|
+
// on web, so this is a defensive guard.
|
|
499
|
+
if (kIsWeb) {
|
|
500
|
+
throw ApiError(code: 501, message: 'Facebook sign-in on web is not supported on Supabase.');
|
|
501
|
+
}
|
|
442
502
|
final loginResult = await FacebookAuth.instance.login(
|
|
443
503
|
permissions: ['email', 'public_profile'],
|
|
444
504
|
);
|
|
@@ -595,6 +655,35 @@ Note: wait a minute after enabling anonymous sign-in before trying again. It tak
|
|
|
595
655
|
Future<String?> getCurrentUserDisplayName() async =>
|
|
596
656
|
client.auth.currentUser?.userMetadata?['full_name'] as String?;
|
|
597
657
|
|
|
658
|
+
@override
|
|
659
|
+
Future<String?> getCurrentUserPhotoUrl() async {
|
|
660
|
+
final meta = client.auth.currentUser?.userMetadata;
|
|
661
|
+
return (meta?['avatar_url'] ?? meta?['picture']) as String?;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
@override
|
|
665
|
+
Future<List<String>> getLinkedProviders() async {
|
|
666
|
+
// Supabase exposes the linked identities in app_metadata['providers'].
|
|
667
|
+
final raw = client.auth.currentUser?.appMetadata['providers'];
|
|
668
|
+
if (raw is! List) return const [];
|
|
669
|
+
return raw.map((p) => p.toString()).toList();
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
@override
|
|
673
|
+
Future<void> setPassword(String password) async {
|
|
674
|
+
// Supabase keeps the same user; sets/updates the password so a social-only
|
|
675
|
+
// account can also sign in with email + password.
|
|
676
|
+
await client.auth.updateUser(UserAttributes(password: password));
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Supabase auto-links identities with the same (confirmed) email on sign-in,
|
|
680
|
+
// so there is no manual-link step: nothing to offer and nothing to do.
|
|
681
|
+
@override
|
|
682
|
+
Future<List<String>> linkableSocialProviders() async => const [];
|
|
683
|
+
|
|
684
|
+
@override
|
|
685
|
+
Future<void> linkSocialProvider(String provider) => Future.value();
|
|
686
|
+
|
|
598
687
|
String _normalizePhoneNumber(String phoneNumber) {
|
|
599
688
|
String normalized = phoneNumber.replaceAll(RegExp(r'\D'), '');
|
|
600
689
|
if (!normalized.startsWith('+')) {
|
|
@@ -68,6 +68,22 @@ abstract class AuthenticationRepository {
|
|
|
68
68
|
/// Returns the display name of the authenticated user.
|
|
69
69
|
Future<String?> getCurrentUserDisplayName();
|
|
70
70
|
|
|
71
|
+
/// Returns the photo URL of the current user (e.g. Google picture), or null.
|
|
72
|
+
Future<String?> getCurrentUserPhotoUrl();
|
|
73
|
+
|
|
74
|
+
/// Returns all sign-in providers linked to the current account.
|
|
75
|
+
Future<List<String>> getLinkedProviders();
|
|
76
|
+
|
|
77
|
+
/// Sets or updates an email/password credential for the current user, so a
|
|
78
|
+
/// social-only account can also sign in with email + password.
|
|
79
|
+
Future<void> setPassword(String password);
|
|
80
|
+
|
|
81
|
+
/// Social providers the current user can still link to their account.
|
|
82
|
+
Future<List<String>> linkableSocialProviders();
|
|
83
|
+
|
|
84
|
+
/// Links a social provider to the current account.
|
|
85
|
+
Future<void> linkSocialProvider(String provider);
|
|
86
|
+
|
|
71
87
|
/// Signin with Google Play Games account on Android
|
|
72
88
|
Future<void> signinWithGooglePlayGames();
|
|
73
89
|
|
|
@@ -268,6 +284,26 @@ class HttpAuthenticationRepository implements AuthenticationRepository {
|
|
|
268
284
|
Future<String?> getCurrentUserDisplayName() =>
|
|
269
285
|
_authenticationApi.getCurrentUserDisplayName();
|
|
270
286
|
|
|
287
|
+
@override
|
|
288
|
+
Future<String?> getCurrentUserPhotoUrl() =>
|
|
289
|
+
_authenticationApi.getCurrentUserPhotoUrl();
|
|
290
|
+
|
|
291
|
+
@override
|
|
292
|
+
Future<List<String>> getLinkedProviders() =>
|
|
293
|
+
_authenticationApi.getLinkedProviders();
|
|
294
|
+
|
|
295
|
+
@override
|
|
296
|
+
Future<void> setPassword(String password) =>
|
|
297
|
+
_authenticationApi.setPassword(password);
|
|
298
|
+
|
|
299
|
+
@override
|
|
300
|
+
Future<List<String>> linkableSocialProviders() =>
|
|
301
|
+
_authenticationApi.linkableSocialProviders();
|
|
302
|
+
|
|
303
|
+
@override
|
|
304
|
+
Future<void> linkSocialProvider(String provider) =>
|
|
305
|
+
_authenticationApi.linkSocialProvider(provider);
|
|
306
|
+
|
|
271
307
|
@override
|
|
272
308
|
Future<void> signinWithGooglePlayGames() async {
|
|
273
309
|
try {
|
|
@@ -37,6 +37,7 @@ sealed class SubscriptionEntity with _$SubscriptionEntity {
|
|
|
37
37
|
@JsonKey(name: 'creation_date') DateTime? creationDate,
|
|
38
38
|
@JsonKey(name: 'last_update_date') DateTime? lastUpdateDate,
|
|
39
39
|
@JsonKey(name: 'period_end_date') DateTime? periodEndDate,
|
|
40
|
+
@JsonKey(name: 'trial_end') DateTime? trialEnd,
|
|
40
41
|
@JsonKey(name: 'status') required SubscriptionStatus status,
|
|
41
42
|
@JsonKey(name: 'store', unknownEnumValue: SubscriptionStore.unknown)
|
|
42
43
|
SubscriptionStore? store,
|
package/lib/scaffold/generate.js
CHANGED
|
@@ -86,11 +86,50 @@ const { FIREBASE_SOURCE_DIR } = require('./shared/backend-config');
|
|
|
86
86
|
* @param {object} [options.moduleAnswers={}]
|
|
87
87
|
* @param {Function} [options.onProgress] - Callback to update the spinner in the CLI.
|
|
88
88
|
* @param {object} [options.deploy] - Used by the Firebase postBuild hook.
|
|
89
|
+
* @param {boolean} [options.offline=false] - Validation mode: never touch the
|
|
90
|
+
* network or the live Firebase project. Skips flutterfire configure, the Google
|
|
91
|
+
* auth/iOS/SW patches it feeds, and the Firestore rules deploy; writes a
|
|
92
|
+
* compilable stub for firebase_options_dev.dart so `flutter analyze` still works.
|
|
89
93
|
* @param {object} [hooks={}]
|
|
90
94
|
* @param {Function|null} [hooks.applyBackendSetup]
|
|
91
95
|
* @param {Function|null} [hooks.postBuild]
|
|
92
96
|
* @returns {Promise<{steps: object[], packageName: string, appName: string, bundleId: string, firebaseProjectId: string}>}
|
|
93
97
|
*/
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Writes a compilable placeholder for lib/firebase_options_dev.dart, the file
|
|
101
|
+
* `flutterfire configure` normally generates from the real Firebase project.
|
|
102
|
+
*
|
|
103
|
+
* Used only by offline validation (`npm run validate:backends`): every backend's
|
|
104
|
+
* main.dart imports this file (Firebase powers FCM/remote config everywhere), so
|
|
105
|
+
* without it `flutter analyze` fails with "uri doesn't exist". The stub exposes
|
|
106
|
+
* the single member main.dart uses — DefaultFirebaseOptions.currentPlatform — with
|
|
107
|
+
* dummy values, so the analyzer type-checks the whole project without any network.
|
|
108
|
+
*/
|
|
109
|
+
async function writeFirebaseOptionsDevStub(targetDir) {
|
|
110
|
+
const stub = `// Offline validation stub written by \`npm run validate:backends\`.
|
|
111
|
+
// NOT shipped to clients: \`kasy new\` runs \`flutterfire configure\`, which
|
|
112
|
+
// generates the real file from their Firebase project. Present only so
|
|
113
|
+
// \`flutter analyze\` can type-check without touching the network.
|
|
114
|
+
// ignore_for_file: type=lint
|
|
115
|
+
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
|
116
|
+
|
|
117
|
+
class DefaultFirebaseOptions {
|
|
118
|
+
static FirebaseOptions get currentPlatform => const FirebaseOptions(
|
|
119
|
+
apiKey: 'offline-validation-stub',
|
|
120
|
+
appId: 'offline-validation-stub',
|
|
121
|
+
messagingSenderId: 'offline-validation-stub',
|
|
122
|
+
projectId: 'offline-validation-stub',
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
`;
|
|
126
|
+
await fs.outputFile(
|
|
127
|
+
path.join(targetDir, 'lib', 'firebase_options_dev.dart'),
|
|
128
|
+
stub,
|
|
129
|
+
'utf8',
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
94
133
|
async function generateProject(targetDir, backend, options, hooks = {}) {
|
|
95
134
|
const {
|
|
96
135
|
appName,
|
|
@@ -102,6 +141,7 @@ async function generateProject(targetDir, backend, options, hooks = {}) {
|
|
|
102
141
|
includeWeb = true,
|
|
103
142
|
language = 'en',
|
|
104
143
|
deferGoogleAuthPatches = false,
|
|
144
|
+
offline = false,
|
|
105
145
|
} = options;
|
|
106
146
|
|
|
107
147
|
const { applyBackendSetup = null, postBuild = null } = hooks;
|
|
@@ -361,9 +401,18 @@ async function generateProject(targetDir, backend, options, hooks = {}) {
|
|
|
361
401
|
steps.push({ name: 'build-runner', skipped: true });
|
|
362
402
|
}
|
|
363
403
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
404
|
+
// Offline validation never touches the network or the live Firebase project, so
|
|
405
|
+
// skip flutterfire and write a compilable stub for the file it would generate.
|
|
406
|
+
// ffResult.ok stays false → the flutterfire-dependent patches below are skipped.
|
|
407
|
+
let ffResult = { ok: false };
|
|
408
|
+
if (offline) {
|
|
409
|
+
await writeFirebaseOptionsDevStub(targetDir);
|
|
410
|
+
steps.push({ name: 'flutterfire', skipped: true, detail: 'offline (stub written)' });
|
|
411
|
+
} else {
|
|
412
|
+
onProgress('flutterfire');
|
|
413
|
+
ffResult = await flutterfireConfigure(targetDir, firebaseProjectId, { includeWeb });
|
|
414
|
+
steps.push({ name: 'flutterfire', ok: ffResult.ok, detail: ffResult.ok ? null : ffResult.error });
|
|
415
|
+
}
|
|
367
416
|
|
|
368
417
|
// After flutterfire: patch Android, iOS and Web config files.
|
|
369
418
|
if (ffResult.ok) {
|
|
@@ -421,7 +470,7 @@ async function generateProject(targetDir, backend, options, hooks = {}) {
|
|
|
421
470
|
// ONLY for FCM push — there is no Firestore there, so deploying firestore:rules
|
|
422
471
|
// is incoherent (it targets a database that doesn't exist) and just hangs for
|
|
423
472
|
// minutes. The data layer for those backends is Supabase/the REST API.
|
|
424
|
-
if (backend === 'firebase' && firebaseProjectId) {
|
|
473
|
+
if (!offline && backend === 'firebase' && firebaseProjectId) {
|
|
425
474
|
onProgress('firestore-rules');
|
|
426
475
|
const rulesResult = await deployFirestoreRules(targetDir, firebaseProjectId);
|
|
427
476
|
steps.push({ name: 'firestore-rules', ok: rulesResult.ok, detail: rulesResult.ok ? null : rulesResult.error });
|
|
@@ -340,6 +340,7 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
|
|
|
340
340
|
lines.push(`import 'package:flutter_riverpod/flutter_riverpod.dart';`);
|
|
341
341
|
lines.push(`import 'package:go_router/go_router.dart';`);
|
|
342
342
|
lines.push(`import 'package:${pkg}/core/bottom_menu/bottom_menu.dart';`);
|
|
343
|
+
lines.push(`import 'package:${pkg}/core/chrome/chrome_visibility.dart';`);
|
|
343
344
|
if (withAnalytics) {
|
|
344
345
|
lines.push(`import 'package:${pkg}/core/data/api/analytics_api.dart';`);
|
|
345
346
|
}
|
|
@@ -417,6 +418,7 @@ async function writeRouter(projectDir, modules, packageName, defaultPaywall = 'b
|
|
|
417
418
|
if (withAnalytics) {
|
|
418
419
|
lines.push(` AnalyticsObserver(analyticsApi: MixpanelAnalyticsApi.instance()),`);
|
|
419
420
|
}
|
|
421
|
+
lines.push(` KasyChromeVisibilityObserver(),`);
|
|
420
422
|
lines.push(` ...?observers,`);
|
|
421
423
|
lines.push(` ],`);
|
|
422
424
|
lines.push(` routes: [`);
|
|
@@ -624,10 +626,15 @@ async function writeFeaturesConfig(projectDir, modules, answers = {}, language =
|
|
|
624
626
|
const withStripe = modules.includes('stripe');
|
|
625
627
|
const withLocalReminders = modules.includes('local_reminders');
|
|
626
628
|
const withWeb = modules.includes('web');
|
|
627
|
-
// Apple sign-in on web
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
|
|
629
|
+
// Apple sign-in on web needs a Service ID + signed secret that don't exist until
|
|
630
|
+
// the developer configures it (`kasy apple-web`). Until then, showing the button
|
|
631
|
+
// means a dead button on web, so it ships false on every backend and the command
|
|
632
|
+
// flips it to true once web Apple actually works. Native always shows it.
|
|
633
|
+
const withAppleWebSignin = false;
|
|
634
|
+
// Facebook sign-in on web works on the Firebase backend (signInWithPopup) after
|
|
635
|
+
// `kasy facebook`; on Supabase the web flow isn't wired yet (roadmap). Ships false
|
|
636
|
+
// on every backend (the command flips it to true on Firebase). Native always shows.
|
|
637
|
+
const withFacebookWebSignin = false;
|
|
631
638
|
|
|
632
639
|
const f = getStrings(language).features;
|
|
633
640
|
const content = `${f.comment1}
|
|
@@ -644,9 +651,14 @@ const bool withStripePromoCodes = true;
|
|
|
644
651
|
// When true, the Stripe Customer Portal lets subscribers switch plans (upgrade / downgrade).
|
|
645
652
|
const bool withStripePlanSwitching = true;
|
|
646
653
|
const bool withLocalReminders = ${withLocalReminders};
|
|
647
|
-
// Apple sign-in on web:
|
|
648
|
-
//
|
|
654
|
+
// Apple sign-in on web: ships false until configured with \`kasy apple-web\` (needs a
|
|
655
|
+
// paid Apple Service ID + signed secret). The command flips this to true once web
|
|
656
|
+
// Apple actually works, so the button never appears dead. Native always shows it.
|
|
649
657
|
const bool withAppleWebSignin = ${withAppleWebSignin};
|
|
658
|
+
// Facebook sign-in on web: ships false until configured with \`kasy facebook\` on the
|
|
659
|
+
// Firebase backend (signInWithPopup). On Supabase the web flow is roadmap, so it stays
|
|
660
|
+
// false there. Native (iOS/Android) always shows the Facebook button.
|
|
661
|
+
const bool withFacebookWebSignin = ${withFacebookWebSignin};
|
|
650
662
|
${f.comment3}
|
|
651
663
|
${f.comment4}
|
|
652
664
|
${f.comment5}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apple "Sign in with Apple" WEB support helpers.
|
|
3
|
+
*
|
|
4
|
+
* The native iOS/macOS flow needs no secret — the CLI already enables it on both
|
|
5
|
+
* backends at project creation. The WEB (and Android, which we hide) flow uses
|
|
6
|
+
* Apple's OAuth, which requires a Service ID + a client secret that is a short-lived
|
|
7
|
+
* JWT signed with the developer's `.p8` private key.
|
|
8
|
+
*
|
|
9
|
+
* Apple offers NO API to create the Service ID or the `.p8` key — those stay manual
|
|
10
|
+
* on developer.apple.com (done once per Apple account). What we CAN automate is
|
|
11
|
+
* taking the four inputs the developer already created and pushing them to the
|
|
12
|
+
* backend:
|
|
13
|
+
* - Firebase: store Service ID + Team ID + Key ID + `.p8` in the Apple provider
|
|
14
|
+
* (Firebase re-signs the JWT itself, so it never expires).
|
|
15
|
+
* - Supabase: sign the JWT here and store it as external_apple_secret (Supabase
|
|
16
|
+
* cannot re-sign, so this expires every 6 months and must be regenerated).
|
|
17
|
+
*
|
|
18
|
+
* This module: (1) signs the Apple client-secret JWT with node:crypto (no extra
|
|
19
|
+
* dependency), and (2) caches the four inputs in ~/.kasy/apple-web.json so future
|
|
20
|
+
* projects (and the 6-month Supabase renewal) configure web Apple without re-asking.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const os = require('node:os');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
const crypto = require('node:crypto');
|
|
26
|
+
const fs = require('fs-extra');
|
|
27
|
+
|
|
28
|
+
// Apple caps the client-secret JWT lifetime at 6 months. We sign for 180 days to
|
|
29
|
+
// stay safely under the limit. Firebase ignores this (it re-signs); Supabase stores
|
|
30
|
+
// the JWT verbatim, so this is the value behind its "expires every 6 months" notice.
|
|
31
|
+
const APPLE_SECRET_MAX_SECONDS = 180 * 24 * 60 * 60;
|
|
32
|
+
|
|
33
|
+
const CONFIG_DIR = path.join(os.homedir(), '.kasy');
|
|
34
|
+
const APPLE_WEB_CONFIG_PATH = path.join(CONFIG_DIR, 'apple-web.json');
|
|
35
|
+
|
|
36
|
+
/** base64url-encode a string or Buffer (no padding, URL-safe alphabet). */
|
|
37
|
+
function base64url(input) {
|
|
38
|
+
return Buffer.from(input)
|
|
39
|
+
.toString('base64')
|
|
40
|
+
.replace(/=+$/g, '')
|
|
41
|
+
.replace(/\+/g, '-')
|
|
42
|
+
.replace(/\//g, '_');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Normalize a `.p8` private key that may arrive with literal "\n" sequences
|
|
47
|
+
* (common when pasted from a one-line env var) into real newlines so
|
|
48
|
+
* crypto.createPrivateKey can parse the PEM.
|
|
49
|
+
*/
|
|
50
|
+
function normalizePrivateKey(privateKey) {
|
|
51
|
+
const key = String(privateKey || '').trim();
|
|
52
|
+
if (key.includes('-----BEGIN') && !key.includes('\n') && key.includes('\\n')) {
|
|
53
|
+
return key.replace(/\\n/g, '\n');
|
|
54
|
+
}
|
|
55
|
+
return key;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Sign the Apple "Sign in with Apple" client secret (an ES256 JWT).
|
|
60
|
+
*
|
|
61
|
+
* @param {object} opts
|
|
62
|
+
* @param {string} opts.serviceId - Apple Service ID (the OAuth client_id), e.g. com.acme.app.signin
|
|
63
|
+
* @param {string} opts.teamId - Apple Developer Team ID
|
|
64
|
+
* @param {string} opts.keyId - Key ID of the .p8
|
|
65
|
+
* @param {string} opts.privateKey - PEM contents of the .p8 (PKCS#8 EC P-256)
|
|
66
|
+
* @param {number} [opts.expiresInSeconds] - lifetime; clamped to Apple's 6-month max
|
|
67
|
+
* @returns {{ token: string, issuedAt: number, expiresAt: number }}
|
|
68
|
+
*/
|
|
69
|
+
function signAppleClientSecret({ serviceId, teamId, keyId, privateKey, expiresInSeconds = APPLE_SECRET_MAX_SECONDS } = {}) {
|
|
70
|
+
if (!serviceId) throw new Error('serviceId (Apple Service ID) is required');
|
|
71
|
+
if (!teamId) throw new Error('teamId (Apple Team ID) is required');
|
|
72
|
+
if (!keyId) throw new Error('keyId is required');
|
|
73
|
+
if (!privateKey) throw new Error('privateKey (.p8 contents) is required');
|
|
74
|
+
|
|
75
|
+
const issuedAt = Math.floor(Date.now() / 1000);
|
|
76
|
+
const expiresAt = issuedAt + Math.min(expiresInSeconds, APPLE_SECRET_MAX_SECONDS);
|
|
77
|
+
|
|
78
|
+
const header = { alg: 'ES256', kid: keyId };
|
|
79
|
+
const payload = {
|
|
80
|
+
iss: teamId,
|
|
81
|
+
iat: issuedAt,
|
|
82
|
+
exp: expiresAt,
|
|
83
|
+
aud: 'https://appleid.apple.com',
|
|
84
|
+
sub: serviceId,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}`;
|
|
88
|
+
|
|
89
|
+
let keyObject;
|
|
90
|
+
try {
|
|
91
|
+
keyObject = crypto.createPrivateKey(normalizePrivateKey(privateKey));
|
|
92
|
+
} catch (err) {
|
|
93
|
+
throw new Error(`Invalid Apple .p8 private key: ${err.message}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ES256 JWTs need the raw r||s signature (IEEE P1363), not DER.
|
|
97
|
+
const signature = crypto.sign('sha256', Buffer.from(signingInput), {
|
|
98
|
+
key: keyObject,
|
|
99
|
+
dsaEncoding: 'ieee-p1363',
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return { token: `${signingInput}.${base64url(signature)}`, issuedAt, expiresAt };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Load cached Apple web credentials from ~/.kasy/apple-web.json.
|
|
107
|
+
* Returns null if absent or incomplete.
|
|
108
|
+
*/
|
|
109
|
+
async function loadAppleWebCreds() {
|
|
110
|
+
try {
|
|
111
|
+
if (!(await fs.pathExists(APPLE_WEB_CONFIG_PATH))) return null;
|
|
112
|
+
const data = await fs.readJson(APPLE_WEB_CONFIG_PATH);
|
|
113
|
+
if (!data || !data.serviceId || !data.teamId || !data.keyId || !data.privateKey) return null;
|
|
114
|
+
return data;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Persist Apple web credentials to ~/.kasy/apple-web.json with 0600 perms so the
|
|
122
|
+
* 6-month Supabase renewal and future projects can reuse them without re-asking.
|
|
123
|
+
*/
|
|
124
|
+
async function saveAppleWebCreds({ serviceId, teamId, keyId, privateKey }) {
|
|
125
|
+
await fs.ensureDir(CONFIG_DIR);
|
|
126
|
+
await fs.writeJson(
|
|
127
|
+
APPLE_WEB_CONFIG_PATH,
|
|
128
|
+
{ serviceId, teamId, keyId, privateKey: normalizePrivateKey(privateKey) },
|
|
129
|
+
{ spaces: 2 },
|
|
130
|
+
);
|
|
131
|
+
// Best effort: restrict to the owner (no-op / unsupported on some Windows setups).
|
|
132
|
+
try {
|
|
133
|
+
await fs.chmod(APPLE_WEB_CONFIG_PATH, 0o600);
|
|
134
|
+
} catch {
|
|
135
|
+
/* ignore */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = {
|
|
140
|
+
APPLE_SECRET_MAX_SECONDS,
|
|
141
|
+
APPLE_WEB_CONFIG_PATH,
|
|
142
|
+
base64url,
|
|
143
|
+
normalizePrivateKey,
|
|
144
|
+
signAppleClientSecret,
|
|
145
|
+
loadAppleWebCreds,
|
|
146
|
+
saveAppleWebCreds,
|
|
147
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Facebook Login helpers, shared by `kasy facebook` and `kasy configure`.
|
|
3
|
+
*
|
|
4
|
+
* Facebook needs values in two places:
|
|
5
|
+
* - Client side (the app): App ID + Client Token in iOS Info.plist and Android
|
|
6
|
+
* strings.xml (build-time). Same for every backend.
|
|
7
|
+
* - Backend provider (server side): App ID + App Secret on Firebase (Identity
|
|
8
|
+
* Toolkit) or Supabase (Management API). Configured by the backend writers.
|
|
9
|
+
*
|
|
10
|
+
* The Meta-side work (create the app, get App ID / Client Token / App Secret, set
|
|
11
|
+
* OAuth redirect URIs and JS domains) has no automation API — it stays manual and
|
|
12
|
+
* is guided by `kasy facebook`. This module owns the native-file read/write and the
|
|
13
|
+
* ~/.kasy/facebook.json credential cache so future projects reuse the values.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const os = require('node:os');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
const fs = require('fs-extra');
|
|
19
|
+
|
|
20
|
+
const CONFIG_DIR = path.join(os.homedir(), '.kasy');
|
|
21
|
+
const FACEBOOK_CONFIG_PATH = path.join(CONFIG_DIR, 'facebook.json');
|
|
22
|
+
|
|
23
|
+
// ── Native build-time files (Info.plist + strings.xml) ──────────────────────
|
|
24
|
+
|
|
25
|
+
function readFacebookCurrent(content, kind) {
|
|
26
|
+
if (kind === 'plist') {
|
|
27
|
+
const appId = content.match(/<key>FacebookAppID<\/key>\s*<string>([^<]*)<\/string>/);
|
|
28
|
+
const token = content.match(/<key>FacebookClientToken<\/key>\s*<string>([^<]*)<\/string>/);
|
|
29
|
+
return { appId: (appId?.[1] || '').trim(), token: (token?.[1] || '').trim() };
|
|
30
|
+
}
|
|
31
|
+
// strings.xml
|
|
32
|
+
const appId = content.match(/<string name="facebook_app_id"[^>]*>([^<]*)<\/string>/);
|
|
33
|
+
const token = content.match(/<string name="facebook_client_token"[^>]*>([^<]*)<\/string>/);
|
|
34
|
+
return { appId: (appId?.[1] || '').trim(), token: (token?.[1] || '').trim() };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isFacebookPlaceholder(value) {
|
|
38
|
+
return !value || /^0+$/.test(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readFacebookState(projectDir) {
|
|
42
|
+
const plistPath = path.join(projectDir, 'ios', 'Runner', 'Info.plist');
|
|
43
|
+
const stringsPath = path.join(projectDir, 'android', 'app', 'src', 'main', 'res', 'values', 'strings.xml');
|
|
44
|
+
const state = { appId: '', token: '', plistExists: false, stringsExists: false };
|
|
45
|
+
if (await fs.pathExists(plistPath)) {
|
|
46
|
+
state.plistExists = true;
|
|
47
|
+
const plistRead = readFacebookCurrent(await fs.readFile(plistPath, 'utf8'), 'plist');
|
|
48
|
+
if (!isFacebookPlaceholder(plistRead.appId)) state.appId = plistRead.appId;
|
|
49
|
+
if (!isFacebookPlaceholder(plistRead.token)) state.token = plistRead.token;
|
|
50
|
+
}
|
|
51
|
+
if (await fs.pathExists(stringsPath)) {
|
|
52
|
+
state.stringsExists = true;
|
|
53
|
+
const stringsRead = readFacebookCurrent(await fs.readFile(stringsPath, 'utf8'), 'strings');
|
|
54
|
+
// Prefer plist value when both exist; only fall back if plist was placeholder.
|
|
55
|
+
if (!state.appId && !isFacebookPlaceholder(stringsRead.appId)) state.appId = stringsRead.appId;
|
|
56
|
+
if (!state.token && !isFacebookPlaceholder(stringsRead.token)) state.token = stringsRead.token;
|
|
57
|
+
}
|
|
58
|
+
return state;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function writeFacebookCredentials(projectDir, appId, clientToken) {
|
|
62
|
+
const plistPath = path.join(projectDir, 'ios', 'Runner', 'Info.plist');
|
|
63
|
+
const stringsPath = path.join(projectDir, 'android', 'app', 'src', 'main', 'res', 'values', 'strings.xml');
|
|
64
|
+
const results = { plist: 'skipped', strings: 'skipped' };
|
|
65
|
+
|
|
66
|
+
if (appId && (await fs.pathExists(plistPath))) {
|
|
67
|
+
let plist = await fs.readFile(plistPath, 'utf8');
|
|
68
|
+
plist = plist.replace(
|
|
69
|
+
/(<key>FacebookAppID<\/key>\s*<string>)[^<]*(<\/string>)/,
|
|
70
|
+
`$1${appId}$2`,
|
|
71
|
+
);
|
|
72
|
+
if (clientToken) {
|
|
73
|
+
plist = plist.replace(
|
|
74
|
+
/(<key>FacebookClientToken<\/key>\s*<string>)[^<]*(<\/string>)/,
|
|
75
|
+
`$1${clientToken}$2`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
// CFBundleURLSchemes — replace `fb<zeros>` with `fb<appId>` so deep links work.
|
|
79
|
+
plist = plist.replace(/<string>fb0+<\/string>/, `<string>fb${appId}</string>`);
|
|
80
|
+
await fs.outputFile(plistPath, plist, 'utf8');
|
|
81
|
+
results.plist = 'ok';
|
|
82
|
+
} else if (appId) {
|
|
83
|
+
results.plist = 'missing_file';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (appId && (await fs.pathExists(stringsPath))) {
|
|
87
|
+
let xml = await fs.readFile(stringsPath, 'utf8');
|
|
88
|
+
xml = xml.replace(
|
|
89
|
+
/(<string name="facebook_app_id"[^>]*>)[^<]*(<\/string>)/,
|
|
90
|
+
`$1${appId}$2`,
|
|
91
|
+
);
|
|
92
|
+
if (clientToken) {
|
|
93
|
+
xml = xml.replace(
|
|
94
|
+
/(<string name="facebook_client_token"[^>]*>)[^<]*(<\/string>)/,
|
|
95
|
+
`$1${clientToken}$2`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
// fb_login_protocol_scheme — set if present.
|
|
99
|
+
xml = xml.replace(
|
|
100
|
+
/(<string name="fb_login_protocol_scheme"[^>]*>)[^<]*(<\/string>)/,
|
|
101
|
+
`$1fb${appId}$2`,
|
|
102
|
+
);
|
|
103
|
+
await fs.outputFile(stringsPath, xml, 'utf8');
|
|
104
|
+
results.strings = 'ok';
|
|
105
|
+
} else if (appId) {
|
|
106
|
+
results.strings = 'missing_file';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return results;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Credential cache (~/.kasy/facebook.json) ────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/** Load cached Facebook credentials. Returns null if absent or without an App ID. */
|
|
115
|
+
async function loadFacebookCreds() {
|
|
116
|
+
try {
|
|
117
|
+
if (!(await fs.pathExists(FACEBOOK_CONFIG_PATH))) return null;
|
|
118
|
+
const data = await fs.readJson(FACEBOOK_CONFIG_PATH);
|
|
119
|
+
if (!data || !data.appId) return null;
|
|
120
|
+
return data;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Persist Facebook credentials (App ID + Client Token + App Secret) with 0600. */
|
|
127
|
+
async function saveFacebookCreds({ appId, clientToken, appSecret }) {
|
|
128
|
+
await fs.ensureDir(CONFIG_DIR);
|
|
129
|
+
await fs.writeJson(
|
|
130
|
+
FACEBOOK_CONFIG_PATH,
|
|
131
|
+
{ appId, clientToken: clientToken || '', appSecret: appSecret || '' },
|
|
132
|
+
{ spaces: 2 },
|
|
133
|
+
);
|
|
134
|
+
try {
|
|
135
|
+
await fs.chmod(FACEBOOK_CONFIG_PATH, 0o600);
|
|
136
|
+
} catch {
|
|
137
|
+
/* ignore */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Set `withFacebookWebSignin` to the given boolean in the project's features.dart. */
|
|
142
|
+
async function setFacebookWebFlag(projectDir, value) {
|
|
143
|
+
const file = path.join(projectDir, 'lib', 'core', 'config', 'features.dart');
|
|
144
|
+
if (!(await fs.pathExists(file))) return { ok: false, missing: true };
|
|
145
|
+
let content = await fs.readFile(file, 'utf8');
|
|
146
|
+
const re = /const bool withFacebookWebSignin\s*=\s*(?:true|false)\s*;/;
|
|
147
|
+
if (!re.test(content)) return { ok: false, missing: true };
|
|
148
|
+
content = content.replace(re, `const bool withFacebookWebSignin = ${value};`);
|
|
149
|
+
await fs.writeFile(file, content, 'utf8');
|
|
150
|
+
return { ok: true };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = {
|
|
154
|
+
FACEBOOK_CONFIG_PATH,
|
|
155
|
+
readFacebookCurrent,
|
|
156
|
+
isFacebookPlaceholder,
|
|
157
|
+
readFacebookState,
|
|
158
|
+
writeFacebookCredentials,
|
|
159
|
+
loadFacebookCreds,
|
|
160
|
+
saveFacebookCreds,
|
|
161
|
+
setFacebookWebFlag,
|
|
162
|
+
};
|