@akanjs/devkit 2.4.0-rc.7 → 2.4.0-rc.9

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.
@@ -4,8 +4,10 @@ import {
4
4
  backendRestartReasonFromMessage,
5
5
  buildStatusReplaySequence,
6
6
  createBackendBuildStatus,
7
+ hasBuildFailureForGeneration,
7
8
  isLegacyBackendFallbackFile,
8
9
  mergeBackendRestartReasons,
10
+ mergeInvalidateMessages,
9
11
  normalizeBackendReportedGeneration,
10
12
  shouldAbandonBackendRecovery,
11
13
  shouldMarkBuildPhaseRecovered,
@@ -226,6 +228,96 @@ describe("backend recovery abandonment", () => {
226
228
  });
227
229
  });
228
230
 
231
+ describe("hasBuildFailureForGeneration", () => {
232
+ const status = (phase: DevBuildStatus["phase"], generation: number, ok: boolean): DevBuildStatus => ({
233
+ generation,
234
+ phase,
235
+ ok,
236
+ files: [],
237
+ });
238
+
239
+ test("detects a failing phase recorded for the same generation", () => {
240
+ const statusByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([
241
+ ["csr", status("csr", 3, false)],
242
+ ["barrel", status("barrel", 3, true)],
243
+ ]);
244
+ expect(hasBuildFailureForGeneration(statusByPhase, 3)).toBe(true);
245
+ });
246
+
247
+ test("ignores stale failures from earlier generations", () => {
248
+ const statusByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([
249
+ ["csr", status("csr", 3, false)],
250
+ ["barrel", status("barrel", 4, true)],
251
+ ]);
252
+ expect(hasBuildFailureForGeneration(statusByPhase, 4)).toBe(false);
253
+ });
254
+
255
+ test("treats unknown generations and green boards as healthy", () => {
256
+ const statusByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([["csr", status("csr", 3, false)]]);
257
+ expect(hasBuildFailureForGeneration(statusByPhase, undefined)).toBe(false);
258
+ expect(hasBuildFailureForGeneration(new Map(), 3)).toBe(false);
259
+ });
260
+ });
261
+
262
+ describe("mergeInvalidateMessages", () => {
263
+ const invalidate = (
264
+ generation: number,
265
+ files: string[],
266
+ actions: DevChangeAction[],
267
+ ): Extract<BuilderMessage, { type: "invalidate" }> => ({
268
+ type: "invalidate",
269
+ kinds: ["code"],
270
+ files,
271
+ generation,
272
+ devPlan: {
273
+ generation,
274
+ files,
275
+ generatedFiles: [],
276
+ roles: ["shared"],
277
+ actions,
278
+ reasonByFile: { [files[0] ?? "/repo/a.ts"]: ["shared-path"] },
279
+ },
280
+ });
281
+
282
+ test("unions files, kinds, and actions while keeping the latest generation", () => {
283
+ const merged = mergeInvalidateMessages(
284
+ invalidate(3, ["/repo/b.ts", "/repo/a.ts"], ["restart-builder"]),
285
+ invalidate(5, ["/repo/c.ts"], ["rebuild-client"]),
286
+ );
287
+
288
+ expect(merged.generation).toBe(5);
289
+ expect(merged.files).toEqual(["/repo/a.ts", "/repo/b.ts", "/repo/c.ts"]);
290
+ expect(merged.devPlan?.generation).toBe(5);
291
+ expect(merged.devPlan?.actions).toEqual(["rebuild-client", "restart-builder"]);
292
+ });
293
+
294
+ test("keeps the surviving devPlan when only one side carries one", () => {
295
+ const withPlan = invalidate(3, ["/repo/a.ts"], ["restart-builder"]);
296
+ const withoutPlan: Extract<BuilderMessage, { type: "invalidate" }> = {
297
+ type: "invalidate",
298
+ kinds: ["css"],
299
+ files: ["/repo/style.css"],
300
+ generation: 4,
301
+ };
302
+
303
+ const merged = mergeInvalidateMessages(withPlan, withoutPlan);
304
+ expect(merged.devPlan?.actions).toEqual(["restart-builder"]);
305
+ expect(merged.kinds).toEqual(["code", "css"]);
306
+ expect(merged.generation).toBe(4);
307
+ });
308
+
309
+ test("merges per-file reasons without duplicating entries", () => {
310
+ const current = invalidate(3, ["/repo/a.ts"], ["restart-builder"]);
311
+ const next = invalidate(4, ["/repo/a.ts"], ["restart-builder"]);
312
+ const nextPlan = next.devPlan;
313
+ if (!nextPlan) throw new Error("devPlan expected");
314
+ nextPlan.reasonByFile["/repo/a.ts"] = ["runtime-metadata", "shared-path"];
315
+
316
+ const merged = mergeInvalidateMessages(current, next);
317
+ expect(merged.devPlan?.reasonByFile["/repo/a.ts"]).toEqual(["runtime-metadata", "shared-path"]);
318
+ });
319
+ });
320
+
229
321
  describe("normalizeBackendReportedGeneration", () => {
230
322
  test("drops the gateway's unknown-generation sentinel", () => {
231
323
  expect(normalizeBackendReportedGeneration(-1)).toBeUndefined();
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { Logger } from "akanjs/common";
3
- import type { BuilderMessage, BuildPhase, DevBuildStatus, DevChangeRole } from "akanjs/server";
3
+ import type { BuilderMessage, BuildPhase, DevBuildStatus, DevChangePlan, DevChangeRole } from "akanjs/server";
4
4
  import type { App } from "../commandDecorators";
5
5
  import { createTunnel } from "../createTunnel";
6
6
  import { WorkspaceExecutor } from "../executors";
@@ -17,6 +17,10 @@ const BACKEND_RECOVERY_MAX_ATTEMPTS = 5;
17
17
  const BACKEND_STDERR_TAIL_LIMIT = 40;
18
18
  const BUILDER_READY_TIMEOUT_MS = 150000;
19
19
  const BUILDER_START_MAX_ATTEMPTS = 3;
20
+ // The builder is the file watcher: while it is down no edit can trigger a retry, so unlike the
21
+ // backend the recovery loop never gives up — it only backs off.
22
+ const BUILDER_RECOVERY_BASE_DELAY_MS = 2_000;
23
+ const BUILDER_RECOVERY_MAX_DELAY_MS = 60_000;
20
24
  const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
21
25
  const NON_SOURCE_EXT_RE =
22
26
  /\.(css|scss|sass|less|json|svg|png|jpe?g|webp|gif|avif|ico|woff2?|ttf|otf|mp3|mp4|wav|html)$/i;
@@ -156,6 +160,55 @@ export const shouldReplaceLastGoodMessage = (
156
160
  export const shouldQueueBuildStatusReplay = (backendReady: boolean, pendingReplayCount: number): boolean =>
157
161
  !backendReady || pendingReplayCount > 0;
158
162
 
163
+ /**
164
+ * Recycling the builder/backend on a generation whose build already failed is guaranteed to strand
165
+ * the dev server: the rebooted builder hits the same compile error and exits before builder-ready.
166
+ * Failing phase statuses for a generation arrive over IPC before that generation's invalidate, so
167
+ * the host can check them here and defer the recycle until a healthy batch lands.
168
+ */
169
+ export const hasBuildFailureForGeneration = (
170
+ statusByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
171
+ generation: number | undefined,
172
+ ): boolean => {
173
+ if (typeof generation !== "number") return false;
174
+ for (const status of statusByPhase.values()) {
175
+ if (!status.ok && status.generation === generation) return true;
176
+ }
177
+ return false;
178
+ };
179
+
180
+ const mergeDevPlans = (current?: DevChangePlan, next?: DevChangePlan): DevChangePlan | undefined => {
181
+ if (!current) return next;
182
+ if (!next) return current;
183
+ const reasonByFile: Record<string, string[]> = { ...current.reasonByFile };
184
+ for (const [file, reasons] of Object.entries(next.reasonByFile)) {
185
+ reasonByFile[file] = [...new Set([...(reasonByFile[file] ?? []), ...reasons])].sort();
186
+ }
187
+ return {
188
+ generation: Math.max(current.generation, next.generation),
189
+ files: [...new Set([...current.files, ...next.files])].sort(),
190
+ generatedFiles: [...new Set([...current.generatedFiles, ...next.generatedFiles])].sort(),
191
+ roles: [...new Set([...current.roles, ...next.roles])].sort(),
192
+ actions: [...new Set([...current.actions, ...next.actions])].sort(),
193
+ reasonByFile,
194
+ };
195
+ };
196
+
197
+ /** A deferred recycle accumulates every batch it skipped so the eventual restart covers them all. */
198
+ export const mergeInvalidateMessages = (
199
+ current: Extract<BuilderMessage, { type: "invalidate" }>,
200
+ next: Extract<BuilderMessage, { type: "invalidate" }>,
201
+ ): Extract<BuilderMessage, { type: "invalidate" }> => {
202
+ const generation = Math.max(generationValue(current.generation), generationValue(next.generation));
203
+ return {
204
+ type: "invalidate",
205
+ kinds: [...new Set([...current.kinds, ...next.kinds])].sort(),
206
+ files: [...new Set([...current.files, ...next.files])].sort(),
207
+ generation: generation >= 0 ? generation : undefined,
208
+ devPlan: mergeDevPlans(current.devPlan, next.devPlan),
209
+ };
210
+ };
211
+
159
212
  export const buildStatusReplaySequence = (
160
213
  pendingReplay: readonly DevBuildStatus[],
161
214
  latestByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
@@ -281,8 +334,12 @@ export class AkanAppHost {
281
334
  #restartTimer: ReturnType<typeof setTimeout> | null = null;
282
335
  #backendRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
283
336
  #backendRecoveryAttempts = 0;
337
+ #backendGaveUp = false;
284
338
  #backendLifecycleState: BackendLifecycleState = "stopped";
285
339
  #pendingRestartReason: BackendRestartReason | null = null;
340
+ #pendingRecycle: { message: Extract<BuilderMessage, { type: "invalidate" }>; refreshConfig: boolean } | null = null;
341
+ #builderRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
342
+ #builderRecoveryAttempts = 0;
286
343
  #backendStartStatus: { generation?: number; files: string[] } | null = null;
287
344
  #backendBuildStatusGeneration = 0;
288
345
  #backendStderrTail: string[] = [];
@@ -320,6 +377,10 @@ export class AkanAppHost {
320
377
  clearTimeout(this.#backendRecoveryTimer);
321
378
  this.#backendRecoveryTimer = null;
322
379
  }
380
+ if (this.#builderRecoveryTimer) {
381
+ clearTimeout(this.#builderRecoveryTimer);
382
+ this.#builderRecoveryTimer = null;
383
+ }
323
384
  await this.#stopBackend();
324
385
  this.#stopBuilder();
325
386
  return this;
@@ -335,6 +396,7 @@ export class AkanAppHost {
335
396
  }
336
397
  #startBackend(startStatus: { generation?: number; files: string[] } | null = null) {
337
398
  this.#backendStartStatus = startStatus;
399
+ this.#backendGaveUp = false;
338
400
  this.#setBackendLifecycleState("starting");
339
401
  this.#backendReady = false;
340
402
  this.#backendStderrTail = [];
@@ -531,7 +593,8 @@ export class AkanAppHost {
531
593
  #scheduleBackendRecovery(reason: string) {
532
594
  if (this.#backendRecoveryTimer || this.#backend) return;
533
595
  if (shouldAbandonBackendRecovery(this.#backendRecoveryAttempts)) {
534
- const message = `Backend exited ${this.#backendRecoveryAttempts} times in a row (${reason}); waiting for a server-side edit to retry.`;
596
+ const message = `Backend exited ${this.#backendRecoveryAttempts} times in a row (${reason}); waiting for an edit or a green build to retry.`;
597
+ this.#backendGaveUp = true;
535
598
  this.#setBackendLifecycleState("stopped", `gave up after ${this.#backendRecoveryAttempts} recovery attempts`);
536
599
  this.logger.error(`[backend-recovery] ${message}`);
537
600
  if (this.#backendStderrTail.length > 0) {
@@ -576,6 +639,7 @@ export class AkanAppHost {
576
639
  if (message.type === "build-status") {
577
640
  this.#recordBuildStatus(message.data);
578
641
  this.#sendOrQueueBuildStatus(message.data);
642
+ this.#reviveBackendAfterGreenBuild(message.data);
579
643
  return;
580
644
  }
581
645
  if (message.type === "pages-updated") this.#recordLastGood(message);
@@ -590,19 +654,25 @@ export class AkanAppHost {
590
654
  this.#logDevPlan(message);
591
655
  // Config changes subsume builder restarts: the dev-host restart recycles builder and backend
592
656
  // AND re-runs the prepare step, so check it first when a batch carries both actions.
593
- if (shouldRestartDevHostByDevPlan(message)) {
594
- try {
595
- await this.#restartDevHost(message);
596
- } catch (err) {
597
- this.#recordDevHostRestartFailure(message, err, "Config");
657
+ const wantsDevHostRestart = shouldRestartDevHostByDevPlan(message);
658
+ const pending = this.#pendingRecycle;
659
+ // A pending (deferred) recycle rides along on the next code batch — that batch is where the
660
+ // fix lands; css-only batches cannot heal a compile error, so they never resume it.
661
+ if (wantsDevHostRestart || shouldRestartBuilderByDevPlan(message) || (pending && message.kinds.includes("code"))) {
662
+ const refreshConfig = wantsDevHostRestart || (pending?.refreshConfig ?? false);
663
+ const merged = pending ? mergeInvalidateMessages(pending.message, message) : message;
664
+ const generation = message.devPlan?.generation ?? message.generation;
665
+ if (hasBuildFailureForGeneration(this.#buildStatusByPhase, generation)) {
666
+ this.#deferRecycle(merged, { refreshConfig, generation });
667
+ return;
598
668
  }
599
- return;
600
- }
601
- if (shouldRestartBuilderByDevPlan(message)) {
669
+ this.#pendingRecycle = null;
602
670
  try {
603
- await this.#restartDevChildren(message);
671
+ if (refreshConfig) await this.#restartDevHost(merged);
672
+ else await this.#restartDevChildren(merged);
604
673
  } catch (err) {
605
- this.#recordDevHostRestartFailure(message, err, "Runtime metadata");
674
+ this.#recordDevHostRestartFailure(merged, err, refreshConfig ? "Config" : "Runtime metadata");
675
+ this.#resurrectDevChildren(merged);
606
676
  }
607
677
  return;
608
678
  }
@@ -612,6 +682,79 @@ export class AkanAppHost {
612
682
  }
613
683
  this.#sendToBackend(message);
614
684
  }
685
+ #deferRecycle(
686
+ message: Extract<BuilderMessage, { type: "invalidate" }>,
687
+ { refreshConfig, generation }: { refreshConfig: boolean; generation?: number },
688
+ ): void {
689
+ this.#pendingRecycle = { message, refreshConfig };
690
+ const kind = refreshConfig ? "Config" : "Runtime metadata";
691
+ this.logger.warn(
692
+ `[dev-host] ${kind.toLowerCase()} restart deferred generation=${generation ?? "(unknown)"}; keeping the running dev server until the build error is fixed`,
693
+ );
694
+ const status: DevBuildStatus = {
695
+ generation: generation ?? this.#nextBackendBuildStatusGeneration(),
696
+ phase: "scan",
697
+ ok: false,
698
+ files: message.files,
699
+ message: `${kind} change is on hold while the build is failing; it will apply automatically once the error is fixed.`,
700
+ };
701
+ this.#recordBuildStatus(status);
702
+ this.#sendOrQueueBuildStatus(status);
703
+ }
704
+ /**
705
+ * A failed recycle must never leave the dev server dead: bring the backend back up on the
706
+ * last-good artifact so the error overlay stays reachable, and keep retrying the builder —
707
+ * the builder is the file watcher, so without it no edit could ever trigger a recovery.
708
+ */
709
+ #resurrectDevChildren(message: Extract<BuilderMessage, { type: "invalidate" }>): void {
710
+ const generation = message.devPlan?.generation ?? message.generation;
711
+ if (!this.#backend) this.#startBackend({ generation, files: message.files });
712
+ this.#scheduleBuilderRecovery({ generation, files: message.files });
713
+ }
714
+ #scheduleBuilderRecovery(reason: { generation?: number; files: string[] }): void {
715
+ if (this.#builderRecoveryTimer || this.#builder) return;
716
+ const attempt = this.#builderRecoveryAttempts;
717
+ const delay = Math.min(BUILDER_RECOVERY_BASE_DELAY_MS * 2 ** attempt, BUILDER_RECOVERY_MAX_DELAY_MS);
718
+ this.#builderRecoveryAttempts = attempt + 1;
719
+ this.logger.warn(
720
+ `[builder-recovery] builder is down; retrying start in ${delay}ms (attempt ${this.#builderRecoveryAttempts})`,
721
+ );
722
+ this.#builderRecoveryTimer = setTimeout(() => {
723
+ this.#builderRecoveryTimer = null;
724
+ if (this.#builder) return;
725
+ void this.#recoverBuilder(reason);
726
+ }, delay);
727
+ }
728
+ async #recoverBuilder(reason: { generation?: number; files: string[] }): Promise<void> {
729
+ try {
730
+ await this.#startBuilder();
731
+ } catch (err) {
732
+ this.logger.warn(`[builder-recovery] builder start failed: ${err instanceof Error ? err.message : String(err)}`);
733
+ this.#scheduleBuilderRecovery(reason);
734
+ return;
735
+ }
736
+ this.#builderRecoveryAttempts = 0;
737
+ this.logger.info("[builder-recovery] builder recovered");
738
+ const status: DevBuildStatus = {
739
+ generation: reason.generation ?? this.#nextBackendBuildStatusGeneration(),
740
+ phase: "scan",
741
+ ok: true,
742
+ files: reason.files,
743
+ message: "Builder recovered",
744
+ };
745
+ this.#recordBuildStatus(status);
746
+ this.#sendOrQueueBuildStatus(status);
747
+ if (!this.#backend && !this.#backendRecoveryTimer) {
748
+ this.#startBackend({ generation: reason.generation, files: reason.files });
749
+ }
750
+ }
751
+ /** A backend that gave up recovering on broken code gets one fresh chance whenever a build goes green. */
752
+ #reviveBackendAfterGreenBuild(status: DevBuildStatus): void {
753
+ if (!status.ok || !this.#backendGaveUp || this.#backend || this.#backendRecoveryTimer) return;
754
+ this.logger.info(`[backend-recovery] build went green (generation=${status.generation}); retrying backend`);
755
+ this.#backendRecoveryAttempts = 0;
756
+ this.#startBackend({ generation: status.generation, files: status.files });
757
+ }
615
758
  async #restartDevChildren(message: Extract<BuilderMessage, { type: "invalidate" }>): Promise<void> {
616
759
  const generation = message.devPlan?.generation ?? message.generation;
617
760
  this.logger.warn(
@@ -646,6 +789,11 @@ export class AkanAppHost {
646
789
  clearTimeout(this.#backendRecoveryTimer);
647
790
  this.#backendRecoveryTimer = null;
648
791
  }
792
+ if (this.#builderRecoveryTimer) {
793
+ clearTimeout(this.#builderRecoveryTimer);
794
+ this.#builderRecoveryTimer = null;
795
+ }
796
+ this.#builderRecoveryAttempts = 0;
649
797
  this.#pendingRestartReason = null;
650
798
  this.#lastGoodFrontend = {};
651
799
  this.#buildStatusByPhase.clear();
@@ -693,7 +841,7 @@ export class AkanAppHost {
693
841
  phase: "scan",
694
842
  ok: false,
695
843
  files: message.files,
696
- message: `${kind} change requires restarting \`akan start\` to apply: ${detail}`,
844
+ message: `${kind} change failed to apply; recovering the dev server automatically: ${detail}`,
697
845
  };
698
846
  this.#recordBuildStatus(status);
699
847
  this.#sendOrQueueBuildStatus(status);
@@ -4,7 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import type { PackageJson } from "../types";
7
- import { AkanAppConfig, AkanLibConfig } from "./akanConfig";
7
+ import { AkanAppConfig, AkanLibConfig, deriveDefaultAppId } from "./akanConfig";
8
8
  import type { DeepPartial, LibConfigResult } from "./types";
9
9
 
10
10
  const akanPackageJson = JSON.parse(
@@ -48,14 +48,14 @@ describe("AkanAppConfig", () => {
48
48
  expect(config.images.formats).toEqual(["image/webp"]);
49
49
  expect(config.mobile).toMatchObject({
50
50
  appName: "portal",
51
- appId: "com.portal.app",
51
+ appId: "com.akanjs.portal",
52
52
  version: "0.0.1",
53
53
  buildNum: 1,
54
54
  targets: {
55
55
  default: {
56
56
  name: "default",
57
57
  appName: "portal",
58
- appId: "com.portal.app",
58
+ appId: "com.akanjs.portal",
59
59
  version: "0.0.1",
60
60
  buildNum: 1,
61
61
  },
@@ -268,13 +268,11 @@ describe("AkanAppConfig", () => {
268
268
  // The workspace-root install covers the toolchain/runtime plus every app Capacitor plugin
269
269
  // (deduped — "@capacitor/core" appears in both source lists).
270
270
  const expectedMobilePackages: string[] = [
271
- "firebase",
272
271
  "@capacitor/cli",
273
272
  "@capacitor/core",
274
273
  "@capacitor/ios",
275
274
  "@capacitor/android",
276
275
  "@capacitor/assets",
277
- "@capacitor-community/fcm",
278
276
  "@capacitor/app",
279
277
  "@capacitor/browser",
280
278
  "@capacitor/camera",
@@ -292,7 +290,6 @@ describe("AkanAppConfig", () => {
292
290
  expectedMobilePackages.map((lib) => `${lib}@${runtimeDependencies[lib as keyof typeof runtimeDependencies]}`),
293
291
  );
294
292
  expect(config.getMobileAppCapacitorPlugins()).toEqual([
295
- "@capacitor-community/fcm",
296
293
  "@capacitor/app",
297
294
  "@capacitor/browser",
298
295
  "@capacitor/camera",
@@ -371,6 +368,28 @@ describe("AkanAppConfig", () => {
371
368
  ),
372
369
  ).toThrow("unknown basePath");
373
370
  });
371
+
372
+ test("derives a repo-scoped default appId and records explicit-mobile intent", () => {
373
+ // No mobile section: appId defaults to the repo-scoped reverse-DNS id, and mobile is not explicit.
374
+ const withoutMobile = new AkanAppConfig(app, [], packageJson, {}, baseDevEnv);
375
+ expect(withoutMobile.mobile.appId).toBe("com.akanjs.portal");
376
+ expect(withoutMobile.hasMobileConfig).toBe(false);
377
+
378
+ // Explicit mobile section without appId still uses the repo-scoped default but marks intent.
379
+ const withMobile = new AkanAppConfig(app, [], packageJson, { mobile: { version: "2.0.0" } }, baseDevEnv);
380
+ expect(withMobile.mobile.appId).toBe("com.akanjs.portal");
381
+ expect(withMobile.hasMobileConfig).toBe(true);
382
+ });
383
+ });
384
+
385
+ describe("deriveDefaultAppId", () => {
386
+ test("sanitizes org/app names into a valid reverse-DNS bundle id", () => {
387
+ expect(deriveDefaultAppId("leading-flight-guidance", "myapp")).toBe("com.leadingflightguidance.myapp");
388
+ expect(deriveDefaultAppId("Acme Corp", "Store")).toBe("com.acmecorp.store");
389
+ // Empty org and digit-leading segments stay valid package identifiers.
390
+ expect(deriveDefaultAppId("", "app")).toBe("com.app.app");
391
+ expect(deriveDefaultAppId("123repo", "9app")).toBe("com.app123repo.app9app");
392
+ });
374
393
  });
375
394
 
376
395
  describe("AkanLibConfig", () => {
@@ -58,7 +58,6 @@ const DEFAULT_BACKEND_RUNTIME_PACKAGES = ["croner"] as const;
58
58
  // All are declared only as optional peers, so a fresh workspace never auto-installs them; the mobile
59
59
  // preflight installs them (together with MOBILE_APP_CAPACITOR_PLUGINS) at the workspace root.
60
60
  const MOBILE_RUNTIME_PACKAGES = [
61
- "firebase",
62
61
  "@capacitor/cli",
63
62
  "@capacitor/core",
64
63
  "@capacitor/ios",
@@ -73,7 +72,6 @@ const MOBILE_RUNTIME_PACKAGES = [
73
72
  // alongside MOBILE_RUNTIME_PACKAGES (pinned via optional peers) and declared in the app with a "*"
74
73
  // range so bun dedupes them to that hoisted version instead of pinning a second source of truth.
75
74
  const MOBILE_APP_CAPACITOR_PLUGINS = [
76
- "@capacitor-community/fcm",
77
75
  "@capacitor/app",
78
76
  "@capacitor/browser",
79
77
  "@capacitor/camera",
@@ -125,6 +123,21 @@ const normalizeStringList = (values: string[] | undefined) => {
125
123
  return normalized.length > 0 ? [...new Set(normalized)] : undefined;
126
124
  };
127
125
 
126
+ // Reduce a free-form name to a valid reverse-DNS / Android package segment: lowercase, alphanumerics
127
+ // only (hyphens/spaces dropped), never empty, never starting with a digit.
128
+ const sanitizeAppIdSegment = (value: string): string => {
129
+ const cleaned = value.toLowerCase().replace(/[^a-z0-9]+/g, "");
130
+ if (!cleaned) return "app";
131
+ return /^[a-z]/.test(cleaned) ? cleaned : `app${cleaned}`;
132
+ };
133
+
134
+ // The default mobile bundle id for an app that has not pinned `mobile.appId`. Uses the workspace
135
+ // (repo) name as the org segment so the default is far less collision-prone than a bare
136
+ // `com.<appName>.app`, which Apple's portal routinely already has claimed. Apps that ship pin an
137
+ // explicit appId, so this only affects unconfigured/dev apps.
138
+ export const deriveDefaultAppId = (orgName: string, appName: string): string =>
139
+ `com.${sanitizeAppIdSegment(orgName)}.${sanitizeAppIdSegment(appName)}`;
140
+
128
141
  const normalizeDeepLinkDomain = (domain: string) => {
129
142
  const normalized = domain.trim();
130
143
  if (!normalized) return "";
@@ -166,6 +179,8 @@ export class AkanAppConfig implements AppConfigResult {
166
179
  i18n: AkanI18nConfig;
167
180
  publicEnv: string[];
168
181
  mobile: AkanMobileConfig;
182
+ /** True only when the app's akan.config.ts explicitly declares a `mobile` section (vs. the synthesized default). */
183
+ hasMobileConfig: boolean;
169
184
  secrets: string[];
170
185
  baseDevEnv: BaseDevEnv;
171
186
  libs: string[];
@@ -204,6 +219,7 @@ export class AkanAppConfig implements AppConfigResult {
204
219
  process.env.AKAN_PUBLIC_LOCALES = this.i18n.locales.join(",");
205
220
  this.publicEnv = (config?.publicEnv as string[] | undefined) ?? ([] as string[]);
206
221
  this.secrets = (config?.secrets as string[] | undefined) ?? ([] as string[]);
222
+ this.hasMobileConfig = Boolean(config.mobile);
207
223
  this.mobile = this.#resolveMobileConfig(config.mobile);
208
224
  this.docker = this.#makeDockerContent(config?.docker ?? {});
209
225
  }
@@ -214,7 +230,7 @@ export class AkanAppConfig implements AppConfigResult {
214
230
  ...rawMobile
215
231
  } = (mobile ?? {}) as DeepPartial<AkanMobileConfig> & { indexPath?: unknown };
216
232
  const appName = rawMobile.appName ?? this.app.name;
217
- const appId = rawMobile.appId ?? `com.${this.app.name}.app`;
233
+ const appId = rawMobile.appId ?? deriveDefaultAppId(this.baseDevEnv.repoName, this.app.name);
218
234
  const version = rawMobile.version ?? "0.0.1";
219
235
  const buildNum = rawMobile.buildNum ?? 1;
220
236
  const defaultTargetName = this.#defaultMobileTargetName(rawTargets);
@@ -12,12 +12,17 @@ import {
12
12
  formatAndroidReleaseSigningError,
13
13
  getAdbDeviceStateIssues,
14
14
  getMissingAndroidReleaseSigningKeys,
15
+ isPlaceholderAppId,
15
16
  materializeCapacitorConfig,
16
17
  parseDevicectlDevices,
18
+ parseIosRuntimeMajor,
17
19
  parseSimctlDevices,
18
20
  raiseGradleMinSdkVersion,
19
21
  rootCapacitorConfigFilenames,
22
+ SWIFTUICORE_MIN_IOS_MAJOR,
20
23
  sanitizeIosNativeRunEnv,
24
+ selectLocalDevHost,
25
+ sortIosRunTargets,
21
26
  writeRootCapacitorConfig,
22
27
  } from "./capacitorApp";
23
28
 
@@ -182,17 +187,42 @@ describe("iOS native run helpers", () => {
182
187
  parseSimctlDevices(
183
188
  JSON.stringify({
184
189
  devices: {
185
- "iOS 18.0": [
190
+ "com.apple.CoreSimulator.SimRuntime.iOS-18-2": [
186
191
  { udid: "11111111-2222-3333-4444-555555555555", name: "iPhone 16", state: "Shutdown", isAvailable: true },
187
192
  ],
188
193
  },
189
194
  }),
190
195
  ),
191
196
  ).toEqual([
192
- { id: "11111111-2222-3333-4444-555555555555", name: "iPhone 16", kind: "simulator", state: "Shutdown" },
197
+ {
198
+ id: "11111111-2222-3333-4444-555555555555",
199
+ name: "iPhone 16",
200
+ kind: "simulator",
201
+ state: "Shutdown",
202
+ runtime: "iOS 18.2",
203
+ },
193
204
  ]);
194
205
  });
195
206
 
207
+ test("sorts run targets so booted / connected / newest-runtime targets come first", () => {
208
+ const ios17 = { id: "a", name: "iPhone 15", kind: "simulator" as const, state: "Shutdown", runtime: "iOS 17.5" };
209
+ const ios18 = { id: "b", name: "iPhone 16", kind: "simulator" as const, state: "Shutdown", runtime: "iOS 18.2" };
210
+ const booted17 = { id: "c", name: "iPhone SE", kind: "simulator" as const, state: "Booted", runtime: "iOS 17.0" };
211
+ const device = { id: "d", name: "Seok iPhone", kind: "device" as const, state: "available wired paired" };
212
+ // Input is in the old ascending-runtime order that used to default to iOS 17.
213
+ const sorted = sortIosRunTargets([ios17, ios18, booted17, device]);
214
+ expect(sorted.map((t) => t.id)).toEqual(["d", "c", "b", "a"]);
215
+ // Newest available simulator beats the older one, so the interactive default is no longer iOS 17.
216
+ expect(sortIosRunTargets([ios17, ios18]).map((t) => t.id)).toEqual(["b", "a"]);
217
+ });
218
+
219
+ test("parses the major iOS runtime version", () => {
220
+ expect(parseIosRuntimeMajor("iOS 18.2")).toBe(18);
221
+ expect(parseIosRuntimeMajor("iOS 17.5")).toBe(17);
222
+ expect(parseIosRuntimeMajor(undefined)).toBeUndefined();
223
+ expect(SWIFTUICORE_MIN_IOS_MAJOR).toBe(18);
224
+ });
225
+
196
226
  test("builds xcodebuild destinations and output paths by target kind", () => {
197
227
  const deviceCommand = buildIosNativeRunCommand({
198
228
  appRoot: "/repo/apps/minimal",
@@ -228,6 +258,11 @@ describe("iOS native run helpers", () => {
228
258
  expect(classifyIosRunFailure("arm64-apple-darwin20.0: error: unknown argument: '-index-store-path'").kind).toBe(
229
259
  "compiler-toolchain",
230
260
  );
261
+ expect(
262
+ classifyIosRunFailure(
263
+ "dyld[1234]: Library not loaded: /System/Library/Frameworks/SwiftUICore.framework/SwiftUICore",
264
+ ).kind,
265
+ ).toBe("simulator-runtime");
231
266
  });
232
267
 
233
268
  test("removes shell compiler variables from iOS native run env", () => {
@@ -240,6 +275,55 @@ describe("iOS native run helpers", () => {
240
275
  }),
241
276
  ).toEqual({ AKAN_PUBLIC_APP_NAME: "minimal" });
242
277
  });
278
+
279
+ test("flags placeholder bundle identifiers that Apple's portal already claims", () => {
280
+ for (const appId of ["com.myapp.app", "com.myorg.myapp", "com.example.foo", "com.changeme.app", ""]) {
281
+ expect(isPlaceholderAppId(appId)).toBe(true);
282
+ }
283
+ for (const appId of ["com.minimal.app", "com.nearthlab.leadingflight", "io.akanjs.demo"]) {
284
+ expect(isPlaceholderAppId(appId)).toBe(false);
285
+ }
286
+ });
287
+ });
288
+
289
+ describe("selectLocalDevHost", () => {
290
+ const build = (map: Record<string, { family: string; address: string; internal?: boolean }[]>) =>
291
+ map as unknown as Parameters<typeof selectLocalDevHost>[0];
292
+
293
+ test("prefers a routable LAN NIC over an inactive bridge enumerated first", () => {
294
+ const resolution = selectLocalDevHost(
295
+ build({
296
+ bridge0: [{ family: "IPv4", address: "192.168.2.1" }],
297
+ en0: [{ family: "IPv4", address: "172.25.140.140" }],
298
+ bridge100: [{ family: "IPv4", address: "192.168.3.1" }],
299
+ en8: [{ family: "IPv4", address: "172.25.40.227" }],
300
+ }),
301
+ );
302
+ expect(resolution.host).toBe("172.25.140.140");
303
+ expect(resolution.source).toBe("detected");
304
+ });
305
+
306
+ test("an explicit override always wins and is trimmed", () => {
307
+ const resolution = selectLocalDevHost(build({ en0: [{ family: "IPv4", address: "172.25.140.140" }] }), {
308
+ override: " 10.0.0.9 ",
309
+ });
310
+ expect(resolution).toMatchObject({ host: "10.0.0.9", source: "override" });
311
+ });
312
+
313
+ test("never selects a link-local address when a real LAN address exists", () => {
314
+ const resolution = selectLocalDevHost(
315
+ build({
316
+ en0: [{ family: "IPv4", address: "169.254.10.10" }],
317
+ en1: [{ family: "IPv4", address: "192.168.1.5" }],
318
+ }),
319
+ );
320
+ expect(resolution.host).toBe("192.168.1.5");
321
+ });
322
+
323
+ test("falls back to loopback when nothing routable is present", () => {
324
+ const resolution = selectLocalDevHost(build({ lo0: [{ family: "IPv4", address: "127.0.0.1", internal: true }] }));
325
+ expect(resolution).toMatchObject({ host: "127.0.0.1", source: "loopback" });
326
+ });
243
327
  });
244
328
 
245
329
  describe("Android signing diagnostics", () => {
package/capacitorApp.ts CHANGED
@@ -34,6 +34,8 @@ export interface IosRunTarget {
34
34
  name: string;
35
35
  kind: IosRunTargetKind;
36
36
  state?: string;
37
+ /** Display runtime for simulators, e.g. "iOS 18.2". Undefined for physical devices. */
38
+ runtime?: string;
37
39
  devicectlId?: string;
38
40
  xcodebuildId?: string;
39
41
  }
@@ -56,8 +58,13 @@ export type IosRunFailureKind =
56
58
  | "device-registration"
57
59
  | "device-state"
58
60
  | "devicectl-unavailable"
61
+ | "simulator-runtime"
59
62
  | "unknown";
60
63
 
64
+ // Recent iOS SDKs (iOS 18+) split SwiftUI into a SwiftUICore dylib that does not exist on older
65
+ // runtimes, so an app built against them dyld-crashes at launch on an iOS <18 simulator/device.
66
+ export const SWIFTUICORE_MIN_IOS_MAJOR = 18;
67
+
61
68
  export interface IosRunFailureClassification {
62
69
  kind: IosRunFailureKind;
63
70
  title: string;
@@ -113,15 +120,73 @@ interface MaterializeCapacitorConfigOptions {
113
120
  localIp?: string;
114
121
  }
115
122
 
116
- const getLocalIP = () => {
117
- const interfaces = os.networkInterfaces();
118
- for (const iface of Object.values(interfaces)) {
119
- if (!iface) continue;
120
- for (const alias of iface) {
121
- if (alias.family === "IPv4" && !alias.internal) return alias.address;
123
+ export interface LocalDevHostResolution {
124
+ host: string;
125
+ source: "override" | "detected" | "loopback";
126
+ candidates: { name: string; address: string }[];
127
+ }
128
+
129
+ // Interface-name prefixes that are almost never the routable LAN NIC a physical device can reach:
130
+ // bridges (Thunderbolt/USB), tunnels, AirDrop/awrl, VM/container virtual adapters.
131
+ const virtualInterfacePrefixes = [
132
+ "bridge",
133
+ "utun",
134
+ "llw",
135
+ "awdl",
136
+ "ap",
137
+ "vmnet",
138
+ "vnic",
139
+ "tap",
140
+ "tun",
141
+ "docker",
142
+ "veth",
143
+ "vboxnet",
144
+ "gif",
145
+ "stf",
146
+ ];
147
+ const physicalInterfacePrefixes = ["en", "eth", "wlan", "wlp", "enp", "eno", "wlo"];
148
+
149
+ const isPrivateLanIpv4 = (address: string): boolean => {
150
+ if (address.startsWith("10.") || address.startsWith("192.168.")) return true;
151
+ const secondOctet = Number(address.match(/^172\.(\d+)\./)?.[1]);
152
+ return Number.isFinite(secondOctet) && secondOctet >= 16 && secondOctet <= 31;
153
+ };
154
+
155
+ // Higher = more likely to be the reachable LAN address. Link-local (169.254) is never routable;
156
+ // virtual/bridge interfaces are demoted below real NICs; private-LAN ranges get a small boost.
157
+ const scoreDevHostCandidate = (name: string, address: string): number => {
158
+ const lowerName = name.toLowerCase();
159
+ let score = 0;
160
+ if (address.startsWith("169.254.")) score -= 1000;
161
+ if (virtualInterfacePrefixes.some((prefix) => lowerName.startsWith(prefix))) score -= 100;
162
+ else if (physicalInterfacePrefixes.some((prefix) => lowerName.startsWith(prefix))) score += 100;
163
+ if (isPrivateLanIpv4(address)) score += 10;
164
+ return score;
165
+ };
166
+
167
+ // Pick the dev-server host a physical device should connect to. An explicit override always wins;
168
+ // otherwise rank non-internal IPv4 interfaces so a down/virtual interface (e.g. an inactive
169
+ // Thunderbolt bridge enumerated first) never shadows a real LAN NIC. Deterministic tie-break.
170
+ export const selectLocalDevHost = (
171
+ interfaces: NodeJS.Dict<os.NetworkInterfaceInfo[]>,
172
+ { override }: { override?: string } = {},
173
+ ): LocalDevHostResolution => {
174
+ const candidates: { name: string; address: string }[] = [];
175
+ for (const [name, aliases] of Object.entries(interfaces)) {
176
+ for (const alias of aliases ?? []) {
177
+ if (alias.family !== "IPv4" || alias.internal) continue;
178
+ candidates.push({ name, address: alias.address });
122
179
  }
123
180
  }
124
- return "127.0.0.1";
181
+ const trimmedOverride = override?.trim();
182
+ if (trimmedOverride) return { host: trimmedOverride, source: "override", candidates };
183
+ const [best] = [...candidates].sort(
184
+ (a, b) =>
185
+ scoreDevHostCandidate(b.name, b.address) - scoreDevHostCandidate(a.name, a.address) ||
186
+ a.name.localeCompare(b.name) ||
187
+ a.address.localeCompare(b.address),
188
+ );
189
+ return best ? { host: best.address, source: "detected", candidates } : { host: "127.0.0.1", source: "loopback", candidates };
125
190
  };
126
191
 
127
192
  const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -147,6 +212,38 @@ const dedupeIosRunTargets = (targets: IosRunTarget[]) => {
147
212
  return [...byKey.values()];
148
213
  };
149
214
 
215
+ // Normalize a simctl runtime — either the JSON key ("com.apple.CoreSimulator.SimRuntime.iOS-18-2")
216
+ // or a per-device `runtimeIdentifier` / text header ("iOS 18.2") — into a "iOS 18.2" display string.
217
+ const formatSimctlRuntime = (key?: string): string | undefined => {
218
+ if (!key) return undefined;
219
+ if (/^(iOS|watchOS|tvOS|visionOS)\s+[\d.]+$/i.test(key)) return key;
220
+ const match = key.match(/(iOS|watchOS|tvOS|visionOS)-(\d+)(?:-(\d+))?/i);
221
+ if (!match) return undefined;
222
+ return `${match[1]} ${match[2]}${match[3] ? `.${match[3]}` : ""}`;
223
+ };
224
+
225
+ // Extract the major OS version from a runtime display string ("iOS 18.2" → 18). Undefined for
226
+ // physical devices (no runtime) or unparseable values.
227
+ export const parseIosRuntimeMajor = (runtime?: string): number | undefined => {
228
+ const major = runtime?.match(/(\d+)/)?.[1];
229
+ if (major === undefined) return undefined;
230
+ const parsed = Number.parseInt(major, 10);
231
+ return Number.isNaN(parsed) ? undefined : parsed;
232
+ };
233
+
234
+ // Rank for default-target ordering: ready targets (booted simulator / connected device) first, then
235
+ // physical devices, then newer simulator runtimes. Fixes the old ascending-runtime order that made a
236
+ // stale iOS 17.x simulator the default pick.
237
+ const iosRunTargetRank = (target: IosRunTarget): number => {
238
+ const state = target.state?.toLowerCase() ?? "";
239
+ const ready = state.includes("booted") || state.includes("connected") || state.includes("available") ? 1000 : 0;
240
+ const device = target.kind === "device" ? 500 : 0;
241
+ return ready + device + (parseIosRuntimeMajor(target.runtime) ?? 0);
242
+ };
243
+
244
+ export const sortIosRunTargets = (targets: IosRunTarget[]): IosRunTarget[] =>
245
+ [...targets].sort((a, b) => iosRunTargetRank(b) - iosRunTargetRank(a) || a.name.localeCompare(b.name));
246
+
150
247
  function walkRecords(value: unknown, visit: (record: Record<string, unknown>) => void) {
151
248
  if (Array.isArray(value)) {
152
249
  for (const item of value) walkRecords(item, visit);
@@ -202,22 +299,36 @@ export function parseSimctlDevices(output: string): IosRunTarget[] {
202
299
  try {
203
300
  const json = JSON.parse(output) as { devices?: Record<string, unknown[]> };
204
301
  const devices = json.devices ?? {};
205
- return Object.values(devices)
206
- .flat()
207
- .filter(isRecord)
208
- .flatMap((device) => {
302
+ return Object.entries(devices).flatMap(([runtimeKey, list]) => {
303
+ const runtime = formatSimctlRuntime(runtimeKey);
304
+ return (Array.isArray(list) ? list : []).filter(isRecord).flatMap((device) => {
209
305
  const id = firstString(device.udid, device.UDID, device.identifier);
210
306
  const name = firstString(device.name, device.displayName);
211
307
  const isAvailable = device.isAvailable !== false && device.availabilityError === undefined;
212
308
  if (!id || !name || !isAvailable) return [];
213
- return [{ id, name, kind: "simulator" as const, state: asString(device.state) }];
309
+ return [
310
+ {
311
+ id,
312
+ name,
313
+ kind: "simulator" as const,
314
+ state: asString(device.state),
315
+ runtime: runtime ?? formatSimctlRuntime(asString(device.runtimeIdentifier)),
316
+ },
317
+ ];
214
318
  });
319
+ });
215
320
  } catch {
216
321
  const targets: IosRunTarget[] = [];
322
+ let runtime: string | undefined;
217
323
  for (const line of output.split(/\r?\n/)) {
324
+ const header = line.match(/^--\s*(.+?)\s*--\s*$/);
325
+ if (header) {
326
+ runtime = formatSimctlRuntime(header[1]);
327
+ continue;
328
+ }
218
329
  const match = line.match(/^\s*(.+?)\s+\(([0-9A-Fa-f-]{20,})\)\s+\(([^)]+)\)/);
219
330
  if (!match) continue;
220
- targets.push({ id: match[2], name: match[1].trim(), kind: "simulator", state: match[3] });
331
+ targets.push({ id: match[2], name: match[1].trim(), kind: "simulator", state: match[3], runtime });
221
332
  }
222
333
  return targets;
223
334
  }
@@ -260,6 +371,16 @@ export function buildIosNativeRunCommand({
260
371
 
261
372
  export function classifyIosRunFailure(log: string): IosRunFailureClassification {
262
373
  const lower = log.toLowerCase();
374
+ if (
375
+ lower.includes("swiftuicore") &&
376
+ (lower.includes("library not loaded") || lower.includes("library missing") || lower.includes("dyld"))
377
+ ) {
378
+ return {
379
+ kind: "simulator-runtime",
380
+ title: "App crashed loading SwiftUICore — the iOS runtime is too old.",
381
+ detail: `The build links SwiftUICore, which only exists on iOS ${SWIFTUICORE_MIN_IOS_MAJOR}+. Rerun on an iOS ${SWIFTUICORE_MIN_IOS_MAJOR} or newer simulator/device (pass --device to pick one non-interactively).`,
382
+ };
383
+ }
263
384
  if (lower.includes("unknown argument: '-index-store-path'") || lower.includes("compiler was not recognized")) {
264
385
  return {
265
386
  kind: "compiler-toolchain",
@@ -379,6 +500,24 @@ export function sanitizeIosNativeRunEnv(env: MobileCommandEnv): MobileCommandEnv
379
500
  return Object.fromEntries(Object.entries(env).filter(([key]) => !iosNativeBlockedEnvKeys.has(key)));
380
501
  }
381
502
 
503
+ // Bundle IDs that ship as scaffold placeholders (or use an obviously generic org segment) and are
504
+ // almost always already claimed on Apple's developer portal, so device signing fails with
505
+ // "cannot be registered to your development team". A unique reverse-DNS id fixes it.
506
+ export const PLACEHOLDER_APP_IDS = [
507
+ "com.myapp.app",
508
+ "com.myorg.myapp",
509
+ "com.example.app",
510
+ "com.example.myapp",
511
+ ] as const;
512
+ const placeholderAppIdSegment = /^(example|examples|myorg|myapp|mycompany|myorganization|changeme|todo|sample|test)$/;
513
+
514
+ export const isPlaceholderAppId = (appId: string | null | undefined): boolean => {
515
+ const normalized = appId?.trim().toLowerCase() ?? "";
516
+ if (!normalized) return true;
517
+ if ((PLACEHOLDER_APP_IDS as readonly string[]).includes(normalized)) return true;
518
+ return normalized.split(".").some((segment) => placeholderAppIdSegment.test(segment));
519
+ };
520
+
382
521
  const androidReleaseSigningKeys = [
383
522
  "MYAPP_RELEASE_STORE_FILE",
384
523
  "MYAPP_RELEASE_STORE_PASSWORD",
@@ -651,22 +790,46 @@ export class CapacitorApp {
651
790
  }
652
791
 
653
792
  async #selectIosRunTarget(deviceId?: string) {
654
- const targets = await this.#loadIosRunTargets();
793
+ const targets = sortIosRunTargets(await this.#loadIosRunTargets());
655
794
  if (deviceId) {
656
- const found = targets.find((target) => target.id === deviceId);
657
- if (!found) throw new Error(`iOS run target '${deviceId}' was not found.`);
795
+ const needle = deviceId.toLowerCase();
796
+ const found =
797
+ targets.find((target) => target.id === deviceId) ??
798
+ targets.find((target) => target.name.toLowerCase() === needle) ??
799
+ targets.find(
800
+ (target) =>
801
+ target.name.toLowerCase().includes(needle) ||
802
+ target.id.toLowerCase().includes(needle) ||
803
+ (target.runtime?.toLowerCase().includes(needle) ?? false),
804
+ );
805
+ if (!found) {
806
+ const available = targets.map((t) => `${t.name}${t.runtime ? ` (${t.runtime})` : ""}`).join(", ") || "none";
807
+ throw new Error(`iOS run target '${deviceId}' was not found. Available: ${available}`);
808
+ }
809
+ this.#warnIfLegacySimulatorRuntime(found);
658
810
  return found;
659
811
  }
660
812
  if (targets.length === 0) {
661
813
  throw new Error("No iOS run targets found. Open Simulator or connect an iPhone, then retry.");
662
814
  }
663
- return await select<IosRunTarget>({
815
+ const selected = await select<IosRunTarget>({
664
816
  message: "Select iOS run target",
665
817
  choices: targets.map((target) => ({
666
- name: `[${target.kind}] ${target.name}${target.state ? ` (${target.state})` : ""}`,
818
+ name: `[${target.kind}] ${target.name}${target.runtime ? ` — ${target.runtime}` : ""}${target.state ? ` (${target.state})` : ""}`,
667
819
  value: target,
668
820
  })),
669
821
  });
822
+ this.#warnIfLegacySimulatorRuntime(selected);
823
+ return selected;
824
+ }
825
+
826
+ #warnIfLegacySimulatorRuntime(target: IosRunTarget) {
827
+ if (target.kind !== "simulator") return;
828
+ const major = parseIosRuntimeMajor(target.runtime);
829
+ if (major === undefined || major >= SWIFTUICORE_MIN_IOS_MAJOR) return;
830
+ this.app.logger.warn(
831
+ `Selected simulator runs ${target.runtime ?? "an older iOS"}. Recent SDK builds link SwiftUICore and require iOS ${SWIFTUICORE_MIN_IOS_MAJOR}+; if the app crashes at launch with a "Library not loaded: SwiftUICore" dyld error, pick an iOS ${SWIFTUICORE_MIN_IOS_MAJOR}+ simulator instead.`,
832
+ );
670
833
  }
671
834
 
672
835
  async #loadIosRunTargets() {
@@ -937,7 +1100,13 @@ export class CapacitorApp {
937
1100
  }
938
1101
  async #writeCapacitorConfig({ operation }: Pick<RunConfig, "operation">, commandEnv: MobileCommandEnv) {
939
1102
  await mkdir(this.targetRoot, { recursive: true });
940
- const localIp = operation === "local" ? getLocalIP() : undefined;
1103
+ let localIp: string | undefined;
1104
+ if (operation === "local") {
1105
+ const override = commandEnv.AKAN_PUBLIC_CLIENT_HOST ?? process.env.AKAN_PUBLIC_CLIENT_HOST;
1106
+ const resolution = selectLocalDevHost(os.networkInterfaces(), { override });
1107
+ localIp = resolution.host;
1108
+ this.#logDevHostResolution(resolution, commandEnv);
1109
+ }
941
1110
  const config = materializeCapacitorConfig(this.target, {
942
1111
  operation,
943
1112
  localIp,
@@ -947,6 +1116,21 @@ export class CapacitorApp {
947
1116
  await Bun.write(path.join(this.targetRoot, "capacitor.config.json"), content);
948
1117
  return content;
949
1118
  }
1119
+ // Surface the live-reload URL a physical device must reach, and warn when auto-detection landed on
1120
+ // a likely-unreachable host so a blank WebView is not mistaken for an app bug.
1121
+ #logDevHostResolution(resolution: LocalDevHostResolution, commandEnv: MobileCommandEnv) {
1122
+ this.app.log(`Mobile live-reload server: ${this.#localCsrUrl(resolution.host, commandEnv)}`);
1123
+ if (resolution.source === "override") return;
1124
+ const suspicious = resolution.host === "127.0.0.1" || resolution.host.startsWith("169.254.");
1125
+ const alternatives = resolution.candidates.filter((candidate) => candidate.address !== resolution.host);
1126
+ if (!suspicious && alternatives.length === 0) return;
1127
+ const alternativeText = alternatives.length
1128
+ ? ` Other interfaces: ${alternatives.map((candidate) => `${candidate.address} (${candidate.name})`).join(", ")}.`
1129
+ : "";
1130
+ this.app.logger.warn(
1131
+ `A physical device must reach ${resolution.host} on your LAN.${suspicious ? " That address looks non-routable." : ""}${alternativeText} If the device shows a blank screen, pin the right one with AKAN_PUBLIC_CLIENT_HOST=<ip>.`,
1132
+ );
1133
+ }
950
1134
  async #prepareTargetAssets() {
951
1135
  if (!this.target.assets) return;
952
1136
  await mkdir(this.targetAssetRoot, { recursive: true });
@@ -36,8 +36,14 @@ interface IncrementalBuilderOptions {
36
36
  cssCompiler: CssCompiler;
37
37
  optimizedFonts: Awaited<ReturnType<FontOptimizer["optimize"]>>;
38
38
  discovery: ClientEntryDiscovery;
39
+ initialGeneration?: number;
39
40
  }
40
41
 
42
+ type IncrementalBuilderBootDeps = Pick<
43
+ IncrementalBuilderOptions,
44
+ "artifact" | "cssCompiler" | "optimizedFonts" | "discovery"
45
+ >;
46
+
41
47
  class IncrementalBuilder {
42
48
  #logger = new Logger("IncrementalBuilder");
43
49
  #app: App;
@@ -62,11 +68,16 @@ class IncrementalBuilder {
62
68
  this.#cssCompiler = options.cssCompiler;
63
69
  this.#optimizedFonts = options.optimizedFonts;
64
70
  this.#discovery = options.discovery;
71
+ this.#generation = options.initialGeneration ?? 0;
65
72
  this.#changePlanner = new DevChangePlanner({ workspaceRoot: options.app.workspace.workspaceRoot });
66
73
  this.#generatedIndexSync = new DevGeneratedIndexSync({ workspaceRoot: options.app.workspace.workspaceRoot });
67
74
  this.#autoImportSync = new AutoImportSync({ workspaceRoot: options.app.workspace.workspaceRoot });
68
75
  }
69
76
 
77
+ get #artifactDir() {
78
+ return `${this.#app.cwdPath}/.akan/artifact`;
79
+ }
80
+
70
81
  async handleBuildRoute(msg: BuilderReq): Promise<BuilderRes> {
71
82
  return this.#enqueueWork(`build-route:${msg.routeId}`, async () => this.#handleBuildRoute(msg));
72
83
  }
@@ -255,7 +266,7 @@ class IncrementalBuilder {
255
266
  });
256
267
  }
257
268
  async installWatcher() {
258
- const [appDir, artifactDir] = [`${this.#app.cwdPath}/page`, `${this.#app.cwdPath}/.akan/artifact`];
269
+ const [appDir, artifactDir] = [`${this.#app.cwdPath}/page`, this.#artifactDir];
259
270
  const roots = await new WatchRootResolver(this.#app).resolve();
260
271
  const watcher = new HmrWatcher({
261
272
  roots,
@@ -365,48 +376,142 @@ class IncrementalBuilder {
365
376
  }
366
377
 
367
378
  async boot(): Promise<void> {
368
- process.on("message", async (msg: BuilderMessage) => {
369
- if (!msg || typeof msg !== "object") return;
370
- switch (msg.type) {
371
- case "build-route": {
372
- const res = await this.handleBuildRoute(msg);
373
- process.send?.(res);
374
- return;
375
- }
376
- default:
377
- return;
378
- }
379
- });
380
- // The IPC channel closes when the dev host dies (including SIGKILL); exit instead of running
381
- // as an orphaned watcher that keeps rebuilding for nobody.
382
- process.on("disconnect", () => {
383
- this.#logger.warn("host IPC channel closed; exiting builder");
384
- process.exit(0);
385
- });
386
379
  if (this.#watch) await this.installWatcher();
387
380
  process.send?.({ type: "builder-ready" });
388
381
  this.#logger.verbose(`ready (watch=${this.#watch})`);
389
382
  }
390
383
 
384
+ /**
385
+ * After a degraded boot recovers, the backend is still serving the last-good bundle; push a
386
+ * fresh pages/css state so connected browsers pick up the fixed code without another edit.
387
+ */
388
+ async announceRecoveredState(changedFiles: string[]): Promise<void> {
389
+ const generation = ++this.#generation;
390
+ await this.#enqueueWork("boot-recovered", async () => {
391
+ try {
392
+ const next = await new PagesBundleBuilder(this.#app).build();
393
+ process.send?.({
394
+ type: "pages-updated",
395
+ data: { bundlePath: next.bundlePath, buildId: next.buildId, generation, changedFiles },
396
+ });
397
+ this.#sendBuildStatus("pages", { generation, ok: true, files: changedFiles, message: "Boot build recovered" });
398
+ } catch (err) {
399
+ const message = err instanceof Error ? err.message : String(err);
400
+ this.#logger.error(`recovered pages rebundle failed: ${message}`);
401
+ this.#sendBuildStatus("pages", { generation, ok: false, files: changedFiles, message });
402
+ }
403
+ this.scheduleCssRebuild(this.#artifactDir, { refresh: true, generation, changedFiles });
404
+ });
405
+ }
406
+
391
407
  #shouldRebuildCsr() {
392
408
  // CSR is served by `akn start`, so rebuild dev CSR artifacts until incremental CSR HMR is implemented.
393
409
  return true;
394
410
  }
395
411
 
396
- static async create() {
412
+ static async #buildBootDeps(app: App): Promise<IncrementalBuilderBootDeps> {
413
+ const { artifact, cssCompiler, optimizedFonts } = await new SsrBaseArtifactBuilder(app).build();
414
+ await new CsrArtifactBuilder(app).build();
415
+ const discovery = await GraphClientEntryDiscovery.create(app);
416
+ return { artifact, cssCompiler, optimizedFonts, discovery };
417
+ }
418
+
419
+ /**
420
+ * A compile error in the boot build must not kill the builder: the builder is the dev server's
421
+ * file watcher, so exiting here leaves nothing to notice the fix. Report the failure, emit
422
+ * builder-ready so the host keeps the backend serving the last-good artifact, then retry the
423
+ * boot build on every file change until it succeeds.
424
+ */
425
+ static #recoverBoot(
426
+ app: App,
427
+ bootError: unknown,
428
+ logger: Logger,
429
+ ): Promise<{ builder: IncrementalBuilder; changedFiles: string[] }> {
430
+ const firstMessage = bootError instanceof Error ? bootError.message : String(bootError);
431
+ logger.error(`boot build failed; entering degraded watch mode until the error is fixed: ${firstMessage}`);
432
+ let generation = 0;
433
+ const sendFailure = (files: string[], message: string) => {
434
+ process.send?.({
435
+ type: "build-status",
436
+ data: { generation, phase: "pages", ok: false, files, message: `Boot build failed: ${message}` },
437
+ });
438
+ };
439
+ sendFailure([], firstMessage);
440
+ process.send?.({ type: "builder-ready" });
441
+ return new Promise((resolve, reject) => {
442
+ void (async () => {
443
+ const roots = await new WatchRootResolver(app).resolve();
444
+ const watcher = new HmrWatcher({
445
+ roots,
446
+ logger,
447
+ onBatch: async (batch) => {
448
+ generation += 1;
449
+ const files = [...batch.files].sort();
450
+ try {
451
+ // A broken akan.config.ts caches its import failure; re-import it before rebuilding.
452
+ if (new Set(batch.kinds).has("config")) await app.getConfig({ refresh: true });
453
+ const deps = await IncrementalBuilder.#buildBootDeps(app);
454
+ const builder = new IncrementalBuilder({ app, watch: true, initialGeneration: generation, ...deps });
455
+ watcher.stop();
456
+ logger.info(`boot build recovered generation=${generation}`);
457
+ resolve({ builder, changedFiles: files });
458
+ } catch (err) {
459
+ const message = err instanceof Error ? err.message : String(err);
460
+ logger.error(`boot build retry failed: ${message}`);
461
+ sendFailure(files, message);
462
+ }
463
+ },
464
+ });
465
+ watcher.start();
466
+ logger.warn(`[degraded] watching ${roots.length} roots for a fix`);
467
+ })().catch(reject);
468
+ });
469
+ }
470
+
471
+ static async main(): Promise<void> {
472
+ const logger = new Logger("IncrementalBuilder");
397
473
  const { appName, repoName, workspaceRoot } = WorkspaceExecutor.getBaseDevEnv();
398
474
  if (!workspaceRoot || !appName) throw new Error("AKAN_WORKSPACE_ROOT or AKAN_PUBLIC_APP_NAME is not set");
399
475
  const workspace = WorkspaceExecutor.fromRoot({ workspaceRoot, repoName });
400
476
  const app = AppExecutor.from(workspace, appName);
401
477
  const watch = process.env.AKAN_WATCH !== "0";
402
- const { artifact, cssCompiler, optimizedFonts } = await new SsrBaseArtifactBuilder(app).build();
403
- await new CsrArtifactBuilder(app).build();
404
- const discovery = await GraphClientEntryDiscovery.create(app);
405
- return new IncrementalBuilder({ app, cssCompiler, artifact, watch, optimizedFonts, discovery });
478
+ let builder: IncrementalBuilder | null = null;
479
+ // Registered before the boot build so build-route requests get an error response (instead of
480
+ // hanging the backend) while the builder is still booting or recovering from a failed build.
481
+ process.on("message", (msg: BuilderMessage) => {
482
+ if (!msg || typeof msg !== "object" || msg.type !== "build-route") return;
483
+ if (!builder) {
484
+ process.send?.({
485
+ type: "build-route-res",
486
+ id: msg.id,
487
+ ok: false,
488
+ error: "builder is recovering from a failed boot build; retry after the build error is fixed",
489
+ });
490
+ return;
491
+ }
492
+ void builder.handleBuildRoute(msg).then((res) => process.send?.(res));
493
+ });
494
+ // The IPC channel closes when the dev host dies (including SIGKILL); exit instead of running
495
+ // as an orphaned watcher that keeps rebuilding for nobody.
496
+ process.on("disconnect", () => {
497
+ logger.warn("host IPC channel closed; exiting builder");
498
+ process.exit(0);
499
+ });
500
+ let recoveredFiles: string[] | null = null;
501
+ try {
502
+ builder = new IncrementalBuilder({ app, watch, ...(await IncrementalBuilder.#buildBootDeps(app)) });
503
+ } catch (err) {
504
+ if (!watch) throw err;
505
+ const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger);
506
+ builder = recovered.builder;
507
+ recoveredFiles = recovered.changedFiles;
508
+ }
509
+ await builder.boot();
510
+ if (recoveredFiles) await builder.announceRecoveredState(recoveredFiles);
406
511
  }
407
512
  }
408
513
 
409
- void (await IncrementalBuilder.create()).boot().catch((err) => {
514
+ void IncrementalBuilder.main().catch((err) => {
410
515
  console.error(err);
411
516
  process.exit(1);
412
517
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.4.0-rc.7",
3
+ "version": "2.4.0-rc.9",
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.4.0-rc.7",
35
+ "akanjs": "2.4.0-rc.9",
36
36
  "chalk": "^5.6.2",
37
37
  "commander": "^14.0.3",
38
38
  "daisyui": "5.5.23",