kasy-cli 1.13.0 → 1.15.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.
Files changed (157) hide show
  1. package/bin/kasy.js +140 -12
  2. package/lib/commands/add.js +2 -2
  3. package/lib/commands/codemagic.js +11 -4
  4. package/lib/commands/deploy.js +3 -3
  5. package/lib/commands/favicon.js +115 -0
  6. package/lib/commands/icon.js +143 -0
  7. package/lib/commands/ios.js +28 -7
  8. package/lib/commands/new.js +8 -20
  9. package/lib/commands/remove.js +1 -1
  10. package/lib/commands/reset.js +385 -0
  11. package/lib/commands/run.js +24 -17
  12. package/lib/commands/splash.js +14 -4
  13. package/lib/commands/update.js +1 -1
  14. package/lib/scaffold/backends/api/patch/README.md +1 -1
  15. package/lib/scaffold/backends/api/patch/android/app/src/main/AndroidManifest.xml +1 -1
  16. package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +53 -0
  17. package/lib/scaffold/backends/api/pubspec.yaml.tpl +11 -1
  18. package/lib/scaffold/backends/firebase/tokens.js +2 -2
  19. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +8 -2
  20. package/lib/scaffold/backends/supabase/migrations/20240101000011_dedupe_device_tokens.sql +34 -0
  21. package/lib/scaffold/backends/supabase/patch/README.md +1 -1
  22. package/lib/scaffold/backends/supabase/patch/android/app/src/main/AndroidManifest.xml +1 -1
  23. package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +43 -0
  24. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +11 -1
  25. package/lib/utils/apple-release.js +115 -16
  26. package/lib/utils/checks.js +45 -107
  27. package/lib/utils/debug.js +75 -0
  28. package/lib/utils/flutter-run.js +173 -0
  29. package/lib/utils/friendly-error.js +91 -0
  30. package/lib/utils/i18n/messages-en.js +970 -0
  31. package/lib/utils/i18n/messages-es.js +968 -0
  32. package/lib/utils/i18n/messages-pt.js +968 -0
  33. package/lib/utils/i18n.js +21 -2483
  34. package/lib/utils/mobile-identity.js +35 -0
  35. package/lib/utils/png-padding.js +120 -0
  36. package/lib/utils/ui.js +114 -0
  37. package/package.json +8 -4
  38. package/templates/firebase/README.en.md +1 -1
  39. package/templates/firebase/README.es.md +1 -1
  40. package/templates/firebase/README.md +1 -1
  41. package/templates/firebase/android/app/build.gradle.kts +10 -1
  42. package/templates/firebase/android/app/src/main/AndroidManifest.xml +1 -1
  43. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MainActivity.kt +25 -1
  44. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidget.kt +161 -11
  45. package/templates/firebase/android/app/src/main/res/drawable/widget_add_button.xml +15 -0
  46. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_bg.xml +9 -0
  47. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_inner.xml +12 -0
  48. package/templates/firebase/android/app/src/main/res/drawable/widget_plan_pill_bg.xml +5 -0
  49. package/templates/firebase/android/app/src/main/res/drawable/widget_preview_image.xml +17 -0
  50. package/templates/firebase/android/app/src/main/res/drawable/widget_pro_pill_bg.xml +5 -0
  51. package/templates/firebase/android/app/src/main/res/drawable-hdpi/android12splash.png +0 -0
  52. package/templates/firebase/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png +0 -0
  53. package/templates/firebase/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png +0 -0
  54. package/templates/firebase/android/app/src/main/res/drawable-mdpi/android12splash.png +0 -0
  55. package/templates/firebase/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png +0 -0
  56. package/templates/firebase/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png +0 -0
  57. package/templates/firebase/android/app/src/main/res/drawable-night-hdpi/android12splash.png +0 -0
  58. package/templates/firebase/android/app/src/main/res/drawable-night-mdpi/android12splash.png +0 -0
  59. package/templates/firebase/android/app/src/main/res/drawable-night-xhdpi/android12splash.png +0 -0
  60. package/templates/firebase/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png +0 -0
  61. package/templates/firebase/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png +0 -0
  62. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/android12splash.png +0 -0
  63. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png +0 -0
  64. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png +0 -0
  65. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/android12splash.png +0 -0
  66. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png +0 -0
  67. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png +0 -0
  68. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/android12splash.png +0 -0
  69. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png +0 -0
  70. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png +0 -0
  71. package/templates/firebase/android/app/src/main/res/layout/widget_loading.xml +8 -0
  72. package/templates/firebase/android/app/src/main/res/layout/widget_preview.xml +53 -0
  73. package/templates/firebase/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +9 -0
  74. package/templates/firebase/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  75. package/templates/firebase/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  76. package/templates/firebase/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  77. package/templates/firebase/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  78. package/templates/firebase/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  79. package/templates/firebase/android/app/src/main/res/xml/mywidget_info.xml +9 -3
  80. package/templates/firebase/assets/images/favicon.png +0 -0
  81. package/templates/firebase/assets/images/icon.png +0 -0
  82. package/templates/firebase/assets/images/icon_android.png +0 -0
  83. package/templates/firebase/assets/images/icon_foreground_empty.png +0 -0
  84. package/templates/firebase/assets/images/splash_logo_dark_android12.png +0 -0
  85. package/templates/firebase/assets/images/splash_logo_light_android12.png +0 -0
  86. package/templates/firebase/firestore.indexes.json +10 -0
  87. package/templates/firebase/functions/src/core/data/entities/user_device_entity.ts +3 -0
  88. package/templates/firebase/functions/src/core/data/repositories/user_device_repository.ts +17 -1
  89. package/templates/firebase/functions/src/index.ts +1 -0
  90. package/templates/firebase/functions/src/notifications/device_triggers.ts +58 -0
  91. package/templates/firebase/ios/HomeWidgetExtension/MyWidget.swift +116 -33
  92. package/templates/firebase/ios/Runner/AppDelegate.swift +17 -1
  93. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png +0 -0
  94. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png +0 -0
  95. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png +0 -0
  96. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png +0 -0
  97. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png +0 -0
  98. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png +0 -0
  99. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png +0 -0
  100. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png +0 -0
  101. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png +0 -0
  102. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png +0 -0
  103. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png +0 -0
  104. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png +0 -0
  105. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png +0 -0
  106. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png +0 -0
  107. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png +0 -0
  108. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png +0 -0
  109. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png +0 -0
  110. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png +0 -0
  111. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png +0 -0
  112. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png +0 -0
  113. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png +0 -0
  114. package/templates/firebase/ios/Runner/Info.plist +2 -2
  115. package/templates/firebase/ios/Runner/es.lproj/InfoPlist.strings +1 -1
  116. package/templates/firebase/ios/Runner/pt-BR.lproj/InfoPlist.strings +1 -1
  117. package/templates/firebase/ios/Runner/pt.lproj/InfoPlist.strings +1 -1
  118. package/templates/firebase/lib/components/components.dart +1 -0
  119. package/templates/firebase/lib/components/kasy_avatar.dart +88 -57
  120. package/templates/firebase/lib/components/kasy_avatar_presets.dart +116 -74
  121. package/templates/firebase/lib/components/kasy_button.dart +8 -0
  122. package/templates/firebase/lib/components/kasy_tabs.dart +431 -0
  123. package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +18 -0
  124. package/templates/firebase/lib/core/home_widgets/home_widget_mywidget_service.dart +73 -19
  125. package/templates/firebase/lib/core/home_widgets/home_widget_service.dart +22 -8
  126. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +16 -4
  127. package/templates/firebase/lib/core/states/components/maybeshow_component.dart +4 -8
  128. package/templates/firebase/lib/core/states/user_state_notifier.dart +13 -1
  129. package/templates/firebase/lib/features/home/home_components_page.dart +1 -1
  130. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +316 -93
  131. package/templates/firebase/lib/features/home/home_page.dart +0 -6
  132. package/templates/firebase/lib/features/notifications/api/device_api.dart +57 -0
  133. package/templates/firebase/lib/features/notifications/providers/models/notification.dart +11 -1
  134. package/templates/firebase/lib/features/notifications/repositories/device_repository.dart +9 -0
  135. package/templates/firebase/lib/features/notifications/repositories/notifications_repository.dart +1 -4
  136. package/templates/firebase/lib/features/notifications/shared/att_permission.dart +28 -8
  137. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +16 -1
  138. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +44 -11
  139. package/templates/firebase/lib/features/settings/ui/components/admin/admin_bottom_sheet.dart +31 -29
  140. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +21 -5
  141. package/templates/firebase/lib/i18n/en.i18n.json +4 -1
  142. package/templates/firebase/lib/i18n/es.i18n.json +4 -1
  143. package/templates/firebase/lib/i18n/pt.i18n.json +4 -1
  144. package/templates/firebase/pubspec.yaml +10 -3
  145. package/templates/firebase/test/features/notifications/data/device_api_fake.dart +9 -0
  146. package/templates/firebase/web/favicon.png +0 -0
  147. package/templates/firebase/web/icons/Icon-192.png +0 -0
  148. package/templates/firebase/web/icons/Icon-512.png +0 -0
  149. package/templates/firebase/web/icons/Icon-maskable-192.png +0 -0
  150. package/templates/firebase/web/icons/Icon-maskable-512.png +0 -0
  151. package/templates/firebase/web/index.html +9 -0
  152. package/templates/firebase/web/manifest.json +3 -3
  153. package/templates/firebase/assets/images/app_icon.png +0 -0
  154. package/templates/firebase/assets/images/onboarding/img1.jpg +0 -0
  155. package/templates/firebase/assets/images/onboarding/onboarding.png +0 -0
  156. package/templates/firebase/lib/core/states/components/maybe_ask_biometric_setup.dart +0 -88
  157. package/templates/firebase/lib/features/notifications/shared/notification_permission_bottom_sheet.dart +0 -144
