@akanjs/devkit 2.3.11-rc.7 → 2.3.11-rc.8
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/akanConfig/akanConfig.ts +1 -3
- package/capacitorApp.ts +89 -14
- package/executors.ts +26 -0
- package/firebaseMessagingSw.test.ts +66 -0
- package/firebaseMessagingSw.ts +74 -0
- package/index.ts +1 -0
- package/lint/no-throw-raw-error.grit +40 -0
- package/package.json +2 -2
- package/scanInfo.ts +7 -0
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -171,9 +171,7 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
171
171
|
targets: rawTargets,
|
|
172
172
|
indexPath: _indexPath,
|
|
173
173
|
...rawMobile
|
|
174
|
-
} = (mobile ?? {}) as DeepPartial<AkanMobileConfig> & {
|
|
175
|
-
indexPath?: unknown;
|
|
176
|
-
};
|
|
174
|
+
} = (mobile ?? {}) as DeepPartial<AkanMobileConfig> & { indexPath?: unknown };
|
|
177
175
|
const appName = rawMobile.appName ?? this.app.name;
|
|
178
176
|
const appId = rawMobile.appId ?? `com.${this.app.name}.app`;
|
|
179
177
|
const version = rawMobile.version ?? "0.0.1";
|
package/capacitorApp.ts
CHANGED
|
@@ -26,6 +26,7 @@ interface RunIosConfig extends RunConfig {
|
|
|
26
26
|
interface PrepareConfig extends RunConfig {}
|
|
27
27
|
|
|
28
28
|
type MobileCommandEnv = Record<string, string | undefined>;
|
|
29
|
+
type IosApnsEnvironment = "development" | "production";
|
|
29
30
|
|
|
30
31
|
export type IosRunTargetKind = "device" | "simulator";
|
|
31
32
|
export interface IosRunTarget {
|
|
@@ -130,6 +131,9 @@ const asString = (value: unknown) => (typeof value === "string" ? value : undefi
|
|
|
130
131
|
|
|
131
132
|
const firstString = (...values: unknown[]) => values.find((value): value is string => typeof value === "string");
|
|
132
133
|
|
|
134
|
+
const resolveIosApnsEnvironment = ({ operation }: Pick<RunConfig, "operation" | "env">): IosApnsEnvironment =>
|
|
135
|
+
operation === "release" ? "production" : "development";
|
|
136
|
+
|
|
133
137
|
const scoreIosDeviceTarget = (target: IosRunTarget) => {
|
|
134
138
|
const state = target.state?.toLowerCase() ?? "";
|
|
135
139
|
return (state.includes("available") ? 4 : 0) + (state.includes("wired") ? 2 : 0) + (state.includes("paired") ? 1 : 0);
|
|
@@ -486,6 +490,10 @@ export function materializeCapacitorConfig(
|
|
|
486
490
|
const experimentalConfig = isRecord(experimental) ? experimental : undefined;
|
|
487
491
|
const pluginsConfig = isRecord(plugins) ? plugins : {};
|
|
488
492
|
const keyboardPluginConfig = isRecord(pluginsConfig.Keyboard) ? pluginsConfig.Keyboard : {};
|
|
493
|
+
const pushNotificationsPluginConfig = isRecord(pluginsConfig.PushNotifications)
|
|
494
|
+
? pluginsConfig.PushNotifications
|
|
495
|
+
: {};
|
|
496
|
+
const usesPushNotifications = target.permissions?.includes("push") ?? false;
|
|
489
497
|
const config: CapacitorConfig = {
|
|
490
498
|
...capacitorConfig,
|
|
491
499
|
appId,
|
|
@@ -494,6 +502,14 @@ export function materializeCapacitorConfig(
|
|
|
494
502
|
plugins: {
|
|
495
503
|
CapacitorCookies: { enabled: true },
|
|
496
504
|
...pluginsConfig,
|
|
505
|
+
...(usesPushNotifications || isRecord(pluginsConfig.PushNotifications)
|
|
506
|
+
? {
|
|
507
|
+
PushNotifications: {
|
|
508
|
+
...(usesPushNotifications ? { presentationOptions: ["badge", "sound", "alert"] } : {}),
|
|
509
|
+
...pushNotificationsPluginConfig,
|
|
510
|
+
},
|
|
511
|
+
}
|
|
512
|
+
: {}),
|
|
497
513
|
Keyboard: {
|
|
498
514
|
resize: "none",
|
|
499
515
|
...keyboardPluginConfig,
|
|
@@ -587,7 +603,7 @@ export class CapacitorApp {
|
|
|
587
603
|
await this.#prepareTargetAssets();
|
|
588
604
|
await this.#prepareExternalFiles("ios");
|
|
589
605
|
await this.#applyIosMetadata();
|
|
590
|
-
await this.#applyPermissions();
|
|
606
|
+
await this.#applyPermissions({ operation, env });
|
|
591
607
|
await this.#applyDeepLinks("ios", { operation, env });
|
|
592
608
|
await this.project.commit();
|
|
593
609
|
await this.#generateAssets({ operation, env });
|
|
@@ -749,7 +765,7 @@ export class CapacitorApp {
|
|
|
749
765
|
await this.#prepareTargetAssets();
|
|
750
766
|
await this.#prepareExternalFiles("android");
|
|
751
767
|
await this.#applyAndroidMetadata();
|
|
752
|
-
await this.#applyPermissions();
|
|
768
|
+
await this.#applyPermissions({ operation, env });
|
|
753
769
|
await this.#applyDeepLinks("android", { operation, env });
|
|
754
770
|
await this.project.commit();
|
|
755
771
|
await this.#generateAssets({ operation, env });
|
|
@@ -975,12 +991,12 @@ export class CapacitorApp {
|
|
|
975
991
|
await this.project.android.setVersionCode(this.target.buildNum);
|
|
976
992
|
await this.project.android.setAppName(this.target.appName);
|
|
977
993
|
}
|
|
978
|
-
async #applyPermissions() {
|
|
994
|
+
async #applyPermissions({ operation, env }: Pick<RunConfig, "operation" | "env">) {
|
|
979
995
|
for (const permission of this.target.permissions ?? []) {
|
|
980
996
|
if (permission === "camera") await this.addCamera();
|
|
981
997
|
else if (permission === "contacts") await this.addContact();
|
|
982
998
|
else if (permission === "location") await this.addLocation();
|
|
983
|
-
else if (permission === "push") await this.addPush();
|
|
999
|
+
else if (permission === "push") await this.addPush({ operation, env });
|
|
984
1000
|
}
|
|
985
1001
|
}
|
|
986
1002
|
async #applyDeepLinks(platform: "ios" | "android", { operation, env }: Pick<RunConfig, "operation" | "env">) {
|
|
@@ -996,7 +1012,7 @@ export class CapacitorApp {
|
|
|
996
1012
|
});
|
|
997
1013
|
await this.#setUrlSchemesInIos(schemes);
|
|
998
1014
|
}
|
|
999
|
-
if (domains.length > 0) await this.#setAssociatedDomainsInIos(domains);
|
|
1015
|
+
if (domains.length > 0) await this.#setAssociatedDomainsInIos(domains, { operation, env });
|
|
1000
1016
|
return;
|
|
1001
1017
|
}
|
|
1002
1018
|
if (platform === "android") {
|
|
@@ -1103,12 +1119,58 @@ export class CapacitorApp {
|
|
|
1103
1119
|
this.#setPermissionsInAndroid(["ACCESS_COARSE_LOCATION", "ACCESS_FINE_LOCATION"]);
|
|
1104
1120
|
this.#setFeaturesInAndroid(["android.hardware.location.gps"]);
|
|
1105
1121
|
}
|
|
1106
|
-
async addPush() {
|
|
1122
|
+
async addPush({ operation, env }: Pick<RunConfig, "operation" | "env">) {
|
|
1107
1123
|
await this.#setPermissionInIos({
|
|
1108
1124
|
userNotificationsUsageDescription: "$(PRODUCT_NAME) uses notifications to keep you updated.",
|
|
1109
1125
|
});
|
|
1126
|
+
await Promise.all([
|
|
1127
|
+
this.project.ios.updateInfoPlist(this.iosTargetName, "Debug", { UIBackgroundModes: ["remote-notification"] }),
|
|
1128
|
+
this.project.ios.updateInfoPlist(this.iosTargetName, "Release", { UIBackgroundModes: ["remote-notification"] }),
|
|
1129
|
+
this.#writeEntitlementsInIos(this.target.deepLinks?.domains ?? [], { operation, env }),
|
|
1130
|
+
this.#ensurePushAppDelegateInIos(),
|
|
1131
|
+
]);
|
|
1110
1132
|
this.#setPermissionsInAndroid(["POST_NOTIFICATIONS"]);
|
|
1111
1133
|
}
|
|
1134
|
+
async #ensurePushAppDelegateInIos() {
|
|
1135
|
+
const appDelegatePath = path.join(this.app.cwdPath, this.iosProjectPath, "App/AppDelegate.swift");
|
|
1136
|
+
if (!(await Bun.file(appDelegatePath).exists())) return;
|
|
1137
|
+
|
|
1138
|
+
const editor = await FileEditor.create(appDelegatePath);
|
|
1139
|
+
let content = editor.getContent();
|
|
1140
|
+
|
|
1141
|
+
if (!content.includes("import FirebaseCore")) {
|
|
1142
|
+
content = content.replace("import Capacitor", "import Capacitor\nimport FirebaseCore");
|
|
1143
|
+
}
|
|
1144
|
+
if (!content.includes("import FirebaseMessaging")) {
|
|
1145
|
+
content = content.replace("import FirebaseCore", "import FirebaseCore\nimport FirebaseMessaging");
|
|
1146
|
+
}
|
|
1147
|
+
if (!content.includes("FirebaseApp.configure()")) {
|
|
1148
|
+
content = content.replace(/\n(\s*)return true/, "\n$1FirebaseApp.configure()\n\n$1return true");
|
|
1149
|
+
}
|
|
1150
|
+
if (!content.includes("didRegisterForRemoteNotificationsWithDeviceToken")) {
|
|
1151
|
+
const delegateMethods = `
|
|
1152
|
+
func application(_ application: UIApplication,
|
|
1153
|
+
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
|
1154
|
+
Messaging.messaging().apnsToken = deviceToken
|
|
1155
|
+
NotificationCenter.default.post(
|
|
1156
|
+
name: .capacitorDidRegisterForRemoteNotifications,
|
|
1157
|
+
object: deviceToken
|
|
1158
|
+
)
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
func application(_ application: UIApplication,
|
|
1162
|
+
didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
|
1163
|
+
NotificationCenter.default.post(
|
|
1164
|
+
name: .capacitorDidFailToRegisterForRemoteNotifications,
|
|
1165
|
+
object: error
|
|
1166
|
+
)
|
|
1167
|
+
}
|
|
1168
|
+
`;
|
|
1169
|
+
content = content.replace("\n func applicationWillResignActive", `${delegateMethods}\n func applicationWillResignActive`);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (content !== editor.getContent()) await editor.setContent(content).save();
|
|
1173
|
+
}
|
|
1112
1174
|
async #setPermissionInIos(permissions: { [key: string]: string }) {
|
|
1113
1175
|
const updateNs = Object.fromEntries(
|
|
1114
1176
|
Object.entries(permissions).map(([key, value]) => [`NS${capitalize(key)}`, value]),
|
|
@@ -1128,24 +1190,37 @@ export class CapacitorApp {
|
|
|
1128
1190
|
this.project.ios.updateInfoPlist(this.iosTargetName, "Release", { CFBundleURLTypes: urlTypes }),
|
|
1129
1191
|
]);
|
|
1130
1192
|
}
|
|
1131
|
-
async #setAssociatedDomainsInIos(domains: string[]) {
|
|
1193
|
+
async #setAssociatedDomainsInIos(domains: string[], { operation, env }: Pick<RunConfig, "operation" | "env">) {
|
|
1194
|
+
await this.#writeEntitlementsInIos(domains, { operation, env });
|
|
1195
|
+
}
|
|
1196
|
+
async #writeEntitlementsInIos(domains: string[], runConfig: Pick<RunConfig, "operation" | "env">) {
|
|
1132
1197
|
const entitlementsRelPath = "App/App.entitlements";
|
|
1133
1198
|
const entitlementsPath = path.join(this.app.cwdPath, this.iosProjectPath, entitlementsRelPath);
|
|
1134
1199
|
const values = domains.map((domain) => `applinks:${domain}`);
|
|
1200
|
+
const usesPush = this.target.permissions?.includes("push") ?? false;
|
|
1201
|
+
const apsEnvironment = resolveIosApnsEnvironment(runConfig);
|
|
1135
1202
|
const body = [
|
|
1136
1203
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
1137
1204
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
1138
1205
|
'<plist version="1.0">',
|
|
1139
1206
|
"<dict>",
|
|
1140
|
-
" <key>
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1207
|
+
...(usesPush ? [" <key>aps-environment</key>", ` <string>${apsEnvironment}</string>`] : []),
|
|
1208
|
+
...(values.length > 0
|
|
1209
|
+
? [
|
|
1210
|
+
" <key>com.apple.developer.associated-domains</key>",
|
|
1211
|
+
" <array>",
|
|
1212
|
+
...values.map((value) => ` <string>${value}</string>`),
|
|
1213
|
+
" </array>",
|
|
1214
|
+
]
|
|
1215
|
+
: []),
|
|
1144
1216
|
"</dict>",
|
|
1145
1217
|
"</plist>",
|
|
1146
1218
|
"",
|
|
1147
1219
|
].join("\n");
|
|
1148
|
-
await
|
|
1220
|
+
const currentBody = (await Bun.file(entitlementsPath).exists())
|
|
1221
|
+
? await Bun.file(entitlementsPath).text()
|
|
1222
|
+
: undefined;
|
|
1223
|
+
if (currentBody !== body) await writeFile(entitlementsPath, body);
|
|
1149
1224
|
await this.#setCodeSignEntitlementsInIos(entitlementsRelPath);
|
|
1150
1225
|
}
|
|
1151
1226
|
async #setCodeSignEntitlementsInIos(entitlementsRelPath: string) {
|
|
@@ -1236,7 +1311,7 @@ export class CapacitorApp {
|
|
|
1236
1311
|
for (const permission of permissions) {
|
|
1237
1312
|
if (this.#hasPermissionInAndroid(permission)) {
|
|
1238
1313
|
this.app.logger.info(`${permission} already exists in android`);
|
|
1239
|
-
|
|
1314
|
+
continue;
|
|
1240
1315
|
}
|
|
1241
1316
|
this.app.logger.info(`Adding ${permission} to android`);
|
|
1242
1317
|
this.project.android
|
|
@@ -1253,6 +1328,6 @@ export class CapacitorApp {
|
|
|
1253
1328
|
return Array.from(usesPermission).map((permission) => permission.getAttribute("android:name"));
|
|
1254
1329
|
}
|
|
1255
1330
|
#hasPermissionInAndroid(permission: string) {
|
|
1256
|
-
return this.#getPermissionsInAndroid().includes(permission);
|
|
1331
|
+
return this.#getPermissionsInAndroid().includes(`android.permission.${permission}`);
|
|
1257
1332
|
}
|
|
1258
1333
|
}
|
package/executors.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
11
|
import { copyFile, mkdir, readdir as readDirEntries, stat } from "node:fs/promises";
|
|
12
12
|
import path from "node:path";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
13
14
|
import {
|
|
14
15
|
capitalize,
|
|
15
16
|
isRouteSourceFile,
|
|
@@ -23,6 +24,11 @@ import chalk from "chalk";
|
|
|
23
24
|
import ts from "typescript";
|
|
24
25
|
import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
|
|
25
26
|
import { FileSys } from "./fileSys";
|
|
27
|
+
import {
|
|
28
|
+
createFirebaseMessagingServiceWorker,
|
|
29
|
+
type FirebaseClientEnvConfig,
|
|
30
|
+
normalizeFirebaseClientConfig,
|
|
31
|
+
} from "./firebaseMessagingSw";
|
|
26
32
|
import { getDirname } from "./getDirname";
|
|
27
33
|
import { Linter } from "./linter";
|
|
28
34
|
import { AppInfo, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
|
|
@@ -1503,8 +1509,28 @@ export class AppExecutor extends SysExecutor {
|
|
|
1503
1509
|
writeLib: write,
|
|
1504
1510
|
})) as AppInfo;
|
|
1505
1511
|
if (write) await this.syncAssets(scanInfo.getScanResult().libDeps);
|
|
1512
|
+
if (write) await this.#syncFirebaseMessagingSw();
|
|
1506
1513
|
return scanInfo;
|
|
1507
1514
|
}
|
|
1515
|
+
//* firebase config 가 있으면 public/firebase-messaging-sw.js 를 1회 생성한다(있으면 스킵).
|
|
1516
|
+
//* 서버 동적 라우트를 대체하는 정적 파일. env.client 파생이라 gitignore 되고 env 별로 재생성된다.
|
|
1517
|
+
async #syncFirebaseMessagingSw() {
|
|
1518
|
+
const swRelPath = "public/firebase-messaging-sw.js";
|
|
1519
|
+
if (await FileSys.fileExists(this.getPath(swRelPath))) return;
|
|
1520
|
+
const envClientPath = path.join(this.cwdPath, "env", "env.client.ts");
|
|
1521
|
+
if (!(await FileSys.fileExists(envClientPath))) return;
|
|
1522
|
+
let firebaseConfig: FirebaseClientEnvConfig | null = null;
|
|
1523
|
+
try {
|
|
1524
|
+
const envUrl = pathToFileURL(envClientPath);
|
|
1525
|
+
envUrl.searchParams.set("t", String(Date.now()));
|
|
1526
|
+
const envModule = (await import(envUrl.href)) as { env?: { firebase?: unknown } };
|
|
1527
|
+
firebaseConfig = normalizeFirebaseClientConfig(envModule.env?.firebase);
|
|
1528
|
+
} catch {
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
if (!firebaseConfig) return;
|
|
1532
|
+
await this.writeFile(swRelPath, createFirebaseMessagingServiceWorker(firebaseConfig), { overwrite: false });
|
|
1533
|
+
}
|
|
1508
1534
|
async increaseBuildNum() {
|
|
1509
1535
|
await increaseBuildNum(this);
|
|
1510
1536
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createFirebaseMessagingServiceWorker,
|
|
4
|
+
normalizeFirebaseClientConfig,
|
|
5
|
+
} from "./firebaseMessagingSw";
|
|
6
|
+
|
|
7
|
+
describe("normalizeFirebaseClientConfig", () => {
|
|
8
|
+
test("whitelists client fields and drops secrets / vapidKey", () => {
|
|
9
|
+
const normalized = normalizeFirebaseClientConfig({
|
|
10
|
+
apiKey: "public-api-key",
|
|
11
|
+
authDomain: "example.firebaseapp.com",
|
|
12
|
+
projectId: "public-project",
|
|
13
|
+
storageBucket: "public-project.appspot.com",
|
|
14
|
+
messagingSenderId: "1234567890",
|
|
15
|
+
appId: "public-app-id",
|
|
16
|
+
vapidKey: "public-vapid-key",
|
|
17
|
+
private_key: "SERVER_PRIVATE_KEY_MUST_NOT_LEAK",
|
|
18
|
+
});
|
|
19
|
+
expect(normalized).toEqual({
|
|
20
|
+
apiKey: "public-api-key",
|
|
21
|
+
authDomain: "example.firebaseapp.com",
|
|
22
|
+
projectId: "public-project",
|
|
23
|
+
storageBucket: "public-project.appspot.com",
|
|
24
|
+
messagingSenderId: "1234567890",
|
|
25
|
+
appId: "public-app-id",
|
|
26
|
+
});
|
|
27
|
+
expect(normalized).not.toHaveProperty("vapidKey");
|
|
28
|
+
expect(normalized).not.toHaveProperty("private_key");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("returns null when required fields are missing or input is not an object", () => {
|
|
32
|
+
expect(normalizeFirebaseClientConfig(undefined)).toBe(null);
|
|
33
|
+
expect(normalizeFirebaseClientConfig(null)).toBe(null);
|
|
34
|
+
expect(normalizeFirebaseClientConfig("nope")).toBe(null);
|
|
35
|
+
expect(normalizeFirebaseClientConfig({ apiKey: "only-key" })).toBe(null);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("createFirebaseMessagingServiceWorker", () => {
|
|
40
|
+
test("inlines client config and imports firebase compat SDK, without leaking secrets", () => {
|
|
41
|
+
const config = normalizeFirebaseClientConfig({
|
|
42
|
+
apiKey: "public-api-key",
|
|
43
|
+
projectId: "public-project",
|
|
44
|
+
messagingSenderId: "1234567890",
|
|
45
|
+
appId: "public-app-id",
|
|
46
|
+
private_key: "SERVER_PRIVATE_KEY_MUST_NOT_LEAK",
|
|
47
|
+
});
|
|
48
|
+
const body = createFirebaseMessagingServiceWorker(config);
|
|
49
|
+
expect(body).toContain("public-api-key");
|
|
50
|
+
expect(body).toContain("public-project");
|
|
51
|
+
expect(body).toContain("firebase-messaging-compat.js");
|
|
52
|
+
expect(body).toContain("onBackgroundMessage");
|
|
53
|
+
expect(body).toContain("notificationclick");
|
|
54
|
+
expect(body).not.toContain("SERVER_PRIVATE_KEY_MUST_NOT_LEAK");
|
|
55
|
+
expect(body).not.toContain("private_key");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("produces a no-op worker (config null) that still registers notificationclick", () => {
|
|
59
|
+
const body = createFirebaseMessagingServiceWorker(null);
|
|
60
|
+
// config is null → the importScripts/onBackgroundMessage block is gated by `if (firebaseConfig)` at runtime,
|
|
61
|
+
// but notificationclick is always registered.
|
|
62
|
+
expect(body).toContain("const firebaseConfig = null");
|
|
63
|
+
expect(body).toContain("notificationclick");
|
|
64
|
+
expect(body).toContain("if (firebaseConfig)");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const FIREBASE_WEB_SDK_VERSION = "12.13.0";
|
|
2
|
+
|
|
3
|
+
export interface FirebaseClientEnvConfig {
|
|
4
|
+
apiKey: string;
|
|
5
|
+
authDomain?: string;
|
|
6
|
+
projectId: string;
|
|
7
|
+
storageBucket?: string;
|
|
8
|
+
messagingSenderId: string;
|
|
9
|
+
appId: string;
|
|
10
|
+
vapidKey?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
//* env.client 의 firebase 설정을 검증하고, 서비스워커에 필요한 필드만 화이트리스트한다.
|
|
14
|
+
//* vapidKey/서버 시크릿 등 나머지 필드는 의도적으로 제외한다(SW 본문에 유출 방지).
|
|
15
|
+
export function normalizeFirebaseClientConfig(config: unknown): FirebaseClientEnvConfig | null {
|
|
16
|
+
if (!config || typeof config !== "object") return null;
|
|
17
|
+
const value = config as Partial<Record<keyof FirebaseClientEnvConfig, unknown>>;
|
|
18
|
+
if (
|
|
19
|
+
typeof value.apiKey !== "string" ||
|
|
20
|
+
typeof value.projectId !== "string" ||
|
|
21
|
+
typeof value.messagingSenderId !== "string" ||
|
|
22
|
+
typeof value.appId !== "string"
|
|
23
|
+
) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
apiKey: value.apiKey,
|
|
28
|
+
...(typeof value.authDomain === "string" ? { authDomain: value.authDomain } : {}),
|
|
29
|
+
projectId: value.projectId,
|
|
30
|
+
...(typeof value.storageBucket === "string" ? { storageBucket: value.storageBucket } : {}),
|
|
31
|
+
messagingSenderId: value.messagingSenderId,
|
|
32
|
+
appId: value.appId,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
//* firebase-messaging-sw.js 생성 코드
|
|
37
|
+
export function createFirebaseMessagingServiceWorker(config: FirebaseClientEnvConfig | null): string {
|
|
38
|
+
const configJson = JSON.stringify(config);
|
|
39
|
+
return `/* Generated by Akan.js. Do not edit. */
|
|
40
|
+
const firebaseConfig = ${configJson};
|
|
41
|
+
|
|
42
|
+
if (firebaseConfig) {
|
|
43
|
+
importScripts("https://www.gstatic.com/firebasejs/${FIREBASE_WEB_SDK_VERSION}/firebase-app-compat.js");
|
|
44
|
+
importScripts("https://www.gstatic.com/firebasejs/${FIREBASE_WEB_SDK_VERSION}/firebase-messaging-compat.js");
|
|
45
|
+
|
|
46
|
+
firebase.initializeApp(firebaseConfig);
|
|
47
|
+
const messaging = firebase.messaging();
|
|
48
|
+
|
|
49
|
+
const notificationUrl = (payload) =>
|
|
50
|
+
payload?.data?.url || payload?.fcmOptions?.link || payload?.notification?.click_action;
|
|
51
|
+
|
|
52
|
+
messaging.onBackgroundMessage((payload) => {
|
|
53
|
+
const title = payload?.notification?.title || "";
|
|
54
|
+
const options = {
|
|
55
|
+
body: payload?.notification?.body,
|
|
56
|
+
icon: payload?.notification?.icon,
|
|
57
|
+
image: payload?.notification?.image,
|
|
58
|
+
data: {
|
|
59
|
+
url: notificationUrl(payload),
|
|
60
|
+
FCM_MSG: payload,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
self.registration.showNotification(title, options);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
self.addEventListener("notificationclick", (event) => {
|
|
68
|
+
const url = event.notification?.data?.url || event.notification?.data?.FCM_MSG?.data?.url;
|
|
69
|
+
event.notification?.close();
|
|
70
|
+
if (!url) return;
|
|
71
|
+
event.waitUntil(clients.openWindow(url));
|
|
72
|
+
});
|
|
73
|
+
`;
|
|
74
|
+
}
|
package/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export * from "./dependencyScanner";
|
|
|
18
18
|
export * from "./executors";
|
|
19
19
|
export * from "./extractDeps";
|
|
20
20
|
export * from "./fileSys";
|
|
21
|
+
export * from "./firebaseMessagingSw";
|
|
21
22
|
export * from "./frontendBuild";
|
|
22
23
|
export * from "./getCredentials";
|
|
23
24
|
export * from "./getDirname";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Domain code in apps/ and libs/ must not throw a raw `Error`. Throw the typed
|
|
5
|
+
// `Err` instead, which is generated per app/lib by `makeTrans`
|
|
6
|
+
// (pkgs/akanjs/dictionary/trans.ts) and imported via `import { Err } from "../dict"`
|
|
7
|
+
// (or the app/lib client entrypoint). `Err` carries a translatable error key,
|
|
8
|
+
// a status code, and structured payload, so raw `Error` throws lose i18n and
|
|
9
|
+
// HTTP-status handling.
|
|
10
|
+
//
|
|
11
|
+
// The `$msg` metavariable in the argument position leniently matches any arity,
|
|
12
|
+
// so `throw new Error()`, `throw new Error("x")`, and
|
|
13
|
+
// `throw new Error("x", { cause })` are all caught, as is the `new`-less
|
|
14
|
+
// `throw Error(...)` call form. Only real throw statements match — a
|
|
15
|
+
// `new Error(...)` inside a string literal or one that is not thrown is ignored.
|
|
16
|
+
//
|
|
17
|
+
// No autofix: `Err`'s first argument must be a typed dictionary error key
|
|
18
|
+
// (e.g. throw new Err('model.errorKey')), so a mechanical rewrite of an
|
|
19
|
+
// arbitrary message string would not typecheck. Each site must be migrated to a
|
|
20
|
+
// real error key by hand.
|
|
21
|
+
//
|
|
22
|
+
// NOTE: the diagnostic messages below use single quotes in the example on
|
|
23
|
+
// purpose. Escaped double quotes (\") inside a GritQL string trigger a
|
|
24
|
+
// double-unescape bug in Biome 2.4.4 that corrupts the rendered message.
|
|
25
|
+
or {
|
|
26
|
+
`throw new Error($msg)` as $throw where {
|
|
27
|
+
register_diagnostic(
|
|
28
|
+
span = $throw,
|
|
29
|
+
message = "Do not throw a raw `Error`. Throw the typed `Err` instead (import { Err } from the app/lib dict), e.g. throw new Err('model.errorKey').",
|
|
30
|
+
severity = "error"
|
|
31
|
+
)
|
|
32
|
+
},
|
|
33
|
+
`throw Error($msg)` as $throw where {
|
|
34
|
+
register_diagnostic(
|
|
35
|
+
span = $throw,
|
|
36
|
+
message = "Do not throw a raw `Error`. Throw the typed `Err` instead (import { Err } from the app/lib dict), e.g. throw new Err('model.errorKey').",
|
|
37
|
+
severity = "error"
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "2.3.11-rc.
|
|
3
|
+
"version": "2.3.11-rc.8",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@langchain/openai": "^1.4.6",
|
|
33
33
|
"@tailwindcss/node": "^4.3.0",
|
|
34
34
|
"@trapezedev/project": "^7.1.4",
|
|
35
|
-
"akanjs": "2.3.11-rc.
|
|
35
|
+
"akanjs": "2.3.11-rc.8",
|
|
36
36
|
"chalk": "^5.6.2",
|
|
37
37
|
"commander": "^14.0.3",
|
|
38
38
|
"daisyui": "5.5.23",
|
package/scanInfo.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
2
3
|
import type {
|
|
3
4
|
AppConfigResult,
|
|
4
5
|
AppScanResult,
|
|
@@ -54,6 +55,7 @@ const appRootAllowedFiles = new Set([
|
|
|
54
55
|
"tsconfig.json",
|
|
55
56
|
"tsconfig.tsbuildinfo",
|
|
56
57
|
]);
|
|
58
|
+
const generatedRootCapacitorConfigFiles = ["capacitor.config.js", "capacitor.config.json"] as const;
|
|
57
59
|
const appRootAllowedDirs = new Set([
|
|
58
60
|
".akan",
|
|
59
61
|
"android",
|
|
@@ -100,12 +102,17 @@ const isAllowedLibRootFile = (filename: string) =>
|
|
|
100
102
|
libRootAllowedFiles.has(filename) || rootSignalTestFilePattern.test(filename);
|
|
101
103
|
const getScanPath = (exec: AppExecutor | LibExecutor, relativePath: string) =>
|
|
102
104
|
path.posix.join(`${exec.type}s`, exec.name, relativePath.split(path.sep).join("/"));
|
|
105
|
+
async function clearGeneratedRootCapacitorConfigs(exec: AppExecutor | LibExecutor) {
|
|
106
|
+
if (exec.type !== "app") return;
|
|
107
|
+
await Promise.all(generatedRootCapacitorConfigFiles.map((filename) => rm(exec.getPath(filename), { force: true })));
|
|
108
|
+
}
|
|
103
109
|
const getModuleNameFromPath = (kind: ModuleKind, modulePath: string) => {
|
|
104
110
|
const dirname = path.basename(modulePath);
|
|
105
111
|
return kind === "service" ? dirname.replace(/^_+/, "") : dirname;
|
|
106
112
|
};
|
|
107
113
|
|
|
108
114
|
async function assertScanConvention(exec: AppExecutor | LibExecutor, libRoot: { files: string[]; dirs: string[] }) {
|
|
115
|
+
await clearGeneratedRootCapacitorConfigs(exec);
|
|
109
116
|
const violations: string[] = [];
|
|
110
117
|
const addViolation = (relativePath: string, reason: string) => {
|
|
111
118
|
violations.push(`${getScanPath(exec, relativePath)}: ${reason}`);
|