kasy-cli 1.13.0 → 1.14.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 (120) hide show
  1. package/bin/kasy.js +122 -7
  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 +20 -5
  8. package/lib/commands/new.js +8 -20
  9. package/lib/commands/remove.js +1 -1
  10. package/lib/commands/reset.js +287 -0
  11. package/lib/commands/run.js +24 -17
  12. package/lib/commands/splash.js +3 -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 +85 -16
  26. package/lib/utils/checks.js +4 -105
  27. package/lib/utils/flutter-run.js +173 -0
  28. package/lib/utils/i18n.js +335 -0
  29. package/lib/utils/mobile-identity.js +35 -0
  30. package/lib/utils/ui.js +114 -0
  31. package/package.json +1 -2
  32. package/templates/firebase/README.en.md +1 -1
  33. package/templates/firebase/README.es.md +1 -1
  34. package/templates/firebase/README.md +1 -1
  35. package/templates/firebase/android/app/build.gradle.kts +10 -1
  36. package/templates/firebase/android/app/src/main/AndroidManifest.xml +1 -1
  37. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MainActivity.kt +25 -1
  38. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidget.kt +160 -11
  39. package/templates/firebase/android/app/src/main/res/drawable/widget_add_button.xml +15 -0
  40. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_bg.xml +9 -0
  41. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_inner.xml +12 -0
  42. package/templates/firebase/android/app/src/main/res/drawable/widget_plan_pill_bg.xml +5 -0
  43. package/templates/firebase/android/app/src/main/res/drawable/widget_preview_image.xml +17 -0
  44. package/templates/firebase/android/app/src/main/res/drawable/widget_pro_pill_bg.xml +5 -0
  45. package/templates/firebase/android/app/src/main/res/layout/widget_loading.xml +8 -0
  46. package/templates/firebase/android/app/src/main/res/layout/widget_preview.xml +46 -0
  47. package/templates/firebase/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  48. package/templates/firebase/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  49. package/templates/firebase/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  50. package/templates/firebase/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  51. package/templates/firebase/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  52. package/templates/firebase/android/app/src/main/res/xml/mywidget_info.xml +9 -3
  53. package/templates/firebase/assets/images/favicon.png +0 -0
  54. package/templates/firebase/assets/images/icon.png +0 -0
  55. package/templates/firebase/firestore.indexes.json +10 -0
  56. package/templates/firebase/functions/src/core/data/entities/user_device_entity.ts +3 -0
  57. package/templates/firebase/functions/src/core/data/repositories/user_device_repository.ts +17 -1
  58. package/templates/firebase/functions/src/index.ts +1 -0
  59. package/templates/firebase/functions/src/notifications/device_triggers.ts +58 -0
  60. package/templates/firebase/ios/HomeWidgetExtension/MyWidget.swift +116 -33
  61. package/templates/firebase/ios/Runner/AppDelegate.swift +17 -1
  62. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png +0 -0
  63. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png +0 -0
  64. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png +0 -0
  65. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png +0 -0
  66. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png +0 -0
  67. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png +0 -0
  68. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png +0 -0
  69. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png +0 -0
  70. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png +0 -0
  71. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png +0 -0
  72. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png +0 -0
  73. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png +0 -0
  74. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png +0 -0
  75. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png +0 -0
  76. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png +0 -0
  77. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png +0 -0
  78. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png +0 -0
  79. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png +0 -0
  80. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png +0 -0
  81. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png +0 -0
  82. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png +0 -0
  83. package/templates/firebase/ios/Runner/Info.plist +2 -2
  84. package/templates/firebase/ios/Runner/es.lproj/InfoPlist.strings +1 -1
  85. package/templates/firebase/ios/Runner/pt-BR.lproj/InfoPlist.strings +1 -1
  86. package/templates/firebase/ios/Runner/pt.lproj/InfoPlist.strings +1 -1
  87. package/templates/firebase/lib/components/kasy_button.dart +8 -0
  88. package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +18 -0
  89. package/templates/firebase/lib/core/home_widgets/home_widget_mywidget_service.dart +67 -19
  90. package/templates/firebase/lib/core/home_widgets/home_widget_service.dart +22 -8
  91. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +16 -4
  92. package/templates/firebase/lib/core/states/components/maybeshow_component.dart +4 -8
  93. package/templates/firebase/lib/core/states/user_state_notifier.dart +13 -1
  94. package/templates/firebase/lib/features/home/home_page.dart +0 -6
  95. package/templates/firebase/lib/features/notifications/api/device_api.dart +57 -0
  96. package/templates/firebase/lib/features/notifications/providers/models/notification.dart +11 -1
  97. package/templates/firebase/lib/features/notifications/repositories/device_repository.dart +9 -0
  98. package/templates/firebase/lib/features/notifications/repositories/notifications_repository.dart +1 -4
  99. package/templates/firebase/lib/features/notifications/shared/att_permission.dart +28 -8
  100. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +16 -1
  101. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +44 -11
  102. package/templates/firebase/lib/features/settings/ui/components/admin/admin_bottom_sheet.dart +31 -29
  103. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +21 -5
  104. package/templates/firebase/lib/i18n/en.i18n.json +4 -1
  105. package/templates/firebase/lib/i18n/es.i18n.json +4 -1
  106. package/templates/firebase/lib/i18n/pt.i18n.json +4 -1
  107. package/templates/firebase/pubspec.yaml +6 -1
  108. package/templates/firebase/test/features/notifications/data/device_api_fake.dart +9 -0
  109. package/templates/firebase/web/favicon.png +0 -0
  110. package/templates/firebase/web/icons/Icon-192.png +0 -0
  111. package/templates/firebase/web/icons/Icon-512.png +0 -0
  112. package/templates/firebase/web/icons/Icon-maskable-192.png +0 -0
  113. package/templates/firebase/web/icons/Icon-maskable-512.png +0 -0
  114. package/templates/firebase/web/index.html +3 -0
  115. package/templates/firebase/web/manifest.json +3 -3
  116. package/templates/firebase/assets/images/app_icon.png +0 -0
  117. package/templates/firebase/assets/images/onboarding/img1.jpg +0 -0
  118. package/templates/firebase/assets/images/onboarding/onboarding.png +0 -0
  119. package/templates/firebase/lib/core/states/components/maybe_ask_biometric_setup.dart +0 -88
  120. package/templates/firebase/lib/features/notifications/shared/notification_permission_bottom_sheet.dart +0 -144
