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.
- package/bin/kasy.js +122 -7
- package/lib/commands/add.js +2 -2
- package/lib/commands/codemagic.js +11 -4
- package/lib/commands/deploy.js +3 -3
- package/lib/commands/favicon.js +115 -0
- package/lib/commands/icon.js +143 -0
- package/lib/commands/ios.js +20 -5
- package/lib/commands/new.js +8 -20
- package/lib/commands/remove.js +1 -1
- package/lib/commands/reset.js +287 -0
- package/lib/commands/run.js +24 -17
- package/lib/commands/splash.js +3 -4
- package/lib/commands/update.js +1 -1
- package/lib/scaffold/backends/api/patch/README.md +1 -1
- package/lib/scaffold/backends/api/patch/android/app/src/main/AndroidManifest.xml +1 -1
- package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +53 -0
- package/lib/scaffold/backends/api/pubspec.yaml.tpl +11 -1
- package/lib/scaffold/backends/firebase/tokens.js +2 -2
- package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +8 -2
- package/lib/scaffold/backends/supabase/migrations/20240101000011_dedupe_device_tokens.sql +34 -0
- package/lib/scaffold/backends/supabase/patch/README.md +1 -1
- package/lib/scaffold/backends/supabase/patch/android/app/src/main/AndroidManifest.xml +1 -1
- package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +43 -0
- package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +11 -1
- package/lib/utils/apple-release.js +85 -16
- package/lib/utils/checks.js +4 -105
- package/lib/utils/flutter-run.js +173 -0
- package/lib/utils/i18n.js +335 -0
- package/lib/utils/mobile-identity.js +35 -0
- package/lib/utils/ui.js +114 -0
- package/package.json +1 -2
- package/templates/firebase/README.en.md +1 -1
- package/templates/firebase/README.es.md +1 -1
- package/templates/firebase/README.md +1 -1
- package/templates/firebase/android/app/build.gradle.kts +10 -1
- package/templates/firebase/android/app/src/main/AndroidManifest.xml +1 -1
- package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MainActivity.kt +25 -1
- package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidget.kt +160 -11
- package/templates/firebase/android/app/src/main/res/drawable/widget_add_button.xml +15 -0
- package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_bg.xml +9 -0
- package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_inner.xml +12 -0
- package/templates/firebase/android/app/src/main/res/drawable/widget_plan_pill_bg.xml +5 -0
- package/templates/firebase/android/app/src/main/res/drawable/widget_preview_image.xml +17 -0
- package/templates/firebase/android/app/src/main/res/drawable/widget_pro_pill_bg.xml +5 -0
- package/templates/firebase/android/app/src/main/res/layout/widget_loading.xml +8 -0
- package/templates/firebase/android/app/src/main/res/layout/widget_preview.xml +46 -0
- package/templates/firebase/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/templates/firebase/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/templates/firebase/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/templates/firebase/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/templates/firebase/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/templates/firebase/android/app/src/main/res/xml/mywidget_info.xml +9 -3
- package/templates/firebase/assets/images/favicon.png +0 -0
- package/templates/firebase/assets/images/icon.png +0 -0
- package/templates/firebase/firestore.indexes.json +10 -0
- package/templates/firebase/functions/src/core/data/entities/user_device_entity.ts +3 -0
- package/templates/firebase/functions/src/core/data/repositories/user_device_repository.ts +17 -1
- package/templates/firebase/functions/src/index.ts +1 -0
- package/templates/firebase/functions/src/notifications/device_triggers.ts +58 -0
- package/templates/firebase/ios/HomeWidgetExtension/MyWidget.swift +116 -33
- package/templates/firebase/ios/Runner/AppDelegate.swift +17 -1
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png +0 -0
- package/templates/firebase/ios/Runner/Info.plist +2 -2
- package/templates/firebase/ios/Runner/es.lproj/InfoPlist.strings +1 -1
- package/templates/firebase/ios/Runner/pt-BR.lproj/InfoPlist.strings +1 -1
- package/templates/firebase/ios/Runner/pt.lproj/InfoPlist.strings +1 -1
- package/templates/firebase/lib/components/kasy_button.dart +8 -0
- package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +18 -0
- package/templates/firebase/lib/core/home_widgets/home_widget_mywidget_service.dart +67 -19
- package/templates/firebase/lib/core/home_widgets/home_widget_service.dart +22 -8
- package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +16 -4
- package/templates/firebase/lib/core/states/components/maybeshow_component.dart +4 -8
- package/templates/firebase/lib/core/states/user_state_notifier.dart +13 -1
- package/templates/firebase/lib/features/home/home_page.dart +0 -6
- package/templates/firebase/lib/features/notifications/api/device_api.dart +57 -0
- package/templates/firebase/lib/features/notifications/providers/models/notification.dart +11 -1
- package/templates/firebase/lib/features/notifications/repositories/device_repository.dart +9 -0
- package/templates/firebase/lib/features/notifications/repositories/notifications_repository.dart +1 -4
- package/templates/firebase/lib/features/notifications/shared/att_permission.dart +28 -8
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +16 -1
- package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +44 -11
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_bottom_sheet.dart +31 -29
- package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +21 -5
- package/templates/firebase/lib/i18n/en.i18n.json +4 -1
- package/templates/firebase/lib/i18n/es.i18n.json +4 -1
- package/templates/firebase/lib/i18n/pt.i18n.json +4 -1
- package/templates/firebase/pubspec.yaml +6 -1
- package/templates/firebase/test/features/notifications/data/device_api_fake.dart +9 -0
- package/templates/firebase/web/favicon.png +0 -0
- package/templates/firebase/web/icons/Icon-192.png +0 -0
- package/templates/firebase/web/icons/Icon-512.png +0 -0
- package/templates/firebase/web/icons/Icon-maskable-192.png +0 -0
- package/templates/firebase/web/icons/Icon-maskable-512.png +0 -0
- package/templates/firebase/web/index.html +3 -0
- package/templates/firebase/web/manifest.json +3 -3
- package/templates/firebase/assets/images/app_icon.png +0 -0
- package/templates/firebase/assets/images/onboarding/img1.jpg +0 -0
- package/templates/firebase/assets/images/onboarding/onboarding.png +0 -0
- package/templates/firebase/lib/core/states/components/maybe_ask_biometric_setup.dart +0 -88
- package/templates/firebase/lib/features/notifications/shared/notification_permission_bottom_sheet.dart +0 -144
package/lib/commands/remove.js
CHANGED
|
@@ -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.
|
|
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 };
|
package/lib/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
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 };
|
package/lib/commands/splash.js
CHANGED
|
@@ -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
|
|
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
|
|
103
|
-
const darkPath = resolveInputPath(options.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'));
|
package/lib/commands/update.js
CHANGED
|
@@ -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.
|
|
377
|
+
const spinner = ui.timedSpinner();
|
|
378
378
|
spinner.start(t('update.buildRunner'));
|
|
379
379
|
try {
|
|
380
380
|
await execAsync(
|
|
@@ -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/
|
|
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 :
|
|
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 = '
|
|
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();
|
|
@@ -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/
|
|
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
|