@@ -29,6 +29,14 @@ abstract class DeviceApi {
29
29
  /// Register the device in the backend
30
30
  /// Of course your backend should check if the device is already registered
31
31
  /// throws an [ApiError] if something goes wrong
32
+ ///
33
+ /// IMPORTANT — Cross-user token uniqueness:
34
+ /// The backend `POST /users/{userId}/devices` endpoint MUST guarantee that
35
+ /// the FCM token is unique across users. When the same token is registered
36
+ /// for a new user, delete any existing record holding that same token under
37
+ /// other users. Without this, a failed logout (offline) leaves the phone
38
+ /// registered to both accounts, and push for account A delivers to a phone
39
+ /// now signed in as account B.
32
40
  Future<DeviceEntity> register(String userId, DeviceEntity device);
33
41
 
34
42
  /// Update the device in the backend
@@ -38,6 +46,25 @@ abstract class DeviceApi {
38
46
  /// Unregister the device in the backend
39
47
  Future<void> unregister(String userId, String deviceId);
40
48
 
49
+ /// Heartbeat — tell the backend this install is still active.
50
+ /// Backend should update a `lastUpdateDate` (or equivalent) timestamp and use
51
+ /// it to skip stale devices when sending push notifications. Without this,
52
+ /// re-installing the app (Xcode -> TestFlight, build updates) leaves
53
+ /// orphan device records that still receive push, causing duplicates.
54
+ ///
55
+ /// Suggested endpoint: `PATCH /users/{userId}/devices/{installationId}/touch`
56
+ /// Implement on your API to update the device row's last-seen timestamp.
57
+ Future<void> touch(String userId, String installationId);
58
+
59
+ /// Ask the backend to drop device records of the same user that haven't
60
+ /// been heartbeated in a while (typically 30 days). Called after registering
61
+ /// a fresh installation to clean up orphans from previous installs on the
62
+ /// same physical device.
63
+ ///
64
+ /// Suggested endpoint: `POST /users/{userId}/devices/cleanup-stale`
65
+ /// with body `{ "currentInstallationId": "...", "olderThanDays": 30 }`.
66
+ Future<void> cleanupStaleDevices(String userId, String currentInstallationId);
67
+
41
68
  /// Listen to token refresh
42
69
  void onTokenRefresh(OnTokenRefresh onTokenRefresh);
43
70
 
@@ -153,6 +180,32 @@ class FirebaseDeviceApi implements DeviceApi {
153
180
  }
154
181
  }
155
182
 
183
+ @override
184
+ Future<void> touch(String userId, String installationId) async {
185
+ // Fire-and-forget: silently no-ops if the backend doesn't implement
186
+ // the touch endpoint yet. The duplicated-push protection becomes active
187
+ // once the API exposes the endpoint described in the abstract above.
188
+ try {
189
+ await _client.patch('/users/$userId/devices/$installationId/touch');
190
+ } catch (_) {}
191
+ }
192
+
193
+ @override
194
+ Future<void> cleanupStaleDevices(
195
+ String userId,
196
+ String currentInstallationId,
197
+ ) async {
198
+ try {
199
+ await _client.post(
200
+ '/users/$userId/devices/cleanup-stale',
201
+ data: {
202
+ 'currentInstallationId': currentInstallationId,
203
+ 'olderThanDays': 30,
204
+ },
205
+ );
206
+ } catch (_) {}
207
+ }
208
+
156
209
  @override
157
210
  void onTokenRefresh(OnTokenRefresh onTokenRefresh) {
158
211
  _onTokenRefreshSubscription =
@@ -98,11 +98,21 @@ flutter_launcher_icons:
98
98
  android: ic_launcher
99
99
  ios: true
100
100
  remove_alpha_ios: true
101
+ web:
102
+ generate: true
103
+ image_path: assets/images/favicon.png
104
+ background_color: "#01171f"
105
+ theme_color: "#01171f"
101
106
  flutter_native_splash:
102
107
  color: "#FFFFFF"
108
+ color_dark: "#000000"
103
109
  fullscreen: true
104
110
  ios: true
105
111
  android: true
106
- image: assets/images/splashscreen.png
112
+ image: assets/images/splash_logo_light.png
113
+ image_dark: assets/images/splash_logo_dark.png
107
114
  android_12:
108
115
  color: "#FFFFFF"
116
+ color_dark: "#000000"
117
+ image: assets/images/splash_logo_light_android12.png
118
+ image_dark: assets/images/splash_logo_dark_android12.png
@@ -10,13 +10,13 @@
10
10
  * Original hardcoded values in Firebase/:
11
11
  * - package name : kasy_kit (Dart import path prefix)
12
12
  * - bundle ID : com.aicrus.firebase.kit (Android namespace, iOS bundle ID)
13
- * - app display : AppFirebase (AndroidManifest, Info.plist)
13
+ * - app display : Kasy App (AndroidManifest, Info.plist) — unique string with space, won't collide with KasyButton/KasyTheme/etc
14
14
  * - short name : appfirebase (kAppName Dart constant)
15
15
  */
16
16
 
17
17
  const ORIGINAL_PACKAGE = 'kasy_kit';
18
18
  const ORIGINAL_BUNDLE_ID = 'com.aicrus.firebase.kit';
19
- const ORIGINAL_APP_NAME = 'AppFirebase';
19
+ const ORIGINAL_APP_NAME = 'Kasy App';
20
20
  const ORIGINAL_SHORT_NAME = 'appfirebase';
21
21
 
22
22
  /**
@@ -254,11 +254,17 @@ Deno.serve(async (req: Request) => {
254
254
  const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
255
255
  const supabase = createClient(supabaseUrl, serviceRoleKey);
256
256
 
257
- // Fetch device tokens for the user
257
+ // Fetch device tokens for the user, skipping orphan installs.
258
+ // Devices not touched in the last 60 days are treated as leftovers from
259
+ // previous installations on the same physical device (each install gets a
260
+ // fresh installation_id). Sending to them causes duplicated push delivery.
261
+ const STALE_DEVICE_TTL_MS = 60 * 24 * 60 * 60 * 1000;
262
+ const cutoffIso = new Date(Date.now() - STALE_DEVICE_TTL_MS).toISOString();
258
263
  const { data: devices, error: devErr } = await supabase
259
264
  .from("devices")
260
265
  .select("id, token")
261
- .eq("user_id", notification.user_id);
266
+ .eq("user_id", notification.user_id)
267
+ .or(`last_update_date.is.null,last_update_date.gte.${cutoffIso}`);
262
268
 
263
269
  if (devErr || !devices?.length) {
264
270
  console.log(`[send-push] no devices for user ${notification.user_id}`);
@@ -0,0 +1,34 @@
1
+ -- Cross-user device token deduplication.
2
+ --
3
+ -- Guarantees that a single FCM token belongs to at most one user at any time.
4
+ -- When the same install registers under a new account (typical scenario:
5
+ -- logout failed offline), the old row is automatically deleted so the iPhone
6
+ -- only receives push for the account currently signed in.
7
+ --
8
+ -- Winner = the row that was just inserted/updated (most recent intent).
9
+
10
+ CREATE OR REPLACE FUNCTION public.cleanup_duplicate_device_tokens()
11
+ RETURNS TRIGGER
12
+ LANGUAGE plpgsql
13
+ SECURITY DEFINER
14
+ SET search_path = public
15
+ AS $$
16
+ BEGIN
17
+ IF NEW.token IS NULL OR NEW.token = '' THEN
18
+ RETURN NEW;
19
+ END IF;
20
+
21
+ DELETE FROM public.devices
22
+ WHERE token = NEW.token
23
+ AND id <> NEW.id;
24
+
25
+ RETURN NEW;
26
+ END;
27
+ $$;
28
+
29
+ DROP TRIGGER IF EXISTS trg_cleanup_duplicate_device_tokens ON public.devices;
30
+
31
+ CREATE TRIGGER trg_cleanup_duplicate_device_tokens
32
+ AFTER INSERT OR UPDATE OF token ON public.devices
33
+ FOR EACH ROW
34
+ EXECUTE FUNCTION public.cleanup_duplicate_device_tokens();
@@ -1,4 +1,4 @@
1
- # AppFirebase
1
+ # Kasy App
2
2
 
3
3
  Flutter app com backend Supabase — gerado pelo kasy.
4
4
 
@@ -14,7 +14,7 @@
14
14
  <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
15
15
 
16
16
  <application
17
- android:label="AppFirebase"
17
+ android:label="Kasy App"
18
18
  android:name="${applicationName}"
19
19
  android:icon="@mipmap/ic_launcher">
20
20
  <activity
@@ -36,6 +36,15 @@ abstract class DeviceApi {
36
36
  /// Unregister the device in the backend
37
37
  Future<void> unregister(String userId, String deviceId);
38
38
 
39
+ /// Heartbeat — update `last_update_date` on the current device row.
40
+ /// Used so the backend can detect orphan rows from previous installs.
41
+ Future<void> touch(String userId, String installationId);
42
+
43
+ /// Delete device rows of the same user that haven't been touched in a while.
44
+ /// Called after registering a fresh installation to remove orphans left by
45
+ /// previous installs (whose installation_id no longer matches).
46
+ Future<void> cleanupStaleDevices(String userId, String currentInstallationId);
47
+
39
48
  /// Listen to token refresh
40
49
  void onTokenRefresh(OnTokenRefresh onTokenRefresh);
41
50
 
@@ -160,6 +169,40 @@ class FirebaseDeviceApi implements DeviceApi {
160
169
  }
161
170
  }
162
171
 
172
+ @override
173
+ Future<void> touch(String userId, String installationId) async {
174
+ try {
175
+ await _client
176
+ .from('devices')
177
+ .update({'last_update_date': DateTime.now().toIso8601String()})
178
+ .eq('user_id', userId)
179
+ .eq('installation_id', installationId);
180
+ } catch (_) {
181
+ // Missing row — caller will re-register on next session.
182
+ }
183
+ }
184
+
185
+ @override
186
+ Future<void> cleanupStaleDevices(
187
+ String userId,
188
+ String currentInstallationId,
189
+ ) async {
190
+ // Devices not touched in the last 30 days are treated as orphans from
191
+ // previous installations on the same physical device. Active second
192
+ // devices stay above this threshold via heartbeat.
193
+ final cutoff = DateTime.now().subtract(const Duration(days: 30));
194
+ try {
195
+ await _client
196
+ .from('devices')
197
+ .delete()
198
+ .eq('user_id', userId)
199
+ .neq('installation_id', currentInstallationId)
200
+ .lt('last_update_date', cutoff.toIso8601String());
201
+ } catch (e) {
202
+ Logger().w('cleanupStaleDevices failed: $e');
203
+ }
204
+ }
205
+
163
206
  @override
164
207
  Future<void> clear(String userId) async {
165
208
  try {
@@ -100,11 +100,21 @@ flutter_launcher_icons:
100
100
  android: ic_launcher
101
101
  ios: true
102
102
  remove_alpha_ios: true
103
+ web:
104
+ generate: true
105
+ image_path: assets/images/favicon.png
106
+ background_color: "#01171f"
107
+ theme_color: "#01171f"
103
108
  flutter_native_splash:
104
109
  color: "#FFFFFF"
110
+ color_dark: "#000000"
105
111
  fullscreen: true
106
112
  ios: true
107
113
  android: true
108
- image: assets/images/splashscreen.png
114
+ image: assets/images/splash_logo_light.png
115
+ image_dark: assets/images/splash_logo_dark.png
109
116
  android_12:
110
117
  color: "#FFFFFF"
118
+ color_dark: "#000000"
119
+ image: assets/images/splash_logo_light_android12.png
120
+ image_dark: assets/images/splash_logo_dark_android12.png
@@ -5,7 +5,7 @@ const fs = require('fs-extra');
5
5
  const os = require('node:os');
6
6
  const kleur = require('kleur');
7
7
  const ui = require('./ui');
8
- const { exec } = require('node:child_process');
8
+ const { exec, spawn } = require('node:child_process');
9
9
  const { promisify } = require('node:util');
10
10
 
11
11
  const execAsync = promisify(exec);
@@ -43,6 +43,27 @@ async function isKasyFlutterProject(projectDir) {
43
43
  }
44
44
 
45
45
  async function readBundleId(projectDir) {
46
+ // Source of truth is the Xcode project file — what the build actually uses
47
+ // when installing on a device. kit_setup.json can drift if the user renames
48
+ // the bundle id manually after `kasy new`, so it's only used as a fallback
49
+ // when the iOS project hasn't been generated yet.
50
+ const pbxPath = path.join(projectDir, 'ios', 'Runner.xcodeproj', 'project.pbxproj');
51
+ if (await fs.pathExists(pbxPath)) {
52
+ const content = await fs.readFile(pbxPath, 'utf8');
53
+ // Match the main target only — skip Widget/NotificationService extensions
54
+ // by picking the first identifier that doesn't contain a dot extension.
55
+ const matches = [...content.matchAll(/PRODUCT_BUNDLE_IDENTIFIER = ([^;]+);/g)];
56
+ for (const m of matches) {
57
+ const id = m[1].trim().replace(/"/g, '');
58
+ if (
59
+ !id.endsWith('.HomeWidgetExtension') &&
60
+ !id.endsWith('.NotificationService')
61
+ ) {
62
+ return id;
63
+ }
64
+ }
65
+ if (matches[0]) return matches[0][1].trim().replace(/"/g, '');
66
+ }
46
67
  const kitSetupPath = path.join(projectDir, 'kit_setup.json');
47
68
  if (await fs.pathExists(kitSetupPath)) {
48
69
  try {
@@ -52,12 +73,6 @@ async function readBundleId(projectDir) {
52
73
  // ignore
53
74
  }
54
75
  }
55
- const pbxPath = path.join(projectDir, 'ios', 'Runner.xcodeproj', 'project.pbxproj');
56
- if (await fs.pathExists(pbxPath)) {
57
- const content = await fs.readFile(pbxPath, 'utf8');
58
- const m = content.match(/PRODUCT_BUNDLE_IDENTIFIER = ([^;]+);/);
59
- if (m) return m[1].trim().replace(/"/g, '');
60
- }
61
76
  return null;
62
77
  }
63
78
 
@@ -170,6 +185,18 @@ const XCODE_CACHE_ERROR_PATTERNS = [
170
185
  /clang: error: no such file or directory:.*\.swiftmodule/i,
171
186
  ];
172
187
 
188
+ const POD_NETWORK_ERROR_PATTERNS = [
189
+ /curl:\s*\(6\)/i,
190
+ /curl:\s*\(7\)/i,
191
+ /curl:\s*\(28\)/i,
192
+ /curl:\s*\(35\)/i,
193
+ /curl:\s*\(56\)/i,
194
+ /Connection reset by peer/i,
195
+ /Could not resolve host/i,
196
+ /Network is unreachable/i,
197
+ /Operation timed out/i,
198
+ ];
199
+
173
200
  async function getFreeDiskGb(checkPath) {
174
201
  if (process.platform !== 'darwin' && process.platform !== 'linux') {
175
202
  return null;
@@ -201,6 +228,11 @@ function isXcodeCacheBuildError(output) {
201
228
  return XCODE_CACHE_ERROR_PATTERNS.some((re) => re.test(text));
202
229
  }
203
230
 
231
+ function isPodNetworkError(output) {
232
+ const text = String(output || '');
233
+ return POD_NETWORK_ERROR_PATTERNS.some((re) => re.test(text));
234
+ }
235
+
204
236
  function printBuildFailureHints(t, projectDir) {
205
237
  const name = path.basename(projectDir);
206
238
  const cleanArg = projectDir !== process.cwd() ? ` ${projectDir}` : '';
@@ -220,6 +252,17 @@ function printBuildFailureHints(t, projectDir) {
220
252
  ui.note(lines.join('\n'), t('ios.hints.title'));
221
253
  }
222
254
 
255
+ function printPodNetworkHints(t) {
256
+ const lines = [
257
+ t('ios.hints.network.body'),
258
+ '',
259
+ `1. ${t('ios.hints.network.step1')}`,
260
+ `2. ${t('ios.hints.network.step2')}: ${kleur.cyan('kasy ios')}`,
261
+ `3. ${t('ios.hints.network.step3')}: ${kleur.cyan('kasy ios clean')}`,
262
+ ];
263
+ ui.note(lines.join('\n'), t('ios.hints.network.title'));
264
+ }
265
+
223
266
  async function runIosClean(projectDir, t) {
224
267
  const steps = [
225
268
  { label: t('ios.clean.step.flutterClean'), cmd: 'flutter clean' },
@@ -247,15 +290,69 @@ async function runReleaseScript(projectDir, args, t) {
247
290
  if (!(await fs.pathExists(scriptPath))) {
248
291
  throw new Error(t('ios.error.noScript'));
249
292
  }
250
- const cmd = `bash "${scriptPath}" ${args.join(' ')}`.trim();
251
- try {
252
- await execAsync(cmd, { cwd: projectDir, maxBuffer: 50 * 1024 * 1024 });
253
- } catch (err) {
254
- const output = [err.stdout, err.stderr, err.message].filter(Boolean).join('\n');
255
- const error = new Error(output || err.message);
256
- error.buildOutput = output;
257
- throw error;
258
- }
293
+
294
+ const isBuildOnly = args.includes('--no-upload');
295
+ const titleKey = isBuildOnly ? 'ios.build.task.building' : 'ios.release.task.building';
296
+ const doneKey = isBuildOnly ? 'ios.build.task.done' : 'ios.release.task.done';
297
+ const failKey = isBuildOnly ? 'ios.build.task.failed' : 'ios.release.task.failed';
298
+
299
+ const spinner = ui.timedSpinner();
300
+ spinner.start(t(titleKey));
301
+
302
+ return new Promise((resolve, reject) => {
303
+ const proc = spawn('bash', [scriptPath, ...args], {
304
+ cwd: projectDir,
305
+ stdio: ['ignore', 'pipe', 'pipe'],
306
+ });
307
+
308
+ let allOutput = '';
309
+ let uploadStage = false;
310
+
311
+ // Trim noise so the spinner message stays readable. Skip empty lines,
312
+ // pure dotted progress bars (e.g. "....."), and lines that are just
313
+ // separators. Show the last meaningful line as the spinner status.
314
+ const isMeaningful = (line) => {
315
+ if (!line) return false;
316
+ if (/^[.\s]+$/.test(line)) return false;
317
+ if (/^=+$/.test(line)) return false;
318
+ return true;
319
+ };
320
+
321
+ const handleChunk = (chunk) => {
322
+ const text = chunk.toString();
323
+ allOutput += text;
324
+
325
+ if (!uploadStage && /Uploading to App Store Connect/i.test(text)) {
326
+ uploadStage = true;
327
+ spinner.message(t('ios.release.task.uploading'));
328
+ return;
329
+ }
330
+
331
+ const lines = text.split('\n').map((l) => l.replace(/\r/g, '').trim()).filter(isMeaningful);
332
+ const lastLine = lines[lines.length - 1];
333
+ if (lastLine) spinner.message(lastLine);
334
+ };
335
+
336
+ proc.stdout.on('data', handleChunk);
337
+ proc.stderr.on('data', handleChunk);
338
+
339
+ proc.on('close', (code) => {
340
+ if (code === 0) {
341
+ spinner.stop(t(doneKey));
342
+ resolve();
343
+ } else {
344
+ spinner.stop(t(failKey), 2);
345
+ const error = new Error(allOutput || `release script exited with code ${code}`);
346
+ error.buildOutput = allOutput;
347
+ reject(error);
348
+ }
349
+ });
350
+
351
+ proc.on('error', (err) => {
352
+ spinner.stop(t(failKey), 2);
353
+ reject(err);
354
+ });
355
+ });
259
356
  }
260
357
 
261
358
  module.exports = {
@@ -280,6 +377,8 @@ module.exports = {
280
377
  getFreeDiskGb,
281
378
  checkDiskSpaceForIosBuild,
282
379
  isXcodeCacheBuildError,
380
+ isPodNetworkError,
283
381
  printBuildFailureHints,
382
+ printPodNetworkHints,
284
383
  runIosClean,
285
384
  };
@@ -1,13 +1,10 @@
1
1
  const { exec } = require('node:child_process');
2
2
  const { promisify } = require('node:util');
3
- const readline = require('node:readline');
4
3
  const kleur = require('kleur');
5
- const oraPackage = require('ora');
6
4
  const ui = require('./ui');
7
5
  const { createTranslator, detectDefaultLanguage } = require('./i18n');
8
6
 
9
7
  const execAsync = promisify(exec);
10
- const ora = oraPackage.default || oraPackage;
11
8
 
12
9
  // Timeout para verificar se uma ferramenta está instalada (15 s é mais que suficiente)
13
10
  const TOOL_CHECK_TIMEOUT = 15_000;
@@ -19,19 +16,6 @@ const MIN_NODE_VERSION = '18.0.0';
19
16
  const MIN_FLUTTER_VERSION = '3.24.0';
20
17
  const MIN_DART_VERSION = '3.5.0';
21
18
 
22
- /**
23
- * Compare two semver strings. Returns true if actual >= required.
24
- */
25
- function meetsMinVersion(actual, required) {
26
- if (!actual || !required) return true;
27
- const parse = (v) => v.replace(/[^0-9.]/g, '').split('.').map(Number);
28
- const [aMaj, aMin, aPat = 0] = parse(actual);
29
- const [rMaj, rMin, rPat = 0] = parse(required);
30
- if (aMaj !== rMaj) return aMaj > rMaj;
31
- if (aMin !== rMin) return aMin > rMin;
32
- return aPat >= rPat;
33
- }
34
-
35
19
  const BASE_CHECKS = [
36
20
  {
37
21
  name: 'Node.js',
@@ -106,7 +90,7 @@ const FIREBASE_CHECKS = [
106
90
  command: 'gcloud --version',
107
91
  required: false,
108
92
  failHint: getGcloudInstallHint(),
109
- waitPrompt: 'Após instalar o gcloud, pressione Enter para verificar novamente...',
93
+ waitPromptKey: 'checks.waitPrompt.gcloud.install',
110
94
  },
111
95
  {
112
96
  name: 'gcloud auth (create-from-scratch)',
@@ -114,7 +98,7 @@ const FIREBASE_CHECKS = [
114
98
  required: false,
115
99
  showVersion: false,
116
100
  failHint: 'gcloud auth login',
117
- waitPrompt: 'Após fazer login com gcloud auth login, pressione Enter para verificar...',
101
+ waitPromptKey: 'checks.waitPrompt.gcloud.auth',
118
102
  },
119
103
  ];
120
104
 
@@ -175,128 +159,71 @@ function extractVersion(stdout, checkName) {
175
159
  return m ? m[0] : raw.slice(0, 20);
176
160
  }
177
161
 
178
- /**
179
- * Wait for the user to press Enter in the terminal.
180
- * Used to pause the flow so the user can install something or authenticate.
181
- */
182
- function waitForUserInput(prompt) {
183
- return new Promise((resolve) => {
184
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
185
- const done = () => { rl.close(); resolve(); };
186
- rl.question(`\n ${kleur.cyan(prompt)}\n `, done);
187
- rl.on('close', resolve);
188
- rl.on('error', resolve);
189
- });
190
- }
191
-
192
162
  async function runSingleCheck(check, options = {}) {
193
163
  const showVersion = check.showVersion !== undefined ? check.showVersion : (options.showVersion !== undefined ? options.showVersion : true);
194
- const silent = options.silent === true;
195
- const t = check.t || createTranslator(check.language || detectDefaultLanguage());
196
- const spinner = silent ? null : ora(t('checks.checking', { name: check.name })).start();
197
164
  let autoInstallFailed = false;
198
165
 
199
166
  try {
200
167
  const { stdout } = await execAsync(check.command, { encoding: 'utf8', timeout: TOOL_CHECK_TIMEOUT });
201
168
  const version = showVersion ? extractVersion(stdout, check.name) : null;
202
- const msg = version
203
- ? t('checks.foundWithVersion', { name: check.name, version })
204
- : t('checks.found', { name: check.name });
205
-
206
- if (!silent) {
207
- // Warn if version is below minimum requirement
208
- if (version && check.minVersion && !meetsMinVersion(version, check.minVersion)) {
209
- spinner.warn(`${msg} ${kleur.yellow(`(mínimo recomendado: ${check.minVersion})`)}`);
210
- } else {
211
- spinner.succeed(msg);
212
- }
213
- }
214
169
  return { ...check, ok: true, version: version || null };
215
170
  } catch (err) {
216
171
  const diagnosis = diagnoseFailure(err);
217
172
  if (check.tryInstall) {
218
- if (!silent) spinner.text = t(check.tryInstallMessageKey || 'setup.flutterfire.installing');
219
173
  try {
220
174
  await execAsync(check.tryInstall, { encoding: 'utf8', timeout: INSTALL_TIMEOUT });
221
175
  const { stdout: retryOut } = await execAsync(check.retryCommand || check.command, { encoding: 'utf8', timeout: TOOL_CHECK_TIMEOUT });
222
176
  const version = showVersion ? extractVersion(retryOut, check.name) : null;
223
- const msg = version
224
- ? t('checks.foundWithVersion', { name: check.name, version: version || 'installed' })
225
- : t('checks.found', { name: check.name });
226
- if (!silent) spinner.succeed(msg);
227
177
  return { ...check, ok: true, version: version || null };
228
178
  } catch {
229
179
  autoInstallFailed = true;
230
180
  }
231
181
  }
232
-
233
- // Guided interactive prompt: show instructions and wait for the user to act.
234
- if (check.waitPrompt && !silent) {
235
- spinner.stop();
236
- if (check.failHint) {
237
- console.log(kleur.yellow(`\n ⚠ ${check.name} não encontrado.\n`));
238
- console.log(kleur.bold(' Execute:\n'));
239
- console.log(kleur.cyan(` ${check.failHint}\n`));
240
- }
241
- await waitForUserInput(check.waitPrompt);
242
- try {
243
- const { stdout: retryOut } = await execAsync(check.command, { encoding: 'utf8', timeout: TOOL_CHECK_TIMEOUT });
244
- const version = showVersion ? extractVersion(retryOut, check.name) : null;
245
- const msg = version
246
- ? t('checks.foundWithVersion', { name: check.name, version })
247
- : t('checks.found', { name: check.name });
248
- spinner.succeed(msg);
249
- return { ...check, ok: true, version: version || null };
250
- } catch {
251
- // Still failing — fall through to report
252
- }
253
- }
254
-
255
- if (!silent) {
256
- if (check.required) {
257
- const detail = autoInstallFailed ? ` — ${t('checks.install.failed')}` : '';
258
- const diagSuffix = diagnosis ? `\n ${kleur.dim(`→ ${t(`checks.diagnostic.${diagnosis}`, { name: check.name })}`)}` : '';
259
- spinner.fail(t('checks.missing', { name: check.name }) + detail + diagSuffix);
260
- } else if (diagnosis) {
261
- spinner.warn(t(`checks.diagnostic.${diagnosis}`, { name: check.name }));
262
- } else {
263
- const hint = !check.waitPrompt && check.failHint ? `\n ${kleur.dim(`→ ${check.failHint}`)}` : '';
264
- if (check.warnMessage) {
265
- spinner.warn(check.warnMessage + hint);
266
- } else if (check.warnMessageKey) {
267
- spinner.warn(t(check.warnMessageKey) + hint);
268
- } else {
269
- spinner.warn(t('checks.notFound', { name: check.name }) + hint);
270
- }
271
- }
272
- }
273
-
274
182
  return { ...check, ok: false, autoInstallFailed, diagnosis };
275
183
  }
276
184
  }
277
185
 
186
+ /**
187
+ * Retry a check after the user installs/auths a missing tool. Used when the
188
+ * check definition has `waitPrompt` — we offer the user to run the install
189
+ * step, then re-check on Enter. Returns true if the recheck succeeded.
190
+ */
191
+ async function retryCheckInteractively(check, t) {
192
+ ui.log.warn(`${check.name} ${t('checks.notFound.short') || 'not found'}`);
193
+ if (check.failHint) {
194
+ ui.log.message(`${t('checks.runHint') || 'Run'}: ${kleur.cyan(check.failHint)}`);
195
+ }
196
+ const proceed = await ui.confirm({
197
+ message: check.waitPrompt,
198
+ initialValue: true,
199
+ });
200
+ if (!proceed) return false;
201
+ try {
202
+ const { stdout } = await execAsync(check.command, { encoding: 'utf8', timeout: TOOL_CHECK_TIMEOUT });
203
+ const version = extractVersion(stdout, check.name);
204
+ ui.log.success(version
205
+ ? `${check.name} — ${version}`
206
+ : check.name);
207
+ return true;
208
+ } catch {
209
+ ui.log.error(`${check.name} ${t('checks.stillMissing') || 'still missing'}`);
210
+ return false;
211
+ }
212
+ }
213
+
278
214
  async function runChecks(checks, title, options = {}) {
279
215
  const t = options.t || createTranslator(options.language || detectDefaultLanguage());
280
- const { showVersion = true, compact = false } = options;
216
+ const { showVersion = true } = options;
281
217
 
282
- if (!compact) {
283
- console.log(kleur.bold(`\n${title}`));
284
- const results = [];
285
- for (const check of checks) {
286
- const result = await runSingleCheck({ ...check, t }, { showVersion });
287
- results.push(result);
288
- }
289
- return results;
290
- }
291
-
292
- // Compact mode: single spinner, show failures only
218
+ // Single spinner over all checks, show failures afterwards. The visual
219
+ // sits inside the clack rail (│) opened by the caller's ui.intro().
293
220
  const { spinnerLabel = title, doneLabel = title } = options;
294
221
  const spinner = ui.spinner();
295
222
  spinner.start(spinnerLabel);
296
223
 
297
224
  const results = [];
298
225
  for (const check of checks) {
299
- results.push(await runSingleCheck({ ...check, t }, { showVersion, silent: true }));
226
+ results.push(await runSingleCheck({ ...check, t }, { showVersion }));
300
227
  }
301
228
 
302
229
  const failures = results.filter((r) => !r.ok);
@@ -316,6 +243,17 @@ async function runChecks(checks, title, options = {}) {
316
243
  }
317
244
 
318
245
  for (const result of failures) {
246
+ // Interactive recovery for checks with a waitPrompt (e.g. gcloud install).
247
+ // Lets the user fix the env without restarting the command.
248
+ const waitPromptText = result.waitPromptKey ? t(result.waitPromptKey) : result.waitPrompt;
249
+ if (waitPromptText) {
250
+ const recovered = await retryCheckInteractively({ ...result, waitPrompt: waitPromptText }, t);
251
+ if (recovered) {
252
+ const idx = results.indexOf(result);
253
+ if (idx >= 0) results[idx] = { ...result, ok: true };
254
+ }
255
+ continue;
256
+ }
319
257
  const hint = result.failHint ? `\n${kleur.dim(`→ ${result.failHint}`)}` : '';
320
258
  if (result.required) {
321
259
  const detail = result.autoInstallFailed ? ` — ${t('checks.install.failed')}` : '';