@@ -444,7 +444,7 @@ async function runRemove(module, options = {}) {
444
444
  'revenuecat', 'analytics', 'sentry', 'onboarding', 'llm_chat', 'feedback',
445
445
  ].includes(normalized);
446
446
  if (needsBuildRunner) {
447
- const spinner = ui.spinner();
447
+ const spinner = ui.timedSpinner();
448
448
  spinner.start(t('remove.buildRunner'));
449
449
  try {
450
450
  await execAsync(
@@ -0,0 +1,287 @@
1
+ const path = require('node:path');
2
+ const { spawnSync } = require('node:child_process');
3
+ const fs = require('fs-extra');
4
+ const kleur = require('kleur');
5
+ const ui = require('../utils/ui');
6
+ const { createTranslator, detectDefaultLanguage } = require('../utils/i18n');
7
+ const { printCompactHeader } = require('../utils/brand');
8
+ const { readBundleId, readPackageName } = require('../utils/mobile-identity');
9
+ const { spawnFlutterWithSpinner } = require('../utils/flutter-run');
10
+
11
+ function runCmd(cmd, args) {
12
+ const res = spawnSync(cmd, args, { encoding: 'utf8' });
13
+ return {
14
+ code: res.status,
15
+ stdout: (res.stdout || '').trim(),
16
+ stderr: (res.stderr || '').trim(),
17
+ };
18
+ }
19
+
20
+ async function listFlutterDevices(projectDir) {
21
+ const res = spawnSync('flutter', ['devices', '--machine'], {
22
+ cwd: projectDir,
23
+ encoding: 'utf8',
24
+ });
25
+ if (res.status !== 0) return [];
26
+ try {
27
+ const parsed = JSON.parse(res.stdout);
28
+ return Array.isArray(parsed) ? parsed : [];
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ function classifyTarget(device) {
35
+ const platform = (device.targetPlatform || '').toLowerCase();
36
+ if (platform === 'ios') {
37
+ return device.emulator ? 'ios-simulator' : 'ios-device';
38
+ }
39
+ if (platform.startsWith('android')) {
40
+ return device.emulator ? 'android-emulator' : 'android-device';
41
+ }
42
+ if (platform.startsWith('web')) {
43
+ return 'web';
44
+ }
45
+ return 'unknown';
46
+ }
47
+
48
+ function filterDevices(devices, options) {
49
+ return devices.filter((d) => {
50
+ const kind = classifyTarget(d);
51
+ if (options.device && d.id === options.device) return true;
52
+ if (options.ios && (kind === 'ios-simulator' || kind === 'ios-device')) {
53
+ return true;
54
+ }
55
+ if (
56
+ options.android &&
57
+ (kind === 'android-emulator' || kind === 'android-device')
58
+ ) {
59
+ return true;
60
+ }
61
+ if (options.web && kind === 'web') return true;
62
+ if (!options.ios && !options.android && !options.web && !options.device) {
63
+ return true;
64
+ }
65
+ return false;
66
+ });
67
+ }
68
+
69
+ async function pickDevice(devices, t) {
70
+ if (devices.length === 0) return null;
71
+ if (devices.length === 1) return devices[0];
72
+ const choice = await ui.select({
73
+ message: t('reset.prompt.pickDevice'),
74
+ options: devices.map((d) => ({
75
+ value: d.id,
76
+ label: `${d.name} ${kleur.dim(`(${classifyTarget(d)})`)}`,
77
+ })),
78
+ });
79
+ return devices.find((d) => d.id === choice) || null;
80
+ }
81
+
82
+ function resetIosSimulator(device, bundleId, t) {
83
+ ui.log.message(kleur.dim(`xcrun simctl uninstall ${device.id} ${bundleId}`));
84
+ const res = runCmd('xcrun', ['simctl', 'uninstall', device.id, bundleId]);
85
+ if (res.code !== 0) {
86
+ // simctl returns 0 even when app wasn't installed; non-zero is a real error
87
+ ui.log.warn(res.stderr || t('reset.warn.iosUninstallFailed'));
88
+ return false;
89
+ }
90
+ ui.log.success(t('reset.success.uninstalled'));
91
+ return true;
92
+ }
93
+
94
+ function resetIosDevice(device, bundleId, t) {
95
+ // Try Apple's devicectl (ships with Xcode 15+). Falls back to a manual
96
+ // instruction when devicectl isn't available or the uninstall fails.
97
+ const probe = runCmd('xcrun', ['devicectl', '--version']);
98
+ if (probe.code !== 0) {
99
+ noticeIosPhysical(t);
100
+ return false;
101
+ }
102
+ ui.log.message(
103
+ kleur.dim(
104
+ `xcrun devicectl device uninstall app --device ${device.id} ${bundleId}`
105
+ )
106
+ );
107
+ const res = runCmd('xcrun', [
108
+ 'devicectl',
109
+ 'device',
110
+ 'uninstall',
111
+ 'app',
112
+ '--device',
113
+ device.id,
114
+ bundleId,
115
+ ]);
116
+ if (res.code !== 0) {
117
+ ui.log.warn(res.stderr || t('reset.warn.iosDeviceUninstallFailed'));
118
+ noticeIosPhysical(t);
119
+ return false;
120
+ }
121
+ ui.log.success(t('reset.success.uninstalled'));
122
+ return true;
123
+ }
124
+
125
+ function resetAndroid(device, packageName, t) {
126
+ ui.log.message(kleur.dim(`adb -s ${device.id} uninstall ${packageName}`));
127
+ const res = runCmd('adb', ['-s', device.id, 'uninstall', packageName]);
128
+ // adb prints "Success" or "Failure [DELETE_FAILED_INTERNAL_ERROR]" / "not installed"
129
+ const out = `${res.stdout}\n${res.stderr}`.toLowerCase();
130
+ if (out.includes('success')) {
131
+ ui.log.success(t('reset.success.uninstalled'));
132
+ return true;
133
+ }
134
+ if (out.includes('not installed')) {
135
+ ui.log.message(kleur.dim(`– ${t('reset.info.notInstalled')}`));
136
+ return true;
137
+ }
138
+ ui.log.warn(res.stdout || res.stderr || t('reset.warn.androidUninstallFailed'));
139
+ return false;
140
+ }
141
+
142
+ function noticeIosPhysical(t) {
143
+ ui.log.warn(t('reset.warn.iosDeviceManual'));
144
+ }
145
+
146
+ function isXcodeRunning() {
147
+ const res = spawnSync('pgrep', ['-x', 'Xcode'], { encoding: 'utf8' });
148
+ return res.status === 0 && (res.stdout || '').trim().length > 0;
149
+ }
150
+
151
+ async function ensureXcodeClosed(t) {
152
+ if (!isXcodeRunning()) return true;
153
+ const choice = await ui.select({
154
+ message: t('reset.prompt.xcodeOpen'),
155
+ options: [
156
+ { value: 'close', label: t('reset.prompt.xcodeOpen.close') },
157
+ { value: 'skip', label: t('reset.prompt.xcodeOpen.skip') },
158
+ { value: 'cancel', label: t('reset.prompt.xcodeOpen.cancel') },
159
+ ],
160
+ initialValue: 'close',
161
+ });
162
+ if (choice === 'cancel') return false;
163
+ if (choice === 'close') {
164
+ spawnSync('osascript', ['-e', 'tell application "Xcode" to quit'], {
165
+ encoding: 'utf8',
166
+ });
167
+ // Give Xcode a moment to release the debug session before we proceed.
168
+ await new Promise((resolve) => setTimeout(resolve, 1500));
169
+ ui.log.message(kleur.dim(t('reset.info.xcodeClosed')));
170
+ }
171
+ return true;
172
+ }
173
+
174
+ function noticeWeb(t) {
175
+ ui.log.warn(t('reset.warn.webIncognito'));
176
+ }
177
+
178
+ function runFlutterOnDevice(device, projectDir, t) {
179
+ return spawnFlutterWithSpinner(['run', '-d', device.id], projectDir, t);
180
+ }
181
+
182
+ async function runReset(directory, options = {}) {
183
+ const t = createTranslator(options.language || detectDefaultLanguage());
184
+ const projectDir = path.resolve(directory || '.');
185
+
186
+ if (!(await fs.pathExists(path.join(projectDir, 'pubspec.yaml')))) {
187
+ throw new Error(t('reset.error.notFlutterProject'));
188
+ }
189
+
190
+ printCompactHeader(t);
191
+ ui.intro(t('reset.title'));
192
+
193
+ const [bundleId, packageName] = await Promise.all([
194
+ readBundleId(projectDir),
195
+ readPackageName(projectDir),
196
+ ]);
197
+ if (!bundleId && !packageName) {
198
+ throw new Error(t('reset.error.noIdentifier'));
199
+ }
200
+
201
+ ui.log.message(`${kleur.dim('iOS bundle id:')} ${bundleId || kleur.dim('—')}`);
202
+ ui.log.message(`${kleur.dim('Android package:')} ${packageName || kleur.dim('—')}`);
203
+
204
+ const scanSpinner = ui.spinner();
205
+ scanSpinner.start(t('reset.scanning'));
206
+ const allDevices = await listFlutterDevices(projectDir);
207
+ const devices = filterDevices(allDevices, options);
208
+ scanSpinner.stop(t('reset.scanning'));
209
+
210
+ if (devices.length === 0) {
211
+ ui.log.warn(t('reset.warn.noDevices'));
212
+ return;
213
+ }
214
+
215
+ const target = await pickDevice(devices, t);
216
+ if (!target) {
217
+ ui.log.warn(t('reset.warn.nothingSelected'));
218
+ return;
219
+ }
220
+
221
+ const kind = classifyTarget(target);
222
+
223
+ if (kind === 'ios-device') {
224
+ const proceed = await ensureXcodeClosed(t);
225
+ if (!proceed) {
226
+ ui.outro(t('reset.outro.cancelled'));
227
+ return;
228
+ }
229
+ }
230
+
231
+ ui.log.step(kleur.bold(`${t('reset.resetting')}: ${target.name}`));
232
+
233
+ let didReset = false;
234
+ switch (kind) {
235
+ case 'ios-simulator':
236
+ if (!bundleId) {
237
+ ui.log.error(t('reset.error.noBundleId'));
238
+ break;
239
+ }
240
+ didReset = resetIosSimulator(target, bundleId, t);
241
+ break;
242
+ case 'ios-device':
243
+ if (!bundleId) {
244
+ ui.log.error(t('reset.error.noBundleId'));
245
+ break;
246
+ }
247
+ didReset = resetIosDevice(target, bundleId, t);
248
+ break;
249
+ case 'android-emulator':
250
+ case 'android-device':
251
+ if (!packageName) {
252
+ ui.log.error(t('reset.error.noPackageName'));
253
+ break;
254
+ }
255
+ didReset = resetAndroid(target, packageName, t);
256
+ break;
257
+ case 'web':
258
+ noticeWeb(t);
259
+ break;
260
+ default:
261
+ ui.log.warn(`${t('reset.warn.unknownPlatform')}: ${kind}`);
262
+ }
263
+
264
+ if (!didReset) {
265
+ ui.outro(t('reset.outro.skipped'));
266
+ return;
267
+ }
268
+
269
+ if (options.reinstall === false) {
270
+ ui.outro(t('reset.outro.uninstalledOnly'));
271
+ return;
272
+ }
273
+
274
+ const proceed = await ui.confirm({
275
+ message: t('reset.prompt.reinstallNow'),
276
+ initialValue: true,
277
+ });
278
+ if (!proceed) {
279
+ ui.outro(t('reset.outro.uninstalledOnly'));
280
+ return;
281
+ }
282
+
283
+ ui.log.step(kleur.bold(t('reset.reinstalling')));
284
+ await runFlutterOnDevice(target, projectDir, t);
285
+ }
286
+
287
+ module.exports = { runReset };
@@ -1,9 +1,9 @@
1
- const { spawn } = require('node:child_process');
2
1
  const path = require('node:path');
3
2
  const fs = require('fs-extra');
4
3
  const kleur = require('kleur');
5
4
  const { createTranslator, detectDefaultLanguage } = require('../utils/i18n');
6
5
  const { printCompactHeader } = require('../utils/brand');
6
+ const { spawnFlutterWithSpinner } = require('../utils/flutter-run');
7
7
 
8
8
  /**
9
9
  * Read dart-define args from .vscode/launch.json.
@@ -54,25 +54,32 @@ async function runRun(directory, options = {}) {
54
54
 
55
55
  const args = ['run', ...deviceArgs, ...dartDefines];
56
56
 
57
+ const envDefine = dartDefines.find((a) => a.startsWith('--dart-define=ENV='));
58
+ const envValue = envDefine ? envDefine.split('=').pop() : null;
59
+ const deviceLabel = options.web
60
+ ? 'chrome'
61
+ : options.ios
62
+ ? 'ios'
63
+ : options.android
64
+ ? 'android'
65
+ : options.device || null;
66
+ const summaryParts = [];
67
+ if (envValue) summaryParts.push(`ENV=${envValue}`);
68
+ if (deviceLabel) summaryParts.push(`device: ${deviceLabel}`);
69
+ const summary = summaryParts.length ? ` (${summaryParts.join(', ')})` : '';
70
+
57
71
  printCompactHeader(t);
58
- console.log(kleur.bold(`${t('run.launching')}`));
59
- console.log(kleur.dim(` flutter ${args.join(' ')}`));
72
+ console.log(kleur.bold(`${t('run.launching')}${summary}`));
60
73
  console.log(kleur.dim(` ✦ ${t('run.updateHint.prefix')} `) + kleur.cyan('kasy update') + kleur.dim(` ${t('run.updateHint.suffix')}\n`));
61
74
 
62
- return new Promise((resolve, reject) => {
63
- const proc = spawn('flutter', args, { cwd: projectDir, stdio: 'inherit' });
64
- proc.on('close', (code) => {
65
- if (code === 0) resolve();
66
- else reject(new Error(`flutter run exited with code ${code}`));
67
- });
68
- proc.on('error', (err) => {
69
- if (err.code === 'ENOENT') {
70
- reject(new Error(t('run.error.flutterNotFound')));
71
- } else {
72
- reject(err);
73
- }
74
- });
75
- });
75
+ try {
76
+ await spawnFlutterWithSpinner(args, projectDir, t);
77
+ } catch (err) {
78
+ if (err.code === 'ENOENT') {
79
+ throw new Error(t('run.error.flutterNotFound'));
80
+ }
81
+ throw err;
82
+ }
76
83
  }
77
84
 
78
85
  module.exports = { runRun };
@@ -76,9 +76,8 @@ async function assertKasyProject(projectDir, t) {
76
76
 
77
77
  /**
78
78
  * @param {string} flagValue
79
- * @param {string} role 'light' | 'dark'
80
79
  */
81
- function resolveInputPath(flagValue, role) {
80
+ function resolveInputPath(flagValue) {
82
81
  if (!flagValue) return null;
83
82
  const expanded = flagValue.startsWith('~')
84
83
  ? path.join(require('node:os').homedir(), flagValue.slice(1))
@@ -99,8 +98,8 @@ async function runSplash(projectDir, options = {}) {
99
98
 
100
99
  await assertKasyProject(projectDir, t);
101
100
 
102
- const lightPath = resolveInputPath(options.light, 'light');
103
- const darkPath = resolveInputPath(options.dark, 'dark');
101
+ const lightPath = resolveInputPath(options.light);
102
+ const darkPath = resolveInputPath(options.dark);
104
103
 
105
104
  if (!lightPath || !darkPath) {
106
105
  ui.log.error(t('splash.error.bothRequired'));
@@ -374,7 +374,7 @@ async function runUpdate(module, options = {}) {
374
374
 
375
375
  // build_runner (only for modules that generate code)
376
376
  if (NEEDS_BUILD_RUNNER.includes(normalized)) {
377
- const spinner = ui.spinner();
377
+ const spinner = ui.timedSpinner();
378
378
  spinner.start(t('update.buildRunner'));
379
379
  try {
380
380
  await execAsync(
@@ -1,4 +1,4 @@
1
- # AppFirebase
1
+ # Kasy App
2
2
 
3
3
  Flutter app com backend API REST — 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
@@ -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.png
118
+ image_dark: assets/images/splash_logo_dark.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.png
120
+ image_dark: assets/images/splash_logo_dark.png