@akanjs/devkit 2.3.6-rc.1 → 2.3.6-rc.3
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.
|
@@ -63,6 +63,7 @@ describe("resolveSsrPageEntries", () => {
|
|
|
63
63
|
expect(generatedSource).not.toContain("Object.keys(inheritedLayout.metadata)");
|
|
64
64
|
expect(generatedSource).toContain("export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;");
|
|
65
65
|
expect(generatedSource).toContain("export const Error = userLayout.Error ?? inheritedLayout.Error;");
|
|
66
|
+
expect(generatedSource).toContain("export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;");
|
|
66
67
|
expect(generatedSource).toContain(
|
|
67
68
|
"<UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>",
|
|
68
69
|
);
|
|
@@ -188,6 +188,7 @@ export async function generateMetadata(props: PageProps) {
|
|
|
188
188
|
|
|
189
189
|
export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
|
|
190
190
|
export const Error = userLayout.Error ?? inheritedLayout.Error;
|
|
191
|
+
export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
|
|
191
192
|
|
|
192
193
|
export default function GeneratedLayout({ children, params, searchParams }: LayoutProps) {
|
|
193
194
|
return (
|
|
@@ -229,6 +230,7 @@ export async function generateMetadata(props: PageProps) {
|
|
|
229
230
|
|
|
230
231
|
export const NotFound = userLayout.NotFound ?? inheritedLayout.NotFound;
|
|
231
232
|
export const Error = userLayout.Error ?? inheritedLayout.Error;
|
|
233
|
+
export const pageConfig = userLayout.pageConfig ?? inheritedLayout.pageConfig;
|
|
232
234
|
|
|
233
235
|
export default function GeneratedLayout({ children, params, searchParams }: LayoutProps) {
|
|
234
236
|
return <UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { AkanMobileTargetConfig } from "./akanConfig";
|
|
6
|
+
import {
|
|
7
|
+
assertJsonSerializable,
|
|
8
|
+
buildIosNativeRunCommand,
|
|
9
|
+
classifyIosRunFailure,
|
|
10
|
+
clearRootCapacitorConfigs,
|
|
11
|
+
formatAndroidReleaseSigningError,
|
|
12
|
+
getAdbDeviceStateIssues,
|
|
13
|
+
getMissingAndroidReleaseSigningKeys,
|
|
14
|
+
materializeCapacitorConfig,
|
|
15
|
+
parseDevicectlDevices,
|
|
16
|
+
parseSimctlDevices,
|
|
17
|
+
rootCapacitorConfigFilenames,
|
|
18
|
+
sanitizeIosNativeRunEnv,
|
|
19
|
+
writeRootCapacitorConfig,
|
|
20
|
+
} from "./capacitorApp";
|
|
21
|
+
|
|
22
|
+
const tempRoots: string[] = [];
|
|
23
|
+
|
|
24
|
+
const makeTempRoot = async () => {
|
|
25
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-capacitor-app-"));
|
|
26
|
+
tempRoots.push(root);
|
|
27
|
+
return root;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const baseTarget: AkanMobileTargetConfig = {
|
|
31
|
+
name: "default",
|
|
32
|
+
appName: "Minimal",
|
|
33
|
+
appId: "com.minimal.app",
|
|
34
|
+
version: "1.2.3",
|
|
35
|
+
buildNum: 7,
|
|
36
|
+
basePath: "admin",
|
|
37
|
+
assets: { icon: "mobile/icon.png" },
|
|
38
|
+
permissions: ["camera"],
|
|
39
|
+
links: { schemes: ["minimal"] },
|
|
40
|
+
files: { android: { "app/google-services.json": "private/google-services.json" } },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("materializeCapacitorConfig", () => {
|
|
48
|
+
test("writes only Capacitor fields for release config", () => {
|
|
49
|
+
const config = materializeCapacitorConfig(
|
|
50
|
+
{
|
|
51
|
+
...baseTarget,
|
|
52
|
+
plugins: { CapacitorHttp: { enabled: true } },
|
|
53
|
+
android: { flavor: "qa" },
|
|
54
|
+
ios: { scheme: "App QA" },
|
|
55
|
+
cordova: { preferences: { ScrollEnabled: "false" } },
|
|
56
|
+
experimental: { ios: { spm: { swiftToolsVersion: "5.9" } } },
|
|
57
|
+
},
|
|
58
|
+
{ operation: "release" },
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(config).toMatchObject({
|
|
62
|
+
appId: "com.minimal.app",
|
|
63
|
+
appName: "Minimal",
|
|
64
|
+
webDir: ".akan/mobile/default/www",
|
|
65
|
+
plugins: {
|
|
66
|
+
CapacitorCookies: { enabled: true },
|
|
67
|
+
CapacitorHttp: { enabled: true },
|
|
68
|
+
Keyboard: { resize: "none" },
|
|
69
|
+
},
|
|
70
|
+
android: { flavor: "qa", path: "android" },
|
|
71
|
+
ios: { scheme: "App QA", path: "ios" },
|
|
72
|
+
cordova: { preferences: { ScrollEnabled: "false" } },
|
|
73
|
+
experimental: { ios: { spm: { swiftToolsVersion: "5.9" } } },
|
|
74
|
+
});
|
|
75
|
+
expect(config).not.toHaveProperty("name");
|
|
76
|
+
expect(config).not.toHaveProperty("basePath");
|
|
77
|
+
expect(config).not.toHaveProperty("version");
|
|
78
|
+
expect(config).not.toHaveProperty("buildNum");
|
|
79
|
+
expect(config).not.toHaveProperty("assets");
|
|
80
|
+
expect(config).not.toHaveProperty("permissions");
|
|
81
|
+
expect(config).not.toHaveProperty("links");
|
|
82
|
+
expect(config).not.toHaveProperty("files");
|
|
83
|
+
expect(config).not.toHaveProperty("server");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("adds local server config without requiring env target switching", () => {
|
|
87
|
+
const config = materializeCapacitorConfig(
|
|
88
|
+
{
|
|
89
|
+
...baseTarget,
|
|
90
|
+
server: {
|
|
91
|
+
hostname: "localhost",
|
|
92
|
+
allowNavigation: ["api.example.com"],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
operation: "local",
|
|
97
|
+
localIp: "192.168.0.5",
|
|
98
|
+
localServerUrl: "http://192.168.0.5:8282/en/admin?csr=true&akanMobileTarget=default",
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
expect(config.server).toEqual({
|
|
103
|
+
hostname: "localhost",
|
|
104
|
+
androidScheme: "http",
|
|
105
|
+
url: "http://192.168.0.5:8282/en/admin?csr=true&akanMobileTarget=default",
|
|
106
|
+
cleartext: true,
|
|
107
|
+
allowNavigation: ["api.example.com", "192.168.0.5", "localhost"],
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("allows mobile targets to override the default keyboard resize mode", () => {
|
|
112
|
+
const config = materializeCapacitorConfig(
|
|
113
|
+
{
|
|
114
|
+
...baseTarget,
|
|
115
|
+
plugins: { Keyboard: { resize: "native" } },
|
|
116
|
+
},
|
|
117
|
+
{ operation: "release" },
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(config.plugins).toMatchObject({
|
|
121
|
+
Keyboard: { resize: "native" },
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("rejects non-json config values", () => {
|
|
126
|
+
expect(() => assertJsonSerializable({ plugins: { Custom: () => null } })).toThrow("must be JSON serializable");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("root capacitor config helpers", () => {
|
|
131
|
+
test("clears root configs before writing the temporary json config", async () => {
|
|
132
|
+
const root = await makeTempRoot();
|
|
133
|
+
await Promise.all(
|
|
134
|
+
rootCapacitorConfigFilenames.map((file) => writeFile(path.join(root, file), `old ${file}\n`)),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
await writeRootCapacitorConfig(root, "{\n \"appId\": \"com.minimal.app\"\n}\n");
|
|
138
|
+
|
|
139
|
+
expect(await Bun.file(path.join(root, "capacitor.config.ts")).exists()).toBe(false);
|
|
140
|
+
expect(await Bun.file(path.join(root, "capacitor.config.js")).exists()).toBe(false);
|
|
141
|
+
expect(await Bun.file(path.join(root, "capacitor.config.json")).text()).toBe(
|
|
142
|
+
"{\n \"appId\": \"com.minimal.app\"\n}\n",
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
await clearRootCapacitorConfigs(root);
|
|
146
|
+
|
|
147
|
+
expect(await Bun.file(path.join(root, "capacitor.config.json")).exists()).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("iOS native run helpers", () => {
|
|
152
|
+
test("parses xcrun device and simulator outputs", () => {
|
|
153
|
+
expect(
|
|
154
|
+
parseDevicectlDevices(
|
|
155
|
+
JSON.stringify({
|
|
156
|
+
result: {
|
|
157
|
+
devices: [
|
|
158
|
+
{
|
|
159
|
+
identifier: "1BCB6563-02FF-5D49-9655-1BFF02A638D3",
|
|
160
|
+
connectionProperties: { pairingState: "paired", transportType: "wired", tunnelState: "available" },
|
|
161
|
+
deviceProperties: { name: "Seok iPhone" },
|
|
162
|
+
hardwareProperties: { udid: "00008130-000200113EC1001C" },
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
}),
|
|
167
|
+
),
|
|
168
|
+
).toEqual([
|
|
169
|
+
{
|
|
170
|
+
id: "00008130-000200113EC1001C",
|
|
171
|
+
name: "Seok iPhone",
|
|
172
|
+
kind: "device",
|
|
173
|
+
state: "available wired paired",
|
|
174
|
+
devicectlId: "1BCB6563-02FF-5D49-9655-1BFF02A638D3",
|
|
175
|
+
xcodebuildId: "00008130-000200113EC1001C",
|
|
176
|
+
},
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
expect(
|
|
180
|
+
parseSimctlDevices(
|
|
181
|
+
JSON.stringify({
|
|
182
|
+
devices: {
|
|
183
|
+
"iOS 18.0": [
|
|
184
|
+
{ udid: "11111111-2222-3333-4444-555555555555", name: "iPhone 16", state: "Shutdown", isAvailable: true },
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
),
|
|
189
|
+
).toEqual([{ id: "11111111-2222-3333-4444-555555555555", name: "iPhone 16", kind: "simulator", state: "Shutdown" }]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("builds xcodebuild destinations and output paths by target kind", () => {
|
|
193
|
+
const deviceCommand = buildIosNativeRunCommand({
|
|
194
|
+
appRoot: "/repo/apps/minimal",
|
|
195
|
+
device: { id: "device-1", name: "iPhone", kind: "device" },
|
|
196
|
+
scheme: "App QA",
|
|
197
|
+
configuration: "Debug",
|
|
198
|
+
});
|
|
199
|
+
expect(deviceCommand.xcodebuildArgs).toContain("id=device-1");
|
|
200
|
+
expect(deviceCommand.xcodebuildArgs).toContain("App QA");
|
|
201
|
+
expect(deviceCommand.appPath).toBe("/repo/apps/minimal/ios/DerivedData/device-1/Build/Products/Debug-iphoneos/App.app");
|
|
202
|
+
|
|
203
|
+
const simulatorCommand = buildIosNativeRunCommand({
|
|
204
|
+
appRoot: "/repo/apps/minimal",
|
|
205
|
+
device: { id: "sim-1", name: "Simulator", kind: "simulator" },
|
|
206
|
+
});
|
|
207
|
+
expect(simulatorCommand.xcodebuildArgs).toContain("platform=iOS Simulator,id=sim-1");
|
|
208
|
+
expect(simulatorCommand.appPath).toBe(
|
|
209
|
+
"/repo/apps/minimal/ios/DerivedData/sim-1/Build/Products/Debug-iphonesimulator/App.app",
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("classifies iOS signing and device-state failures", () => {
|
|
214
|
+
expect(classifyIosRunFailure("There are no accounts registered with Xcode").kind).toBe("apple-account");
|
|
215
|
+
expect(classifyIosRunFailure('Failed Registering Bundle Identifier: The app identifier "com.minimal.app" cannot be registered to your development team').kind).toBe("bundle-identifier");
|
|
216
|
+
expect(classifyIosRunFailure("No profiles for 'com.minimal.app' were found").kind).toBe("provisioning-profile");
|
|
217
|
+
expect(classifyIosRunFailure("Developer Mode is disabled on this device").kind).toBe("device-state");
|
|
218
|
+
expect(classifyIosRunFailure("arm64-apple-darwin20.0: error: unknown argument: '-index-store-path'").kind).toBe(
|
|
219
|
+
"compiler-toolchain",
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("removes shell compiler variables from iOS native run env", () => {
|
|
224
|
+
expect(
|
|
225
|
+
sanitizeIosNativeRunEnv({
|
|
226
|
+
AKAN_PUBLIC_APP_NAME: "minimal",
|
|
227
|
+
CC: "arm64-apple-darwin20.0.0-clang",
|
|
228
|
+
CXX: "arm64-apple-darwin20.0.0-clang++",
|
|
229
|
+
SDKROOT: "/wrong/sdk",
|
|
230
|
+
}),
|
|
231
|
+
).toEqual({ AKAN_PUBLIC_APP_NAME: "minimal" });
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("Android signing diagnostics", () => {
|
|
236
|
+
test("reports missing release signing keys and adb authorization issues", () => {
|
|
237
|
+
expect(
|
|
238
|
+
getMissingAndroidReleaseSigningKeys({
|
|
239
|
+
env: { MYAPP_RELEASE_STORE_FILE: "release.jks" } as NodeJS.ProcessEnv,
|
|
240
|
+
gradleProperties: "MYAPP_RELEASE_STORE_PASSWORD=secret\n",
|
|
241
|
+
}),
|
|
242
|
+
).toEqual(["MYAPP_RELEASE_KEY_ALIAS", "MYAPP_RELEASE_KEY_PASSWORD"]);
|
|
243
|
+
expect(formatAndroidReleaseSigningError(["MYAPP_RELEASE_KEY_ALIAS"])).toContain("MYAPP_RELEASE_KEY_ALIAS");
|
|
244
|
+
expect(getAdbDeviceStateIssues("List of devices attached\nabc123 unauthorized\nxyz offline\n")).toEqual([
|
|
245
|
+
"Android device abc123 is unauthorized. Confirm USB debugging authorization on the device.",
|
|
246
|
+
"Android device xyz is offline. Reconnect the device or restart adb.",
|
|
247
|
+
]);
|
|
248
|
+
});
|
|
249
|
+
});
|
package/capacitorApp.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { cp, mkdir, rm } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
4
|
+
import type { CapacitorConfig } from "@capacitor/cli";
|
|
5
|
+
import { select } from "@inquirer/prompts";
|
|
3
6
|
import { MobileProject } from "@trapezedev/project";
|
|
4
7
|
import type { AndroidProject } from "@trapezedev/project/dist/android/project";
|
|
5
8
|
import type { IosProject } from "@trapezedev/project/dist/ios/project";
|
|
6
9
|
import { capitalize } from "akanjs/common";
|
|
7
10
|
import type { AkanMobileTargetConfig } from "./akanConfig";
|
|
8
|
-
import type
|
|
11
|
+
import { type AppExecutor, CommandExecutionError } from "./executors";
|
|
9
12
|
import { FileEditor } from "./fileEditor";
|
|
10
13
|
import { resolveMobilePath, targetHtmlFilename } from "./mobile";
|
|
11
14
|
|
|
@@ -15,8 +18,517 @@ interface RunConfig {
|
|
|
15
18
|
regenerate?: boolean;
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
interface RunIosConfig extends RunConfig {
|
|
22
|
+
noAllowProvisioningUpdates?: boolean;
|
|
23
|
+
iosDeviceId?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
interface PrepareConfig extends RunConfig {}
|
|
19
27
|
|
|
28
|
+
type MobileCommandEnv = Record<string, string | undefined>;
|
|
29
|
+
|
|
30
|
+
export type IosRunTargetKind = "device" | "simulator";
|
|
31
|
+
export interface IosRunTarget {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
kind: IosRunTargetKind;
|
|
35
|
+
state?: string;
|
|
36
|
+
devicectlId?: string;
|
|
37
|
+
xcodebuildId?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface IosNativeRunCommand {
|
|
41
|
+
xcodebuildArgs: string[];
|
|
42
|
+
appPath: string;
|
|
43
|
+
configuration: "Debug" | "Release";
|
|
44
|
+
derivedDataPath: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type IosRunFailureKind =
|
|
48
|
+
| "apple-account"
|
|
49
|
+
| "bundle-identifier"
|
|
50
|
+
| "compiler-toolchain"
|
|
51
|
+
| "team-permission"
|
|
52
|
+
| "license-agreement"
|
|
53
|
+
| "certificate"
|
|
54
|
+
| "provisioning-profile"
|
|
55
|
+
| "device-registration"
|
|
56
|
+
| "device-state"
|
|
57
|
+
| "devicectl-unavailable"
|
|
58
|
+
| "unknown";
|
|
59
|
+
|
|
60
|
+
export interface IosRunFailureClassification {
|
|
61
|
+
kind: IosRunFailureKind;
|
|
62
|
+
title: string;
|
|
63
|
+
detail: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const iosNativeBlockedEnvKeys = new Set([
|
|
67
|
+
"AR",
|
|
68
|
+
"AS",
|
|
69
|
+
"CC",
|
|
70
|
+
"CFLAGS",
|
|
71
|
+
"CONDA_BUILD_SYSROOT",
|
|
72
|
+
"CONDA_PREFIX",
|
|
73
|
+
"CPP",
|
|
74
|
+
"CPPFLAGS",
|
|
75
|
+
"CPATH",
|
|
76
|
+
"CXX",
|
|
77
|
+
"CXXFLAGS",
|
|
78
|
+
"LD",
|
|
79
|
+
"LDFLAGS",
|
|
80
|
+
"LIBRARY_PATH",
|
|
81
|
+
"MACOSX_DEPLOYMENT_TARGET",
|
|
82
|
+
"NM",
|
|
83
|
+
"OBJC",
|
|
84
|
+
"OBJCXX",
|
|
85
|
+
"PREFIX",
|
|
86
|
+
"RANLIB",
|
|
87
|
+
"SDKROOT",
|
|
88
|
+
"STRIP",
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
export const rootCapacitorConfigFilenames = [
|
|
92
|
+
"capacitor.config.ts",
|
|
93
|
+
"capacitor.config.js",
|
|
94
|
+
"capacitor.config.json",
|
|
95
|
+
] as const;
|
|
96
|
+
|
|
97
|
+
export const rootCapacitorConfigPaths = (appRoot: string) =>
|
|
98
|
+
rootCapacitorConfigFilenames.map((file) => path.join(appRoot, file));
|
|
99
|
+
|
|
100
|
+
export async function clearRootCapacitorConfigs(appRoot: string) {
|
|
101
|
+
await Promise.all(rootCapacitorConfigPaths(appRoot).map((file) => rm(file, { force: true })));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function writeRootCapacitorConfig(appRoot: string, content: string) {
|
|
105
|
+
await clearRootCapacitorConfigs(appRoot);
|
|
106
|
+
await Bun.write(path.join(appRoot, "capacitor.config.json"), content);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface MaterializeCapacitorConfigOptions {
|
|
110
|
+
operation: RunConfig["operation"];
|
|
111
|
+
localServerUrl?: string;
|
|
112
|
+
localIp?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const getLocalIP = () => {
|
|
116
|
+
const interfaces = os.networkInterfaces();
|
|
117
|
+
for (const iface of Object.values(interfaces)) {
|
|
118
|
+
if (!iface) continue;
|
|
119
|
+
for (const alias of iface) {
|
|
120
|
+
if (alias.family === "IPv4" && !alias.internal) return alias.address;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return "127.0.0.1";
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
127
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
128
|
+
|
|
129
|
+
const asString = (value: unknown) => (typeof value === "string" ? value : undefined);
|
|
130
|
+
|
|
131
|
+
const firstString = (...values: unknown[]) => values.find((value): value is string => typeof value === "string");
|
|
132
|
+
|
|
133
|
+
const scoreIosDeviceTarget = (target: IosRunTarget) => {
|
|
134
|
+
const state = target.state?.toLowerCase() ?? "";
|
|
135
|
+
return (state.includes("available") ? 4 : 0) + (state.includes("wired") ? 2 : 0) + (state.includes("paired") ? 1 : 0);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const dedupeIosRunTargets = (targets: IosRunTarget[]) => {
|
|
139
|
+
const byKey = new Map<string, IosRunTarget>();
|
|
140
|
+
const runnableTargets = targets.filter((target) => !target.state?.toLowerCase().includes("unavailable"));
|
|
141
|
+
for (const target of runnableTargets) {
|
|
142
|
+
const key = target.xcodebuildId ?? target.id;
|
|
143
|
+
const current = byKey.get(key);
|
|
144
|
+
if (!current || scoreIosDeviceTarget(target) > scoreIosDeviceTarget(current)) byKey.set(key, target);
|
|
145
|
+
}
|
|
146
|
+
return [...byKey.values()];
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
function walkRecords(value: unknown, visit: (record: Record<string, unknown>) => void) {
|
|
150
|
+
if (Array.isArray(value)) {
|
|
151
|
+
for (const item of value) walkRecords(item, visit);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (!isRecord(value)) return;
|
|
155
|
+
visit(value);
|
|
156
|
+
for (const item of Object.values(value)) walkRecords(item, visit);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function parseDevicectlDevices(output: string): IosRunTarget[] {
|
|
160
|
+
try {
|
|
161
|
+
const json = JSON.parse(output) as unknown;
|
|
162
|
+
const targets = new Map<string, IosRunTarget>();
|
|
163
|
+
walkRecords(json, (record) => {
|
|
164
|
+
const deviceProperties = isRecord(record.deviceProperties) ? record.deviceProperties : {};
|
|
165
|
+
const hardwareProperties = isRecord(record.hardwareProperties) ? record.hardwareProperties : {};
|
|
166
|
+
const connectionProperties = isRecord(record.connectionProperties) ? record.connectionProperties : {};
|
|
167
|
+
const devicectlId = firstString(record.identifier, record.deviceIdentifier);
|
|
168
|
+
const potentialHostnames = Array.isArray(connectionProperties.potentialHostnames)
|
|
169
|
+
? connectionProperties.potentialHostnames.filter((value): value is string => typeof value === "string")
|
|
170
|
+
: [];
|
|
171
|
+
const hostnameUdid = potentialHostnames
|
|
172
|
+
.map((hostname) => hostname.match(/([0-9A-Fa-f]{8}-[0-9A-Fa-f]{16})\.coredevice\.local/)?.[1])
|
|
173
|
+
.find((value): value is string => Boolean(value));
|
|
174
|
+
const udid = firstString(hardwareProperties.udid, hostnameUdid, record.udid, record.UDID);
|
|
175
|
+
const id = udid ?? devicectlId;
|
|
176
|
+
const name = firstString(deviceProperties.name, record.name, record.deviceName, record.displayName);
|
|
177
|
+
if (!id || !name) return;
|
|
178
|
+
const state = [
|
|
179
|
+
firstString(record.state, record.connectionState, record.availability, connectionProperties.tunnelState),
|
|
180
|
+
firstString(connectionProperties.transportType),
|
|
181
|
+
firstString(connectionProperties.pairingState),
|
|
182
|
+
]
|
|
183
|
+
.filter(Boolean)
|
|
184
|
+
.join(" ");
|
|
185
|
+
targets.set(id, { id, name, kind: "device", state, devicectlId, xcodebuildId: udid ?? id });
|
|
186
|
+
});
|
|
187
|
+
return dedupeIosRunTargets([...targets.values()]);
|
|
188
|
+
} catch {
|
|
189
|
+
const targets: IosRunTarget[] = [];
|
|
190
|
+
for (const line of output.split(/\r?\n/)) {
|
|
191
|
+
const id = line.match(/[0-9A-Fa-f]{8}-[0-9A-Fa-f]{16}|[0-9A-Fa-f-]{25,}/)?.[0];
|
|
192
|
+
if (!id) continue;
|
|
193
|
+
const name = line.replace(id, "").replace(/[()]/g, " ").trim().replace(/\s+/g, " ") || id;
|
|
194
|
+
targets.push({ id, name, kind: "device" });
|
|
195
|
+
}
|
|
196
|
+
return dedupeIosRunTargets(targets);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function parseSimctlDevices(output: string): IosRunTarget[] {
|
|
201
|
+
try {
|
|
202
|
+
const json = JSON.parse(output) as { devices?: Record<string, unknown[]> };
|
|
203
|
+
const devices = json.devices ?? {};
|
|
204
|
+
return Object.values(devices)
|
|
205
|
+
.flatMap((runtimeDevices) => runtimeDevices)
|
|
206
|
+
.filter(isRecord)
|
|
207
|
+
.flatMap((device) => {
|
|
208
|
+
const id = firstString(device.udid, device.UDID, device.identifier);
|
|
209
|
+
const name = firstString(device.name, device.displayName);
|
|
210
|
+
const isAvailable = device.isAvailable !== false && device.availabilityError === undefined;
|
|
211
|
+
if (!id || !name || !isAvailable) return [];
|
|
212
|
+
return [{ id, name, kind: "simulator" as const, state: asString(device.state) }];
|
|
213
|
+
});
|
|
214
|
+
} catch {
|
|
215
|
+
const targets: IosRunTarget[] = [];
|
|
216
|
+
for (const line of output.split(/\r?\n/)) {
|
|
217
|
+
const match = line.match(/^\s*(.+?)\s+\(([0-9A-Fa-f-]{20,})\)\s+\(([^)]+)\)/);
|
|
218
|
+
if (!match) continue;
|
|
219
|
+
targets.push({ id: match[2], name: match[1].trim(), kind: "simulator", state: match[3] });
|
|
220
|
+
}
|
|
221
|
+
return targets;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function buildIosNativeRunCommand({
|
|
226
|
+
appRoot,
|
|
227
|
+
device,
|
|
228
|
+
scheme = "App",
|
|
229
|
+
configuration = "Debug",
|
|
230
|
+
}: {
|
|
231
|
+
appRoot: string;
|
|
232
|
+
device: IosRunTarget;
|
|
233
|
+
scheme?: string;
|
|
234
|
+
configuration?: "Debug" | "Release";
|
|
235
|
+
}): IosNativeRunCommand {
|
|
236
|
+
const derivedDataPath = path.join(appRoot, "ios/DerivedData", device.id);
|
|
237
|
+
const productPlatform = device.kind === "device" ? "iphoneos" : "iphonesimulator";
|
|
238
|
+
const destination =
|
|
239
|
+
device.kind === "device" ? `id=${device.xcodebuildId ?? device.id}` : `platform=iOS Simulator,id=${device.id}`;
|
|
240
|
+
return {
|
|
241
|
+
configuration,
|
|
242
|
+
derivedDataPath,
|
|
243
|
+
appPath: path.join(derivedDataPath, "Build/Products", `${configuration}-${productPlatform}`, "App.app"),
|
|
244
|
+
xcodebuildArgs: [
|
|
245
|
+
"-project",
|
|
246
|
+
"App.xcodeproj",
|
|
247
|
+
"-scheme",
|
|
248
|
+
scheme,
|
|
249
|
+
"-configuration",
|
|
250
|
+
configuration,
|
|
251
|
+
"-destination",
|
|
252
|
+
destination,
|
|
253
|
+
"-derivedDataPath",
|
|
254
|
+
derivedDataPath,
|
|
255
|
+
"build",
|
|
256
|
+
],
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function classifyIosRunFailure(log: string): IosRunFailureClassification {
|
|
261
|
+
const lower = log.toLowerCase();
|
|
262
|
+
if (lower.includes("unknown argument: '-index-store-path'") || lower.includes("compiler was not recognized")) {
|
|
263
|
+
return {
|
|
264
|
+
kind: "compiler-toolchain",
|
|
265
|
+
title: "iOS build is using a non-Xcode compiler from the shell environment.",
|
|
266
|
+
detail:
|
|
267
|
+
"Akan removes common Conda/compiler environment variables for native iOS runs. If this persists, run outside the activated toolchain environment.",
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
if (lower.includes("developer mode") && lower.includes("disabled")) {
|
|
271
|
+
return {
|
|
272
|
+
kind: "device-state",
|
|
273
|
+
title: "iOS device Developer Mode is disabled.",
|
|
274
|
+
detail: "Enable Developer Mode on the iPhone, then reconnect and run the command again.",
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
if (lower.includes("untrusted") || lower.includes("not paired") || lower.includes("locked")) {
|
|
278
|
+
return {
|
|
279
|
+
kind: "device-state",
|
|
280
|
+
title: "iOS device is not ready for installation.",
|
|
281
|
+
detail: "Unlock the iPhone, trust this computer, and make sure the device is paired before retrying.",
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
if (
|
|
285
|
+
lower.includes('unable to find utility "devicectl"') ||
|
|
286
|
+
(lower.includes("devicectl") && lower.includes("not found"))
|
|
287
|
+
) {
|
|
288
|
+
return {
|
|
289
|
+
kind: "devicectl-unavailable",
|
|
290
|
+
title: "Xcode devicectl is not available.",
|
|
291
|
+
detail: "Install a recent Xcode version and verify xcode-select points to that Xcode installation.",
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (
|
|
295
|
+
lower.includes("there are no accounts registered with xcode") ||
|
|
296
|
+
lower.includes("unable to log in with account")
|
|
297
|
+
) {
|
|
298
|
+
return {
|
|
299
|
+
kind: "apple-account",
|
|
300
|
+
title: "Xcode Apple ID is not available.",
|
|
301
|
+
detail: "Sign in to an Apple ID in Xcode Settings > Accounts, then retry the Akan iOS command.",
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
if (
|
|
305
|
+
lower.includes("failed registering bundle identifier") ||
|
|
306
|
+
lower.includes("cannot be registered to your development team")
|
|
307
|
+
) {
|
|
308
|
+
return {
|
|
309
|
+
kind: "bundle-identifier",
|
|
310
|
+
title: "iOS bundle identifier is not available for this Apple Developer Team.",
|
|
311
|
+
detail:
|
|
312
|
+
"Change mobile appId to a globally unique bundle identifier that your team can register, then rerun the iOS command.",
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
if (lower.includes("does not have permission") || (lower.includes("your account") && lower.includes("permission"))) {
|
|
316
|
+
return {
|
|
317
|
+
kind: "team-permission",
|
|
318
|
+
title: "Apple Developer Team permission is missing.",
|
|
319
|
+
detail: "Check that the signed-in Apple ID has permission for the selected DEVELOPMENT_TEAM.",
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
if (lower.includes("license agreement") || lower.includes("program license agreement")) {
|
|
323
|
+
return {
|
|
324
|
+
kind: "license-agreement",
|
|
325
|
+
title: "Apple Developer Program license agreement is not accepted.",
|
|
326
|
+
detail: "Accept the latest Apple Developer Program license agreement, then retry.",
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
if (lower.includes("no signing certificate") || lower.includes("doesn't include signing certificate")) {
|
|
330
|
+
return {
|
|
331
|
+
kind: "certificate",
|
|
332
|
+
title: "iOS development signing certificate is missing.",
|
|
333
|
+
detail: "Create or download an Apple Development certificate for the selected team.",
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
if (lower.includes("device") && lower.includes("not") && lower.includes("registered")) {
|
|
337
|
+
return {
|
|
338
|
+
kind: "device-registration",
|
|
339
|
+
title: "iPhone is not registered in the provisioning profile.",
|
|
340
|
+
detail: "Allow provisioning updates with a team that can register this device, or register the device manually.",
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
if (lower.includes("no profiles for") || lower.includes("requires a provisioning profile")) {
|
|
344
|
+
return {
|
|
345
|
+
kind: "provisioning-profile",
|
|
346
|
+
title: "Matching iOS provisioning profile was not found.",
|
|
347
|
+
detail:
|
|
348
|
+
"Akan can request Xcode provisioning updates for physical devices, but the Apple account and team must be valid.",
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
kind: "unknown",
|
|
353
|
+
title: "iOS native run failed.",
|
|
354
|
+
detail:
|
|
355
|
+
"Review the xcodebuild/devicectl output above. You can retry with --noAllowProvisioningUpdates to use the conservative path.",
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function formatIosRunFailureMessage(input: {
|
|
360
|
+
classification: IosRunFailureClassification;
|
|
361
|
+
appId: string;
|
|
362
|
+
targetName: string;
|
|
363
|
+
teamId?: string;
|
|
364
|
+
}) {
|
|
365
|
+
return [
|
|
366
|
+
input.classification.title,
|
|
367
|
+
input.classification.detail,
|
|
368
|
+
`Mobile target: ${input.targetName}`,
|
|
369
|
+
`Bundle ID: ${input.appId}`,
|
|
370
|
+
input.teamId ? `Development Team: ${input.teamId}` : null,
|
|
371
|
+
"Capacitor is still used for native project generation and sync; Akan only runs the native build/install step directly.",
|
|
372
|
+
]
|
|
373
|
+
.filter((line): line is string => Boolean(line))
|
|
374
|
+
.join("\n");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function sanitizeIosNativeRunEnv(env: MobileCommandEnv): MobileCommandEnv {
|
|
378
|
+
return Object.fromEntries(Object.entries(env).filter(([key]) => !iosNativeBlockedEnvKeys.has(key)));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const androidReleaseSigningKeys = [
|
|
382
|
+
"MYAPP_RELEASE_STORE_FILE",
|
|
383
|
+
"MYAPP_RELEASE_STORE_PASSWORD",
|
|
384
|
+
"MYAPP_RELEASE_KEY_ALIAS",
|
|
385
|
+
"MYAPP_RELEASE_KEY_PASSWORD",
|
|
386
|
+
] as const;
|
|
387
|
+
|
|
388
|
+
export function getMissingAndroidReleaseSigningKeys({
|
|
389
|
+
env = process.env,
|
|
390
|
+
gradleProperties = "",
|
|
391
|
+
}: {
|
|
392
|
+
env?: NodeJS.ProcessEnv;
|
|
393
|
+
gradleProperties?: string;
|
|
394
|
+
} = {}) {
|
|
395
|
+
return androidReleaseSigningKeys.filter((key) => {
|
|
396
|
+
const gradleEnvKey = `ORG_GRADLE_PROJECT_${key}`;
|
|
397
|
+
return (
|
|
398
|
+
env[key] === undefined &&
|
|
399
|
+
env[gradleEnvKey] === undefined &&
|
|
400
|
+
!new RegExp(`^\\s*${key}\\s*=`, "m").test(gradleProperties)
|
|
401
|
+
);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function formatAndroidReleaseSigningError(missingKeys: readonly string[]) {
|
|
406
|
+
return [
|
|
407
|
+
"Android release signing configuration is incomplete.",
|
|
408
|
+
`Missing: ${missingKeys.join(", ")}`,
|
|
409
|
+
"Set these values in android/gradle.properties or ORG_GRADLE_PROJECT_* environment variables before building a release artifact.",
|
|
410
|
+
].join("\n");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function getAdbDeviceStateIssues(output: string) {
|
|
414
|
+
return output
|
|
415
|
+
.split(/\r?\n/)
|
|
416
|
+
.map((line) => line.trim().split(/\s+/))
|
|
417
|
+
.filter(([id, state]) => id && state && id !== "List")
|
|
418
|
+
.flatMap(([id, state]) => {
|
|
419
|
+
if (state === "unauthorized")
|
|
420
|
+
return [`Android device ${id} is unauthorized. Confirm USB debugging authorization on the device.`];
|
|
421
|
+
if (state === "offline") return [`Android device ${id} is offline. Reconnect the device or restart adb.`];
|
|
422
|
+
return [];
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const mergeAllowNavigation = (configured: unknown, localIp: string | undefined) => {
|
|
427
|
+
const values = Array.isArray(configured)
|
|
428
|
+
? configured.filter((value): value is string => typeof value === "string")
|
|
429
|
+
: [];
|
|
430
|
+
if (localIp) values.push(localIp);
|
|
431
|
+
values.push("localhost");
|
|
432
|
+
return [...new Set(values)];
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
export function assertJsonSerializable(value: unknown, label = "capacitor.config", seen = new WeakSet<object>()) {
|
|
436
|
+
if (value === null) return;
|
|
437
|
+
const valueType = typeof value;
|
|
438
|
+
if (valueType === "function" || valueType === "symbol" || valueType === "bigint" || valueType === "undefined") {
|
|
439
|
+
throw new Error(`${label} must be JSON serializable. Found ${valueType}.`);
|
|
440
|
+
}
|
|
441
|
+
if (valueType === "number" && !Number.isFinite(value)) {
|
|
442
|
+
throw new Error(`${label} must be JSON serializable. Found non-finite number.`);
|
|
443
|
+
}
|
|
444
|
+
if (valueType !== "object") return;
|
|
445
|
+
const objectValue = value as object;
|
|
446
|
+
if (seen.has(objectValue)) throw new Error(`${label} must be JSON serializable. Found circular reference.`);
|
|
447
|
+
seen.add(objectValue);
|
|
448
|
+
if (Array.isArray(value)) {
|
|
449
|
+
value.forEach((item, index) => {
|
|
450
|
+
assertJsonSerializable(item, `${label}[${index}]`, seen);
|
|
451
|
+
});
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
|
455
|
+
assertJsonSerializable(item, `${label}.${key}`, seen);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function materializeCapacitorConfig(
|
|
460
|
+
target: AkanMobileTargetConfig,
|
|
461
|
+
{ operation, localServerUrl, localIp }: MaterializeCapacitorConfigOptions,
|
|
462
|
+
): CapacitorConfig {
|
|
463
|
+
const {
|
|
464
|
+
name,
|
|
465
|
+
basePath: _basePath,
|
|
466
|
+
version: _version,
|
|
467
|
+
buildNum: _buildNum,
|
|
468
|
+
assets: _assets,
|
|
469
|
+
permissions: _permissions,
|
|
470
|
+
links: _links,
|
|
471
|
+
files: _files,
|
|
472
|
+
appId,
|
|
473
|
+
appName,
|
|
474
|
+
webDir: _webDir,
|
|
475
|
+
plugins,
|
|
476
|
+
server,
|
|
477
|
+
android,
|
|
478
|
+
ios,
|
|
479
|
+
cordova,
|
|
480
|
+
experimental,
|
|
481
|
+
...capacitorConfig
|
|
482
|
+
} = target;
|
|
483
|
+
const serverConfig = isRecord(server) ? server : undefined;
|
|
484
|
+
const cordovaConfig = isRecord(cordova) ? cordova : undefined;
|
|
485
|
+
const experimentalConfig = isRecord(experimental) ? experimental : undefined;
|
|
486
|
+
const pluginsConfig = isRecord(plugins) ? plugins : {};
|
|
487
|
+
const keyboardPluginConfig = isRecord(pluginsConfig.Keyboard) ? pluginsConfig.Keyboard : {};
|
|
488
|
+
const config: CapacitorConfig = {
|
|
489
|
+
...capacitorConfig,
|
|
490
|
+
appId,
|
|
491
|
+
appName,
|
|
492
|
+
webDir: path.posix.join(".akan", "mobile", name, "www"),
|
|
493
|
+
plugins: {
|
|
494
|
+
CapacitorCookies: { enabled: true },
|
|
495
|
+
...pluginsConfig,
|
|
496
|
+
Keyboard: {
|
|
497
|
+
resize: "none",
|
|
498
|
+
...keyboardPluginConfig,
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
android: {
|
|
502
|
+
...(isRecord(android) ? android : {}),
|
|
503
|
+
path: "android",
|
|
504
|
+
},
|
|
505
|
+
ios: {
|
|
506
|
+
...(isRecord(ios) ? ios : {}),
|
|
507
|
+
path: "ios",
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
if (operation === "local") {
|
|
511
|
+
if (!localServerUrl) throw new Error(`Local server URL is required for mobile target '${name}'.`);
|
|
512
|
+
config.server = {
|
|
513
|
+
...serverConfig,
|
|
514
|
+
androidScheme: "http",
|
|
515
|
+
url: localServerUrl,
|
|
516
|
+
cleartext: true,
|
|
517
|
+
allowNavigation: mergeAllowNavigation(serverConfig?.allowNavigation, localIp),
|
|
518
|
+
};
|
|
519
|
+
} else if (serverConfig && Object.keys(serverConfig).length > 0) {
|
|
520
|
+
config.server = serverConfig as never;
|
|
521
|
+
}
|
|
522
|
+
if (cordovaConfig && Object.keys(cordovaConfig).length > 0) {
|
|
523
|
+
config.cordova = cordovaConfig as never;
|
|
524
|
+
}
|
|
525
|
+
if (experimentalConfig && Object.keys(experimentalConfig).length > 0) {
|
|
526
|
+
config.experimental = experimentalConfig as never;
|
|
527
|
+
}
|
|
528
|
+
assertJsonSerializable(config);
|
|
529
|
+
return config;
|
|
530
|
+
}
|
|
531
|
+
|
|
20
532
|
export class CapacitorApp {
|
|
21
533
|
project: MobileProject & { ios: IosProject; android: AndroidProject };
|
|
22
534
|
iosTargetName = "App";
|
|
@@ -48,7 +560,6 @@ export class CapacitorApp {
|
|
|
48
560
|
regenerate = false,
|
|
49
561
|
}: { platform?: "ios" | "android" } & Partial<PrepareConfig> = {}) {
|
|
50
562
|
await mkdir(this.targetRoot, { recursive: true });
|
|
51
|
-
await this.#writeCapacitorConfig();
|
|
52
563
|
if (regenerate) {
|
|
53
564
|
if (!platform || platform === "ios")
|
|
54
565
|
await rm(path.join(this.app.cwdPath, this.iosRootPath), { recursive: true, force: true });
|
|
@@ -96,11 +607,140 @@ export class CapacitorApp {
|
|
|
96
607
|
async openIos() {
|
|
97
608
|
await this.#spawnMobile("npx", ["cap", "open", "ios"], { operation: "local", env: "local" });
|
|
98
609
|
}
|
|
99
|
-
async runIos({ operation, env, regenerate = false }:
|
|
610
|
+
async runIos({ operation, env, regenerate = false, noAllowProvisioningUpdates = false, iosDeviceId }: RunIosConfig) {
|
|
100
611
|
if (operation === "release") await this.prepareWww();
|
|
101
612
|
await this.#prepareIos({ operation, env, regenerate });
|
|
102
|
-
const
|
|
103
|
-
|
|
613
|
+
const runTarget = await this.#selectIosRunTarget(iosDeviceId);
|
|
614
|
+
if (runTarget.kind === "simulator") {
|
|
615
|
+
await this.#spawnMobile(
|
|
616
|
+
"npx",
|
|
617
|
+
["cap", "run", "ios", "--target", runTarget.id],
|
|
618
|
+
{ operation, env },
|
|
619
|
+
{ stdio: "inherit" },
|
|
620
|
+
);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
await this.#runIosPhysicalDevice({ operation, env, runTarget, noAllowProvisioningUpdates });
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async #selectIosRunTarget(deviceId?: string) {
|
|
627
|
+
const targets = await this.#loadIosRunTargets();
|
|
628
|
+
if (deviceId) {
|
|
629
|
+
const found = targets.find((target) => target.id === deviceId);
|
|
630
|
+
if (!found) throw new Error(`iOS run target '${deviceId}' was not found.`);
|
|
631
|
+
return found;
|
|
632
|
+
}
|
|
633
|
+
if (targets.length === 0) {
|
|
634
|
+
throw new Error("No iOS run targets found. Open Simulator or connect an iPhone, then retry.");
|
|
635
|
+
}
|
|
636
|
+
return await select<IosRunTarget>({
|
|
637
|
+
message: "Select iOS run target",
|
|
638
|
+
choices: targets.map((target) => ({
|
|
639
|
+
name: `[${target.kind}] ${target.name}${target.state ? ` (${target.state})` : ""}`,
|
|
640
|
+
value: target,
|
|
641
|
+
})),
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async #loadIosRunTargets() {
|
|
646
|
+
const devices = await this.#loadPhysicalIosDevices();
|
|
647
|
+
const simulators = await this.#loadIosSimulators();
|
|
648
|
+
return [...devices, ...simulators];
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async #loadPhysicalIosDevices() {
|
|
652
|
+
try {
|
|
653
|
+
return parseDevicectlDevices(await this.#spawn("xcrun", ["devicectl", "list", "devices", "--json-output", "-"]));
|
|
654
|
+
} catch (jsonError) {
|
|
655
|
+
try {
|
|
656
|
+
return parseDevicectlDevices(await this.#spawn("xcrun", ["devicectl", "list", "devices"]));
|
|
657
|
+
} catch (textError) {
|
|
658
|
+
const classification = classifyIosRunFailure(
|
|
659
|
+
`${jsonError instanceof Error ? jsonError.message : ""}\n${textError instanceof Error ? textError.message : ""}`,
|
|
660
|
+
);
|
|
661
|
+
if (classification.kind === "devicectl-unavailable") this.app.logger.warn(classification.detail);
|
|
662
|
+
return [];
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async #loadIosSimulators() {
|
|
668
|
+
try {
|
|
669
|
+
return parseSimctlDevices(await this.#spawn("xcrun", ["simctl", "list", "devices", "available", "--json"]));
|
|
670
|
+
} catch {
|
|
671
|
+
try {
|
|
672
|
+
return parseSimctlDevices(await this.#spawn("xcrun", ["simctl", "list", "devices", "available"]));
|
|
673
|
+
} catch {
|
|
674
|
+
return [];
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async #runIosPhysicalDevice({
|
|
680
|
+
operation,
|
|
681
|
+
env,
|
|
682
|
+
runTarget,
|
|
683
|
+
noAllowProvisioningUpdates,
|
|
684
|
+
}: Pick<RunConfig, "operation" | "env"> & {
|
|
685
|
+
runTarget: IosRunTarget;
|
|
686
|
+
noAllowProvisioningUpdates: boolean;
|
|
687
|
+
}) {
|
|
688
|
+
const mobileEnv = sanitizeIosNativeRunEnv(await this.#commandEnv(operation, env));
|
|
689
|
+
const configContent = await this.#writeCapacitorConfig({ operation }, mobileEnv);
|
|
690
|
+
await this.#writeRootCapacitorConfig(configContent);
|
|
691
|
+
const scheme = this.#iosScheme();
|
|
692
|
+
const command = buildIosNativeRunCommand({
|
|
693
|
+
appRoot: this.app.cwdPath,
|
|
694
|
+
device: runTarget,
|
|
695
|
+
scheme,
|
|
696
|
+
configuration: operation === "release" ? "Release" : "Debug",
|
|
697
|
+
});
|
|
698
|
+
const xcodebuildArgs = noAllowProvisioningUpdates
|
|
699
|
+
? command.xcodebuildArgs
|
|
700
|
+
: [...command.xcodebuildArgs.slice(0, -1), "-allowProvisioningUpdates", ...command.xcodebuildArgs.slice(-1)];
|
|
701
|
+
try {
|
|
702
|
+
await this.#spawn("xcodebuild", xcodebuildArgs, {
|
|
703
|
+
cwd: path.join(this.app.cwdPath, this.iosProjectPath),
|
|
704
|
+
env: mobileEnv,
|
|
705
|
+
});
|
|
706
|
+
const devicectlId = runTarget.devicectlId ?? runTarget.id;
|
|
707
|
+
await this.#spawn("xcrun", ["devicectl", "device", "install", "app", "--device", devicectlId, command.appPath], {
|
|
708
|
+
env: mobileEnv,
|
|
709
|
+
});
|
|
710
|
+
await this.#spawn(
|
|
711
|
+
"xcrun",
|
|
712
|
+
["devicectl", "device", "process", "launch", "--device", devicectlId, this.target.appId],
|
|
713
|
+
{
|
|
714
|
+
env: mobileEnv,
|
|
715
|
+
},
|
|
716
|
+
);
|
|
717
|
+
} catch (error) {
|
|
718
|
+
throw new Error(
|
|
719
|
+
formatIosRunFailureMessage({
|
|
720
|
+
classification: classifyIosRunFailure(this.#errorOutput(error)),
|
|
721
|
+
appId: this.target.appId,
|
|
722
|
+
targetName: this.target.name,
|
|
723
|
+
teamId: await this.#getIosDevelopmentTeam(),
|
|
724
|
+
}),
|
|
725
|
+
);
|
|
726
|
+
} finally {
|
|
727
|
+
await this.#clearRootCapacitorConfigs();
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
#iosScheme() {
|
|
732
|
+
return isRecord(this.target.ios) && typeof this.target.ios.scheme === "string" ? this.target.ios.scheme : "App";
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async #getIosDevelopmentTeam() {
|
|
736
|
+
const pbxprojPath = path.join(this.app.cwdPath, this.iosProjectPath, "App.xcodeproj/project.pbxproj");
|
|
737
|
+
if (!(await Bun.file(pbxprojPath).exists())) return undefined;
|
|
738
|
+
return (await Bun.file(pbxprojPath).text()).match(/DEVELOPMENT_TEAM = ([^;]+);/)?.[1]?.trim();
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
#errorOutput(error: unknown) {
|
|
742
|
+
if (error instanceof CommandExecutionError) return `${error.stdout}\n${error.stderr}\n${error.message}`;
|
|
743
|
+
return error instanceof Error ? error.message : String(error);
|
|
104
744
|
}
|
|
105
745
|
|
|
106
746
|
async #prepareAndroid({ operation, env, regenerate = false }: PrepareConfig) {
|
|
@@ -161,6 +801,7 @@ export class CapacitorApp {
|
|
|
161
801
|
) {
|
|
162
802
|
await this.prepareWww();
|
|
163
803
|
await this.#prepareAndroid({ operation: "release", env, regenerate });
|
|
804
|
+
await this.#assertAndroidReleaseSigningConfig();
|
|
164
805
|
await this.#updateAndroidBuildTypes();
|
|
165
806
|
//윈도우는 gradlew.bat 사용
|
|
166
807
|
const isWindows = process.platform === "win32";
|
|
@@ -211,11 +852,31 @@ export class CapacitorApp {
|
|
|
211
852
|
async runAndroid({ operation, env, regenerate = false }: RunConfig) {
|
|
212
853
|
if (operation === "release") await this.prepareWww();
|
|
213
854
|
await this.#prepareAndroid({ operation, env, regenerate });
|
|
855
|
+
await this.#assertAndroidAdbReady();
|
|
214
856
|
this.app.logger.info(`Running Android in ${operation} mode on ${env} env`);
|
|
215
857
|
const args = ["cap", "run", "android"];
|
|
216
858
|
await this.#spawnMobile("npx", args, { operation, env }, { stdio: "inherit" });
|
|
217
859
|
}
|
|
218
860
|
|
|
861
|
+
async #assertAndroidReleaseSigningConfig() {
|
|
862
|
+
const gradlePropertiesPath = path.join(this.app.cwdPath, this.androidRootPath, "gradle.properties");
|
|
863
|
+
const gradleProperties = (await Bun.file(gradlePropertiesPath).exists())
|
|
864
|
+
? await Bun.file(gradlePropertiesPath).text()
|
|
865
|
+
: "";
|
|
866
|
+
const missingKeys = getMissingAndroidReleaseSigningKeys({ gradleProperties });
|
|
867
|
+
if (missingKeys.length > 0) throw new Error(formatAndroidReleaseSigningError(missingKeys));
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async #assertAndroidAdbReady() {
|
|
871
|
+
try {
|
|
872
|
+
const issues = getAdbDeviceStateIssues(await this.#spawn("adb", ["devices"]));
|
|
873
|
+
if (issues.length > 0) throw new Error(issues.join("\n"));
|
|
874
|
+
} catch (error) {
|
|
875
|
+
if (error instanceof CommandExecutionError) return;
|
|
876
|
+
throw error;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
219
880
|
async releaseIos() {
|
|
220
881
|
await this.prepareWww();
|
|
221
882
|
await this.#prepareIos({ operation: "release", env: "main" });
|
|
@@ -241,33 +902,17 @@ export class CapacitorApp {
|
|
|
241
902
|
if (html.includes("window.__AKAN_MOBILE_TARGET__")) return html;
|
|
242
903
|
return html.replace(/<\/head\s*>/i, `${script}\n</head>`);
|
|
243
904
|
}
|
|
244
|
-
async #writeCapacitorConfig() {
|
|
905
|
+
async #writeCapacitorConfig({ operation }: Pick<RunConfig, "operation">, commandEnv: MobileCommandEnv) {
|
|
245
906
|
await mkdir(this.targetRoot, { recursive: true });
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
(config, target) => ({
|
|
256
|
-
...config,
|
|
257
|
-
webDir: \`.akan/mobile/\${target.name}/www\`,
|
|
258
|
-
android: {
|
|
259
|
-
...config.android,
|
|
260
|
-
path: "android",
|
|
261
|
-
},
|
|
262
|
-
ios: {
|
|
263
|
-
...config.ios,
|
|
264
|
-
path: "ios",
|
|
265
|
-
},
|
|
266
|
-
}),
|
|
267
|
-
appInfo as AppScanResult,
|
|
268
|
-
);
|
|
269
|
-
`;
|
|
270
|
-
await Bun.write(path.join(this.app.cwdPath, "capacitor.config.ts"), content);
|
|
907
|
+
const localIp = operation === "local" ? getLocalIP() : undefined;
|
|
908
|
+
const config = materializeCapacitorConfig(this.target, {
|
|
909
|
+
operation,
|
|
910
|
+
localIp,
|
|
911
|
+
localServerUrl: localIp ? this.#localCsrUrl(localIp, commandEnv) : undefined,
|
|
912
|
+
});
|
|
913
|
+
const content = `${JSON.stringify(config, null, 2)}\n`;
|
|
914
|
+
await Bun.write(path.join(this.targetRoot, "capacitor.config.json"), content);
|
|
915
|
+
return content;
|
|
271
916
|
}
|
|
272
917
|
async #prepareTargetAssets() {
|
|
273
918
|
if (!this.target.assets) return;
|
|
@@ -372,6 +1017,21 @@ export default withBase(
|
|
|
372
1017
|
...(devPort ? { PORT: devPort, AKAN_PUBLIC_CLIENT_PORT: devPort, AKAN_PUBLIC_SERVER_PORT: devPort } : {}),
|
|
373
1018
|
});
|
|
374
1019
|
}
|
|
1020
|
+
#localCsrUrl(ip: string, commandEnv: MobileCommandEnv) {
|
|
1021
|
+
const basePath = this.target.basePath?.replace(/^\/+|\/+$/g, "");
|
|
1022
|
+
const locale = commandEnv.AKAN_PUBLIC_DEFAULT_LOCALE ?? "en";
|
|
1023
|
+
const pathname = basePath ? `${locale}/${basePath}` : `${locale}/`;
|
|
1024
|
+
const port = commandEnv.AKAN_PUBLIC_CLIENT_PORT ?? commandEnv.PORT ?? "8282";
|
|
1025
|
+
const params = new URLSearchParams({ csr: "true", akanMobileTarget: this.target.name });
|
|
1026
|
+
if (basePath) params.set("akanMobileBasePath", basePath);
|
|
1027
|
+
return `http://${ip}:${port}/${pathname}?${params}`;
|
|
1028
|
+
}
|
|
1029
|
+
async #clearRootCapacitorConfigs() {
|
|
1030
|
+
await clearRootCapacitorConfigs(this.app.cwdPath);
|
|
1031
|
+
}
|
|
1032
|
+
async #writeRootCapacitorConfig(content: string) {
|
|
1033
|
+
await writeRootCapacitorConfig(this.app.cwdPath, content);
|
|
1034
|
+
}
|
|
375
1035
|
async #spawn(command: string, args: string[] = [], options: Parameters<AppExecutor["spawn"]>[2] = {}) {
|
|
376
1036
|
return await this.app.spawn(command, args, { cwd: this.app.cwdPath, ...options });
|
|
377
1037
|
}
|
|
@@ -381,10 +1041,17 @@ export default withBase(
|
|
|
381
1041
|
{ operation, env }: Pick<RunConfig, "operation" | "env">,
|
|
382
1042
|
options: Parameters<AppExecutor["spawn"]>[2] = {},
|
|
383
1043
|
) {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
1044
|
+
const mobileEnv = { ...(await this.#commandEnv(operation, env)), ...options.env };
|
|
1045
|
+
const configContent = await this.#writeCapacitorConfig({ operation }, mobileEnv);
|
|
1046
|
+
await this.#writeRootCapacitorConfig(configContent);
|
|
1047
|
+
try {
|
|
1048
|
+
return await this.#spawn(command, args, {
|
|
1049
|
+
...options,
|
|
1050
|
+
env: mobileEnv,
|
|
1051
|
+
});
|
|
1052
|
+
} finally {
|
|
1053
|
+
await this.#clearRootCapacitorConfigs();
|
|
1054
|
+
}
|
|
388
1055
|
}
|
|
389
1056
|
async addCamera() {
|
|
390
1057
|
await this.#setPermissionInIos({
|
package/executors.ts
CHANGED
|
@@ -162,6 +162,7 @@ const PAGE_ROUTE_EXPORTS = new Set([
|
|
|
162
162
|
]);
|
|
163
163
|
const ROOT_LAYOUT_EXPORTS = new Set([
|
|
164
164
|
"default",
|
|
165
|
+
"pageConfig",
|
|
165
166
|
"head",
|
|
166
167
|
"metadata",
|
|
167
168
|
"generateHead",
|
|
@@ -178,6 +179,7 @@ const ROOT_LAYOUT_EXPORTS = new Set([
|
|
|
178
179
|
]);
|
|
179
180
|
const LAYOUT_ROUTE_EXPORTS = new Set([
|
|
180
181
|
"default",
|
|
182
|
+
"pageConfig",
|
|
181
183
|
"head",
|
|
182
184
|
"metadata",
|
|
183
185
|
"generateHead",
|
|
@@ -354,9 +354,7 @@ describe("DevChangePlanner", () => {
|
|
|
354
354
|
});
|
|
355
355
|
|
|
356
356
|
expect(dictionaryPlan.actions).toEqual(["rebuild-client", "restart-backend", "restart-builder"]);
|
|
357
|
-
expect(dictionaryPlan.reasonByFile[`${root}/apps/demo/lib/_demo/demo.dictionary.ts`]).toContain(
|
|
358
|
-
"runtime-metadata",
|
|
359
|
-
);
|
|
357
|
+
expect(dictionaryPlan.reasonByFile[`${root}/apps/demo/lib/_demo/demo.dictionary.ts`]).toContain("runtime-metadata");
|
|
360
358
|
expect(signalPlan.actions).toContain("restart-builder");
|
|
361
359
|
});
|
|
362
360
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "2.3.6-rc.
|
|
3
|
+
"version": "2.3.6-rc.3",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -32,10 +32,10 @@
|
|
|
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.6-rc.
|
|
35
|
+
"akanjs": "2.3.6-rc.3",
|
|
36
36
|
"chalk": "^5.6.2",
|
|
37
37
|
"commander": "^14.0.3",
|
|
38
|
-
"daisyui": "
|
|
38
|
+
"daisyui": "5.5.23",
|
|
39
39
|
"dayjs": "^1.11.20",
|
|
40
40
|
"fontaine": "^0.8.0",
|
|
41
41
|
"fonteditor-core": "^2.6.3",
|