@akanjs/devkit 2.3.12 → 2.3.13-rc.1
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.test.ts +0 -1
- package/akanConfig/akanConfig.ts +19 -7
- package/akanConfig/types.ts +9 -0
- package/capacitorApp.ts +81 -95
- package/executors.ts +64 -24
- package/frontendBuild/devChangePlanner.ts +1 -1
- package/frontendBuild/devGeneratedIndexSync.ts +1 -1
- package/index.ts +0 -1
- package/package.json +2 -2
- package/qualityScanner.ts +1 -1
- package/firebaseMessagingSw.test.ts +0 -66
- package/firebaseMessagingSw.ts +0 -74
|
@@ -4,7 +4,6 @@ import path from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import type { PackageJson } from "../types";
|
|
6
6
|
import { AkanAppConfig, AkanLibConfig } from "./akanConfig";
|
|
7
|
-
import type { DeepPartial, LibConfigResult } from "./types";
|
|
8
7
|
|
|
9
8
|
const akanPackageJson = JSON.parse(
|
|
10
9
|
fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../akanjs/package.json"), "utf8"),
|
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { AkanPlugin } from "akanjs";
|
|
4
5
|
import { type AkanI18nConfig, resolveAkanI18nConfig } from "akanjs/common";
|
|
5
6
|
import type { AkanImageConfig } from "akanjs/server";
|
|
6
7
|
import type { App, Lib } from "../commandDecorators";
|
|
@@ -136,6 +137,8 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
136
137
|
secrets: string[];
|
|
137
138
|
baseDevEnv: BaseDevEnv;
|
|
138
139
|
libs: string[];
|
|
140
|
+
/** Live-only: plugins declared in this app's `akan.config.ts` (never serialized). */
|
|
141
|
+
plugins: AkanPlugin[];
|
|
139
142
|
domains = new Set<string>();
|
|
140
143
|
subRoutes = new Map<string, Set<string>>();
|
|
141
144
|
basePaths = new Set<string>();
|
|
@@ -146,11 +149,13 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
146
149
|
rootPackageJson: PackageJson,
|
|
147
150
|
config: DeepPartial<AppConfigResult>,
|
|
148
151
|
baseDevEnv: BaseDevEnv,
|
|
152
|
+
plugins: AkanPlugin[] = [],
|
|
149
153
|
) {
|
|
150
154
|
this.app = app;
|
|
151
155
|
this.rootPackageJson = rootPackageJson;
|
|
152
156
|
this.libs = libs;
|
|
153
157
|
this.baseDevEnv = baseDevEnv;
|
|
158
|
+
this.plugins = plugins;
|
|
154
159
|
this.#applyRoutes(config?.routes);
|
|
155
160
|
this.defaultDatabaseMode = config?.defaultDatabaseMode ?? "single";
|
|
156
161
|
this.externalLibs = config?.externalLibs ?? [];
|
|
@@ -341,15 +346,18 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
|
|
|
341
346
|
app.workspace.getLibs(),
|
|
342
347
|
app.workspace.getPackageJson(),
|
|
343
348
|
]);
|
|
344
|
-
const
|
|
345
|
-
|
|
349
|
+
const resolved = typeof configImp === "function" ? configImp(app) : configImp;
|
|
350
|
+
const { plugins, ...config } = (resolved ?? {}) as DeepPartial<AppConfigResult> & { plugins?: AkanPlugin[] };
|
|
351
|
+
return new AkanAppConfig(app, libs, rootPackageJson, config, baseDevEnv, plugins ?? []);
|
|
346
352
|
}
|
|
347
353
|
#resolveProductionDependencyVersion(lib: string) {
|
|
348
354
|
const rootVersion = this.rootPackageJson.dependencies?.[lib] ?? this.rootPackageJson.devDependencies?.[lib];
|
|
349
355
|
if (rootVersion) return rootVersion;
|
|
356
|
+
// Fall back to the framework package's own (peer)dependencies so plugin-declared runtime
|
|
357
|
+
// packages (e.g. firebase for the push plugin) resolve a version without the framework
|
|
358
|
+
// hardcoding a per-feature package list.
|
|
350
359
|
const akanPackageJson = getAkanPackageJson();
|
|
351
|
-
|
|
352
|
-
return akanPackageJson.dependencies?.[lib] ?? akanPackageJson.peerDependencies?.[lib];
|
|
360
|
+
return akanPackageJson.dependencies?.[lib] ?? akanPackageJson.peerDependencies?.[lib];
|
|
353
361
|
}
|
|
354
362
|
#getProductionRuntimePackages() {
|
|
355
363
|
return [
|
|
@@ -451,14 +459,18 @@ function mergeImageConfig(config: Partial<AkanImageConfig> = {}): AkanImageConfi
|
|
|
451
459
|
export class AkanLibConfig implements LibConfigResult {
|
|
452
460
|
lib: Lib;
|
|
453
461
|
externalLibs: string[];
|
|
454
|
-
|
|
462
|
+
/** Live-only: plugins declared in this lib's `akan.config.ts` (never serialized). */
|
|
463
|
+
plugins: AkanPlugin[];
|
|
464
|
+
constructor(lib: Lib, config: DeepPartial<LibConfigResult>, plugins: AkanPlugin[] = []) {
|
|
455
465
|
this.lib = lib;
|
|
456
466
|
this.externalLibs = config?.externalLibs ?? [];
|
|
467
|
+
this.plugins = plugins;
|
|
457
468
|
}
|
|
458
469
|
static async from(lib: Lib) {
|
|
459
470
|
const [configImp] = await Promise.all([import(`${lib.cwdPath}/akan.config.ts`).then((mod) => mod.default)]);
|
|
460
|
-
const
|
|
461
|
-
|
|
471
|
+
const resolved = typeof configImp === "function" ? configImp(lib) : configImp;
|
|
472
|
+
const { plugins, ...config } = (resolved ?? {}) as DeepPartial<LibConfigResult> & { plugins?: AkanPlugin[] };
|
|
473
|
+
return new AkanLibConfig(lib, config, plugins ?? []);
|
|
462
474
|
}
|
|
463
475
|
}
|
|
464
476
|
|
package/akanConfig/types.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
export type {
|
|
2
2
|
AkanConfigFile,
|
|
3
|
+
AkanExecutor,
|
|
3
4
|
AkanMobileConfig,
|
|
4
5
|
AkanMobileTargetConfig,
|
|
6
|
+
AkanNativeContext,
|
|
7
|
+
AkanPlugin,
|
|
8
|
+
AkanPluginCapacitorConfig,
|
|
5
9
|
AkanRouteConfig,
|
|
10
|
+
AkanScanInfo,
|
|
11
|
+
AkanSyncContext,
|
|
6
12
|
AppConfig,
|
|
13
|
+
AppConfigInput,
|
|
7
14
|
AppConfigResult,
|
|
8
15
|
AppScanResult,
|
|
9
16
|
Arch,
|
|
@@ -12,11 +19,13 @@ export type {
|
|
|
12
19
|
DockerConfig,
|
|
13
20
|
FileConventionScanResult,
|
|
14
21
|
LibConfig,
|
|
22
|
+
LibConfigInput,
|
|
15
23
|
LibConfigResult,
|
|
16
24
|
LibScanResult,
|
|
17
25
|
MobileEnv,
|
|
18
26
|
MobilePermission,
|
|
19
27
|
PkgScanResult,
|
|
28
|
+
PluginRuntimeContext,
|
|
20
29
|
ScanResult,
|
|
21
30
|
WorkspaceScanResult,
|
|
22
31
|
} from "akanjs";
|
package/capacitorApp.ts
CHANGED
|
@@ -6,8 +6,9 @@ import { select } from "@inquirer/prompts";
|
|
|
6
6
|
import { MobileProject } from "@trapezedev/project";
|
|
7
7
|
import type { AndroidProject } from "@trapezedev/project/dist/android/project";
|
|
8
8
|
import type { IosProject } from "@trapezedev/project/dist/ios/project";
|
|
9
|
+
import type { AkanNativeContext, AkanPlugin } from "akanjs";
|
|
9
10
|
import { capitalize } from "akanjs/common";
|
|
10
|
-
import type { AkanMobileTargetConfig } from "./akanConfig";
|
|
11
|
+
import type { AkanMobileTargetConfig, MobilePermission } from "./akanConfig";
|
|
11
12
|
import { type AppExecutor, CommandExecutionError } from "./executors";
|
|
12
13
|
import { FileEditor } from "./fileEditor";
|
|
13
14
|
import { resolveMobilePath, targetHtmlFilename } from "./mobile";
|
|
@@ -26,7 +27,6 @@ interface RunIosConfig extends RunConfig {
|
|
|
26
27
|
interface PrepareConfig extends RunConfig {}
|
|
27
28
|
|
|
28
29
|
type MobileCommandEnv = Record<string, string | undefined>;
|
|
29
|
-
type IosApnsEnvironment = "development" | "production";
|
|
30
30
|
|
|
31
31
|
export type IosRunTargetKind = "device" | "simulator";
|
|
32
32
|
export interface IosRunTarget {
|
|
@@ -131,9 +131,6 @@ const asString = (value: unknown) => (typeof value === "string" ? value : undefi
|
|
|
131
131
|
|
|
132
132
|
const firstString = (...values: unknown[]) => values.find((value): value is string => typeof value === "string");
|
|
133
133
|
|
|
134
|
-
const resolveIosApnsEnvironment = ({ operation }: Pick<RunConfig, "operation" | "env">): IosApnsEnvironment =>
|
|
135
|
-
operation === "release" ? "production" : "development";
|
|
136
|
-
|
|
137
134
|
const scoreIosDeviceTarget = (target: IosRunTarget) => {
|
|
138
135
|
const state = target.state?.toLowerCase() ?? "";
|
|
139
136
|
return (state.includes("available") ? 4 : 0) + (state.includes("wired") ? 2 : 0) + (state.includes("paired") ? 1 : 0);
|
|
@@ -557,6 +554,9 @@ export class CapacitorApp {
|
|
|
557
554
|
readonly iosProjectPath = "ios/App";
|
|
558
555
|
readonly androidRootPath = "android";
|
|
559
556
|
readonly androidAssetsPath = "android/app/src/main/assets";
|
|
557
|
+
//* Accumulates iOS entitlements contributed by deep links and plugins during a prepare,
|
|
558
|
+
//* then flushed to App/App.entitlements once (see #flushIosEntitlements).
|
|
559
|
+
#iosEntitlements: Record<string, string | string[]> = {};
|
|
560
560
|
constructor(
|
|
561
561
|
private readonly app: AppExecutor,
|
|
562
562
|
readonly target: AkanMobileTargetConfig,
|
|
@@ -605,6 +605,7 @@ export class CapacitorApp {
|
|
|
605
605
|
await this.#applyIosMetadata();
|
|
606
606
|
await this.#applyPermissions({ operation, env });
|
|
607
607
|
await this.#applyDeepLinks("ios", { operation, env });
|
|
608
|
+
await this.#flushIosEntitlements();
|
|
608
609
|
await this.project.commit();
|
|
609
610
|
await this.#generateAssets({ operation, env });
|
|
610
611
|
this.app.verbose(`syncing iOS`);
|
|
@@ -992,13 +993,52 @@ export class CapacitorApp {
|
|
|
992
993
|
await this.project.android.setAppName(this.target.appName);
|
|
993
994
|
}
|
|
994
995
|
async #applyPermissions({ operation, env }: Pick<RunConfig, "operation" | "env">) {
|
|
996
|
+
const plugins = await this.app.collectPlugins();
|
|
997
|
+
const nativePlugins = new Map<MobilePermission, AkanPlugin[]>();
|
|
998
|
+
for (const plugin of plugins) {
|
|
999
|
+
const permission = plugin.capacitor?.permission;
|
|
1000
|
+
if (!permission || !plugin.capacitor?.configureNative) continue;
|
|
1001
|
+
const claimants = nativePlugins.get(permission) ?? [];
|
|
1002
|
+
claimants.push(plugin);
|
|
1003
|
+
nativePlugins.set(permission, claimants);
|
|
1004
|
+
}
|
|
995
1005
|
for (const permission of this.target.permissions ?? []) {
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1006
|
+
const claimants = nativePlugins.get(permission);
|
|
1007
|
+
if (!claimants?.length) {
|
|
1008
|
+
this.app.logger.warn(
|
|
1009
|
+
`Mobile target '${this.target.name}' declares permission '${permission}' but no plugin configures it. ` +
|
|
1010
|
+
`Depend on a lib (e.g. @libs/util) whose akan.config declares a plugin with capacitor.permission '${permission}'.`,
|
|
1011
|
+
);
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
const ctx = this.#makeNativeContext({ operation, env });
|
|
1015
|
+
for (const plugin of claimants) await plugin.capacitor?.configureNative?.(ctx);
|
|
1000
1016
|
}
|
|
1001
1017
|
}
|
|
1018
|
+
#makeNativeContext({ operation, env }: Pick<RunConfig, "operation" | "env">): AkanNativeContext {
|
|
1019
|
+
return {
|
|
1020
|
+
appPath: this.app.cwdPath,
|
|
1021
|
+
executor: this.app,
|
|
1022
|
+
target: this.target,
|
|
1023
|
+
operation,
|
|
1024
|
+
env,
|
|
1025
|
+
setIosUsageDescriptions: (descriptions) => this.#setPermissionInIos(descriptions),
|
|
1026
|
+
updateIosInfoPlist: async (values) => {
|
|
1027
|
+
await Promise.all([
|
|
1028
|
+
this.project.ios.updateInfoPlist(this.iosTargetName, "Debug", values),
|
|
1029
|
+
this.project.ios.updateInfoPlist(this.iosTargetName, "Release", values),
|
|
1030
|
+
]);
|
|
1031
|
+
},
|
|
1032
|
+
addIosEntitlements: (entitlements) => this.#addIosEntitlements(entitlements),
|
|
1033
|
+
editIosAppDelegate: (transform) => this.#editIosAppDelegate(transform),
|
|
1034
|
+
addAndroidPermissions: (permissions) => {
|
|
1035
|
+
this.#setPermissionsInAndroid(permissions);
|
|
1036
|
+
},
|
|
1037
|
+
addAndroidFeatures: (features) => {
|
|
1038
|
+
this.#setFeaturesInAndroid(features);
|
|
1039
|
+
},
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1002
1042
|
async #applyDeepLinks(platform: "ios" | "android", { operation, env }: Pick<RunConfig, "operation" | "env">) {
|
|
1003
1043
|
const deepLinks = this.target.deepLinks;
|
|
1004
1044
|
if (!deepLinks) return;
|
|
@@ -1012,7 +1052,10 @@ export class CapacitorApp {
|
|
|
1012
1052
|
});
|
|
1013
1053
|
await this.#setUrlSchemesInIos(schemes);
|
|
1014
1054
|
}
|
|
1015
|
-
if (domains.length > 0)
|
|
1055
|
+
if (domains.length > 0)
|
|
1056
|
+
this.#addIosEntitlements({
|
|
1057
|
+
"com.apple.developer.associated-domains": domains.map((domain) => `applinks:${domain}`),
|
|
1058
|
+
});
|
|
1016
1059
|
return;
|
|
1017
1060
|
}
|
|
1018
1061
|
if (platform === "android") {
|
|
@@ -1036,7 +1079,10 @@ export class CapacitorApp {
|
|
|
1036
1079
|
await this.#setDeepLinksInAndroid(schemes, domains);
|
|
1037
1080
|
}
|
|
1038
1081
|
}
|
|
1039
|
-
#assertDeepLinkVerificationConfig(
|
|
1082
|
+
#assertDeepLinkVerificationConfig(
|
|
1083
|
+
platform: "ios" | "android",
|
|
1084
|
+
{ operation, env }: Pick<RunConfig, "operation" | "env">,
|
|
1085
|
+
) {
|
|
1040
1086
|
const deepLinks = this.target.deepLinks;
|
|
1041
1087
|
if (!deepLinks?.domains?.length) return;
|
|
1042
1088
|
if (platform === "ios" && !deepLinks.ios?.teamId) {
|
|
@@ -1097,79 +1143,18 @@ export class CapacitorApp {
|
|
|
1097
1143
|
await this.#clearRootCapacitorConfigs();
|
|
1098
1144
|
}
|
|
1099
1145
|
}
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
cameraUsageDescription: "$(PRODUCT_NAME) requires access to the camera to take photos.",
|
|
1103
|
-
photoAddUsageDescription: "$(PRODUCT_NAME) requires access to the photo library to take photos.",
|
|
1104
|
-
photoUsageDescription: "$(PRODUCT_NAME) requires access to the photo library to take photos.",
|
|
1105
|
-
});
|
|
1106
|
-
this.#setPermissionsInAndroid(["READ_MEDIA_IMAGES", "READ_EXTERNAL_STORAGE", "WRITE_EXTERNAL_STORAGE"]);
|
|
1107
|
-
}
|
|
1108
|
-
async addContact() {
|
|
1109
|
-
await this.#setPermissionInIos({
|
|
1110
|
-
contactsUsageDescription: "$(PRODUCT_NAME) requires access to the contacts to add new contacts.",
|
|
1111
|
-
});
|
|
1112
|
-
this.#setPermissionsInAndroid(["READ_CONTACTS", "WRITE_CONTACTS"]);
|
|
1113
|
-
}
|
|
1114
|
-
async addLocation() {
|
|
1115
|
-
await this.#setPermissionInIos({
|
|
1116
|
-
locationAlwaysUsageDescription: "$(PRODUCT_NAME) requires access to the location to get the user's location.",
|
|
1117
|
-
locationWhenInUseUsageDescription: "$(PRODUCT_NAME) requires access to the location to get the user's location.",
|
|
1118
|
-
});
|
|
1119
|
-
this.#setPermissionsInAndroid(["ACCESS_COARSE_LOCATION", "ACCESS_FINE_LOCATION"]);
|
|
1120
|
-
this.#setFeaturesInAndroid(["android.hardware.location.gps"]);
|
|
1146
|
+
#addIosEntitlements(entitlements: Record<string, string | string[]>) {
|
|
1147
|
+
Object.assign(this.#iosEntitlements, entitlements);
|
|
1121
1148
|
}
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
-
]);
|
|
1132
|
-
this.#setPermissionsInAndroid(["POST_NOTIFICATIONS"]);
|
|
1133
|
-
}
|
|
1134
|
-
async #ensurePushAppDelegateInIos() {
|
|
1149
|
+
//* Generic in-place transform of ios/App/App/AppDelegate.swift (no-op if the file is absent).
|
|
1150
|
+
//* Feature-specific native wiring (e.g. Firebase) lives in plugins, not the framework.
|
|
1151
|
+
async #editIosAppDelegate(transform: (content: string) => string) {
|
|
1135
1152
|
const appDelegatePath = path.join(this.app.cwdPath, this.iosProjectPath, "App/AppDelegate.swift");
|
|
1136
1153
|
if (!(await Bun.file(appDelegatePath).exists())) return;
|
|
1137
|
-
|
|
1138
1154
|
const editor = await FileEditor.create(appDelegatePath);
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
if (
|
|
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();
|
|
1155
|
+
const content = editor.getContent();
|
|
1156
|
+
const next = transform(content);
|
|
1157
|
+
if (next !== content) await editor.setContent(next).save();
|
|
1173
1158
|
}
|
|
1174
1159
|
async #setPermissionInIos(permissions: { [key: string]: string }) {
|
|
1175
1160
|
const updateNs = Object.fromEntries(
|
|
@@ -1190,29 +1175,30 @@ export class CapacitorApp {
|
|
|
1190
1175
|
this.project.ios.updateInfoPlist(this.iosTargetName, "Release", { CFBundleURLTypes: urlTypes }),
|
|
1191
1176
|
]);
|
|
1192
1177
|
}
|
|
1193
|
-
|
|
1194
|
-
|
|
1178
|
+
#serializeIosEntitlements(entitlements: Record<string, string | string[]>) {
|
|
1179
|
+
const lines: string[] = [];
|
|
1180
|
+
for (const [key, value] of Object.entries(entitlements)) {
|
|
1181
|
+
lines.push(` <key>${key}</key>`);
|
|
1182
|
+
if (Array.isArray(value)) {
|
|
1183
|
+
lines.push(" <array>", ...value.map((item) => ` <string>${item}</string>`), " </array>");
|
|
1184
|
+
} else {
|
|
1185
|
+
lines.push(` <string>${value}</string>`);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
return lines;
|
|
1195
1189
|
}
|
|
1196
|
-
|
|
1190
|
+
//* Write the accumulated iOS entitlements (from deep links + plugins) to App/App.entitlements
|
|
1191
|
+
//* once per prepare. Skipped entirely when nothing contributed entitlements.
|
|
1192
|
+
async #flushIosEntitlements() {
|
|
1193
|
+
if (Object.keys(this.#iosEntitlements).length === 0) return;
|
|
1197
1194
|
const entitlementsRelPath = "App/App.entitlements";
|
|
1198
1195
|
const entitlementsPath = path.join(this.app.cwdPath, this.iosProjectPath, entitlementsRelPath);
|
|
1199
|
-
const values = domains.map((domain) => `applinks:${domain}`);
|
|
1200
|
-
const usesPush = this.target.permissions?.includes("push") ?? false;
|
|
1201
|
-
const apsEnvironment = resolveIosApnsEnvironment(runConfig);
|
|
1202
1196
|
const body = [
|
|
1203
1197
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
1204
1198
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
1205
1199
|
'<plist version="1.0">',
|
|
1206
1200
|
"<dict>",
|
|
1207
|
-
...(
|
|
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
|
-
: []),
|
|
1201
|
+
...this.#serializeIosEntitlements(this.#iosEntitlements),
|
|
1216
1202
|
"</dict>",
|
|
1217
1203
|
"</plist>",
|
|
1218
1204
|
"",
|
package/executors.ts
CHANGED
|
@@ -11,6 +11,7 @@ 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
13
|
import { pathToFileURL } from "node:url";
|
|
14
|
+
import type { AkanPlugin, AkanSyncContext, PluginRuntimeContext } from "akanjs";
|
|
14
15
|
import {
|
|
15
16
|
capitalize,
|
|
16
17
|
isRouteSourceFile,
|
|
@@ -24,11 +25,6 @@ import chalk from "chalk";
|
|
|
24
25
|
import ts from "typescript";
|
|
25
26
|
import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
|
|
26
27
|
import { FileSys } from "./fileSys";
|
|
27
|
-
import {
|
|
28
|
-
createFirebaseMessagingServiceWorker,
|
|
29
|
-
type FirebaseClientEnvConfig,
|
|
30
|
-
normalizeFirebaseClientConfig,
|
|
31
|
-
} from "./firebaseMessagingSw";
|
|
32
28
|
import { getDirname } from "./getDirname";
|
|
33
29
|
import { Linter } from "./linter";
|
|
34
30
|
import { AppInfo, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
|
|
@@ -1087,7 +1083,7 @@ interface SysExecutorOptions {
|
|
|
1087
1083
|
type: "app" | "lib";
|
|
1088
1084
|
}
|
|
1089
1085
|
|
|
1090
|
-
const scanFacetDirs = ["ui", "webkit", "srvkit", "common"] as const;
|
|
1086
|
+
const scanFacetDirs = ["ui", "webkit", "srvkit", "common", "plugin"] as const;
|
|
1091
1087
|
|
|
1092
1088
|
export class SysExecutor extends Executor {
|
|
1093
1089
|
workspace: WorkspaceExecutor;
|
|
@@ -1548,27 +1544,71 @@ export class AppExecutor extends SysExecutor {
|
|
|
1548
1544
|
writeLib: write,
|
|
1549
1545
|
})) as AppInfo;
|
|
1550
1546
|
if (write) await this.syncAssets(scanInfo.getScanResult().libDeps);
|
|
1551
|
-
if (write) await this.#
|
|
1547
|
+
if (write) await this.#runPluginSyncAssets();
|
|
1552
1548
|
return scanInfo;
|
|
1553
1549
|
}
|
|
1554
|
-
//*
|
|
1555
|
-
//*
|
|
1556
|
-
async #
|
|
1557
|
-
const
|
|
1558
|
-
if (
|
|
1559
|
-
const
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1550
|
+
//* build-time asset generation is delegated to plugins (e.g. the push plugin writes
|
|
1551
|
+
//* public/firebase-messaging-sw.js). The framework itself no longer knows about firebase.
|
|
1552
|
+
async #runPluginSyncAssets() {
|
|
1553
|
+
const plugins = await this.collectPlugins();
|
|
1554
|
+
if (!plugins.some((plugin) => plugin.syncAssets)) return;
|
|
1555
|
+
const ctx = this.#makeSyncContext();
|
|
1556
|
+
for (const plugin of plugins) await plugin.syncAssets?.(ctx);
|
|
1557
|
+
}
|
|
1558
|
+
#makeSyncContext(): AkanSyncContext {
|
|
1559
|
+
return {
|
|
1560
|
+
appName: this.name,
|
|
1561
|
+
appPath: this.cwdPath,
|
|
1562
|
+
executor: this,
|
|
1563
|
+
getPath: (rel) => this.getPath(rel),
|
|
1564
|
+
fileExists: (rel) => FileSys.fileExists(this.getPath(rel)),
|
|
1565
|
+
writeFile: async (rel, content, opts) => {
|
|
1566
|
+
await this.writeFile(rel, content, opts);
|
|
1567
|
+
},
|
|
1568
|
+
readEnvClient: async () => {
|
|
1569
|
+
const envClientPath = path.join(this.cwdPath, "env", "env.client.ts");
|
|
1570
|
+
if (!(await FileSys.fileExists(envClientPath))) return null;
|
|
1571
|
+
try {
|
|
1572
|
+
const envUrl = pathToFileURL(envClientPath);
|
|
1573
|
+
envUrl.searchParams.set("t", String(Date.now()));
|
|
1574
|
+
const envModule = (await import(envUrl.href)) as { env?: Record<string, unknown> };
|
|
1575
|
+
return envModule.env ?? null;
|
|
1576
|
+
} catch {
|
|
1577
|
+
return null;
|
|
1578
|
+
}
|
|
1579
|
+
},
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
//* Aggregate plugins declared by this app's akan.config plus each of its lib dependencies'
|
|
1583
|
+
//* akan.config. This is how a lib (e.g. libs/util) opts an app into a feature turnkey.
|
|
1584
|
+
async collectPlugins(): Promise<AkanPlugin[]> {
|
|
1585
|
+
const scanInfo = (await this.scan({ write: false })) as AppInfo;
|
|
1586
|
+
const libDeps = scanInfo.getLibs();
|
|
1587
|
+
const appConfig = await this.getConfig();
|
|
1588
|
+
const collected: AkanPlugin[] = [...appConfig.plugins];
|
|
1589
|
+
for (const libName of libDeps) {
|
|
1590
|
+
const libConfig = await LibExecutor.from(this, libName)
|
|
1591
|
+
.getConfig()
|
|
1592
|
+
.catch(() => null);
|
|
1593
|
+
if (libConfig) collected.push(...libConfig.plugins);
|
|
1569
1594
|
}
|
|
1570
|
-
|
|
1571
|
-
|
|
1595
|
+
const seen = new Set<string>();
|
|
1596
|
+
return collected.filter((plugin) => {
|
|
1597
|
+
if (seen.has(plugin.name)) return false;
|
|
1598
|
+
seen.add(plugin.name);
|
|
1599
|
+
return true;
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
async getPluginRuntimePackages(): Promise<string[]> {
|
|
1603
|
+
const plugins = await this.collectPlugins();
|
|
1604
|
+
const appConfig = await this.getConfig();
|
|
1605
|
+
const ctx: PluginRuntimeContext = {
|
|
1606
|
+
appName: this.name,
|
|
1607
|
+
mobile: appConfig.mobile,
|
|
1608
|
+
hasMobilePermission: (permission) =>
|
|
1609
|
+
Object.values(appConfig.mobile.targets).some((target) => target.permissions?.includes(permission) ?? false),
|
|
1610
|
+
};
|
|
1611
|
+
return [...new Set(plugins.flatMap((plugin) => plugin.runtimePackages?.(ctx) ?? []))];
|
|
1572
1612
|
}
|
|
1573
1613
|
async increaseBuildNum() {
|
|
1574
1614
|
await increaseBuildNum(this);
|
|
@@ -3,7 +3,7 @@ import type { ChangeKind, DevChangeAction, DevChangePlan, DevChangeRole } from "
|
|
|
3
3
|
|
|
4
4
|
const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
5
5
|
const CONFIG_BASENAMES = new Set(["akan.config.ts", "bunfig.toml", "tsconfig.json", "package.json"]);
|
|
6
|
-
const BARREL_FACETS = new Set(["common", "srvkit", "ui", "webkit"]);
|
|
6
|
+
const BARREL_FACETS = new Set(["common", "srvkit", "ui", "webkit", "plugin"]);
|
|
7
7
|
const CLIENT_SUFFIXES = [".Template.tsx", ".Unit.tsx", ".Util.tsx", ".View.tsx", ".Zone.tsx", ".store.ts"];
|
|
8
8
|
const SHARED_SUFFIXES = [".constant.ts", ".dictionary.ts", ".signal.ts"];
|
|
9
9
|
const SERVER_SUFFIXES = [".service.ts", ".document.ts"];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
-
const BARREL_FACETS = new Set(["common", "srvkit", "ui", "webkit"]);
|
|
4
|
+
const BARREL_FACETS = new Set(["common", "srvkit", "ui", "webkit", "plugin"]);
|
|
5
5
|
const FACET_SOURCE_FILE_RE = /\.(ts|tsx)$/;
|
|
6
6
|
const FACET_EXCLUDED_FILE_RE = /(^index\.tsx?$|\.d\.ts$|\.(test|spec)\.(ts|tsx)$|\.css$|\.scss$|\.sass$)/;
|
|
7
7
|
// `ui` exports PascalCase names only; `common`/`srvkit`/`webkit` export camelCase names only. Names with
|
package/index.ts
CHANGED
|
@@ -18,7 +18,6 @@ export * from "./dependencyScanner";
|
|
|
18
18
|
export * from "./executors";
|
|
19
19
|
export * from "./extractDeps";
|
|
20
20
|
export * from "./fileSys";
|
|
21
|
-
export * from "./firebaseMessagingSw";
|
|
22
21
|
export * from "./frontendBuild";
|
|
23
22
|
export * from "./getCredentials";
|
|
24
23
|
export * from "./getDirname";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.13-rc.1",
|
|
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.
|
|
35
|
+
"akanjs": "2.3.13-rc.1",
|
|
36
36
|
"chalk": "^5.6.2",
|
|
37
37
|
"commander": "^14.0.3",
|
|
38
38
|
"daisyui": "5.5.23",
|
package/qualityScanner.ts
CHANGED
|
@@ -74,7 +74,7 @@ const SUGGESTED_RULES = [
|
|
|
74
74
|
"Dictionary text should not contain scaffold wording, misspellings, or stale copied domain nouns.",
|
|
75
75
|
"Warn earlier on very long Akan files: 500 lines for services, 800 lines for Template/Zone files, and 1000 lines for Util files.",
|
|
76
76
|
"Global declarations, Window augmentation, and prototype mutation should stay in explicitly approved low-level integration files.",
|
|
77
|
-
"Keep app root folders small and conventional: application code belongs under common, env, lib, page, private, public, script, srvkit, ui, or webkit.",
|
|
77
|
+
"Keep app root folders small and conventional: application code belongs under common, env, lib, page, plugin, private, public, script, srvkit, ui, or webkit.",
|
|
78
78
|
"Keep apps/*/lib and libs/*/lib root files limited to generated support facets such as cnst.ts, db.ts, dict.ts, sig.ts, srv.ts, st.ts, useClient.ts, and useServer.ts.",
|
|
79
79
|
"Use domain module folders consistently: lib/<model> for database modules, lib/_<service> for service modules, and lib/__scalar/<scalar> for scalar modules.",
|
|
80
80
|
"Keep module UI filenames predictable: database modules use <Model>.Template/Unit/Util/View/Zone.tsx, service modules use <Service>.Util/Zone.tsx, and scalar modules use <Scalar>.Template/Unit.tsx.",
|
|
@@ -1,66 +0,0 @@
|
|
|
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
|
-
});
|
package/firebaseMessagingSw.ts
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
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
|
-
}
|