@akanjs/devkit 2.4.0-rc.8 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # @akanjs/devkit
2
2
 
3
+ ## 2.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 23d43b3: Harden dev host recovery during failed builds:
8
+
9
+ - Defer builder/backend recycle while a generation's build is still failing
10
+ - Merge deferred invalidate batches so restarts cover every skipped change
11
+ - Recover the builder with exponential backoff instead of giving up
12
+ - Revive a backend that gave up once the build goes green again
13
+ - Resurrect dev children after a failed recycle so the error overlay stays reachable
14
+ - Enter degraded builder boot mode on compile errors and retry on the next edit
15
+ - Announce recovered pages/css state after a degraded boot succeeds
16
+
17
+ - 18abf71: Improve dev server stability:
18
+
19
+ - Add `isPortInUseError` utility for detecting EADDRINUSE across Bun versions
20
+ - Stop crash-looping replicas after max boot failures in dev mode (`akan start`)
21
+ - Handle parent IPC disconnect to prevent orphaned gateway/child processes
22
+ - Report `wsUpstream` in ready IPC so gateway routes to the actual bound port
23
+ - Fall back to ephemeral port when preferred WS port is in use
24
+ - Support controlled dev-host restart on config changes (`akan.config.ts`, `tsconfig`)
25
+ - Forward backend build-status IPC to dev host for error surfacing in HMR overlay
26
+ - Limit backend recovery attempts (5 max) and idle until next server-side edit
27
+ - Add integration tests for config-edit restart and boot-failure recovery
28
+
29
+ - 23d43b3: Improve the mobile Capacitor workflow:
30
+
31
+ - Auto-declare default Capacitor plugins in the app package.json before iOS/Android launch
32
+ - Expand mobile runtime peer dependencies and workspace-root preflight installs
33
+ - Derive repo-scoped default bundle ids to avoid Apple portal collisions
34
+ - Add `akan doctor --ios` to flag placeholder bundle identifiers
35
+ - Add `--device` to `akan start ios` for non-interactive simulator/device selection
36
+ - Prefer newer iOS runtimes and warn on SwiftUICore-incompatible simulators
37
+ - Detect SwiftUICore dyld failures with actionable guidance
38
+ - Select a routable LAN host for mobile live reload with override support
39
+ - Raise Android minSdkVersion to 26 for bundled Capacitor plugins
40
+ - Include `@capacitor-community/fcm` in push notification runtime packages
41
+ - Resolve client port from `window.location` on the browser client
42
+
43
+ ### Patch Changes
44
+
45
+ - d56a8f0: Ship Pretendard as the default font for newly created apps:
46
+
47
+ - Bundle Pretendard woff2 files under the app template `public/fonts`
48
+ - Declare `fonts` with `default: true` in the generated root `_layout.tsx`
49
+
50
+ - Updated dependencies [d56a8f0]
51
+ - Updated dependencies [23d43b3]
52
+ - Updated dependencies [18abf71]
53
+ - Updated dependencies [23d43b3]
54
+ - akanjs@2.4.0
55
+
3
56
  ## 2.3.11
4
57
 
5
58
  ### Minor Changes
@@ -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
  },
@@ -368,6 +368,28 @@ describe("AkanAppConfig", () => {
368
368
  ),
369
369
  ).toThrow("unknown basePath");
370
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
+ });
371
393
  });
372
394
 
373
395
  describe("AkanLibConfig", () => {
@@ -123,6 +123,21 @@ const normalizeStringList = (values: string[] | undefined) => {
123
123
  return normalized.length > 0 ? [...new Set(normalized)] : undefined;
124
124
  };
125
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
+
126
141
  const normalizeDeepLinkDomain = (domain: string) => {
127
142
  const normalized = domain.trim();
128
143
  if (!normalized) return "";
@@ -164,6 +179,8 @@ export class AkanAppConfig implements AppConfigResult {
164
179
  i18n: AkanI18nConfig;
165
180
  publicEnv: string[];
166
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;
167
184
  secrets: string[];
168
185
  baseDevEnv: BaseDevEnv;
169
186
  libs: string[];
@@ -202,6 +219,7 @@ export class AkanAppConfig implements AppConfigResult {
202
219
  process.env.AKAN_PUBLIC_LOCALES = this.i18n.locales.join(",");
203
220
  this.publicEnv = (config?.publicEnv as string[] | undefined) ?? ([] as string[]);
204
221
  this.secrets = (config?.secrets as string[] | undefined) ?? ([] as string[]);
222
+ this.hasMobileConfig = Boolean(config.mobile);
205
223
  this.mobile = this.#resolveMobileConfig(config.mobile);
206
224
  this.docker = this.#makeDockerContent(config?.docker ?? {});
207
225
  }
@@ -212,7 +230,7 @@ export class AkanAppConfig implements AppConfigResult {
212
230
  ...rawMobile
213
231
  } = (mobile ?? {}) as DeepPartial<AkanMobileConfig> & { indexPath?: unknown };
214
232
  const appName = rawMobile.appName ?? this.app.name;
215
- const appId = rawMobile.appId ?? `com.${this.app.name}.app`;
233
+ const appId = rawMobile.appId ?? deriveDefaultAppId(this.baseDevEnv.repoName, this.app.name);
216
234
  const version = rawMobile.version ?? "0.0.1";
217
235
  const buildNum = rawMobile.buildNum ?? 1;
218
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,75 @@ 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
190
+ ? { host: best.address, source: "detected", candidates }
191
+ : { host: "127.0.0.1", source: "loopback", candidates };
125
192
  };
126
193
 
127
194
  const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -147,6 +214,38 @@ const dedupeIosRunTargets = (targets: IosRunTarget[]) => {
147
214
  return [...byKey.values()];
148
215
  };
149
216
 
217
+ // Normalize a simctl runtime — either the JSON key ("com.apple.CoreSimulator.SimRuntime.iOS-18-2")
218
+ // or a per-device `runtimeIdentifier` / text header ("iOS 18.2") — into a "iOS 18.2" display string.
219
+ const formatSimctlRuntime = (key?: string): string | undefined => {
220
+ if (!key) return undefined;
221
+ if (/^(iOS|watchOS|tvOS|visionOS)\s+[\d.]+$/i.test(key)) return key;
222
+ const match = key.match(/(iOS|watchOS|tvOS|visionOS)-(\d+)(?:-(\d+))?/i);
223
+ if (!match) return undefined;
224
+ return `${match[1]} ${match[2]}${match[3] ? `.${match[3]}` : ""}`;
225
+ };
226
+
227
+ // Extract the major OS version from a runtime display string ("iOS 18.2" → 18). Undefined for
228
+ // physical devices (no runtime) or unparseable values.
229
+ export const parseIosRuntimeMajor = (runtime?: string): number | undefined => {
230
+ const major = runtime?.match(/(\d+)/)?.[1];
231
+ if (major === undefined) return undefined;
232
+ const parsed = Number.parseInt(major, 10);
233
+ return Number.isNaN(parsed) ? undefined : parsed;
234
+ };
235
+
236
+ // Rank for default-target ordering: ready targets (booted simulator / connected device) first, then
237
+ // physical devices, then newer simulator runtimes. Fixes the old ascending-runtime order that made a
238
+ // stale iOS 17.x simulator the default pick.
239
+ const iosRunTargetRank = (target: IosRunTarget): number => {
240
+ const state = target.state?.toLowerCase() ?? "";
241
+ const ready = state.includes("booted") || state.includes("connected") || state.includes("available") ? 1000 : 0;
242
+ const device = target.kind === "device" ? 500 : 0;
243
+ return ready + device + (parseIosRuntimeMajor(target.runtime) ?? 0);
244
+ };
245
+
246
+ export const sortIosRunTargets = (targets: IosRunTarget[]): IosRunTarget[] =>
247
+ [...targets].sort((a, b) => iosRunTargetRank(b) - iosRunTargetRank(a) || a.name.localeCompare(b.name));
248
+
150
249
  function walkRecords(value: unknown, visit: (record: Record<string, unknown>) => void) {
151
250
  if (Array.isArray(value)) {
152
251
  for (const item of value) walkRecords(item, visit);
@@ -202,22 +301,36 @@ export function parseSimctlDevices(output: string): IosRunTarget[] {
202
301
  try {
203
302
  const json = JSON.parse(output) as { devices?: Record<string, unknown[]> };
204
303
  const devices = json.devices ?? {};
205
- return Object.values(devices)
206
- .flat()
207
- .filter(isRecord)
208
- .flatMap((device) => {
304
+ return Object.entries(devices).flatMap(([runtimeKey, list]) => {
305
+ const runtime = formatSimctlRuntime(runtimeKey);
306
+ return (Array.isArray(list) ? list : []).filter(isRecord).flatMap((device) => {
209
307
  const id = firstString(device.udid, device.UDID, device.identifier);
210
308
  const name = firstString(device.name, device.displayName);
211
309
  const isAvailable = device.isAvailable !== false && device.availabilityError === undefined;
212
310
  if (!id || !name || !isAvailable) return [];
213
- return [{ id, name, kind: "simulator" as const, state: asString(device.state) }];
311
+ return [
312
+ {
313
+ id,
314
+ name,
315
+ kind: "simulator" as const,
316
+ state: asString(device.state),
317
+ runtime: runtime ?? formatSimctlRuntime(asString(device.runtimeIdentifier)),
318
+ },
319
+ ];
214
320
  });
321
+ });
215
322
  } catch {
216
323
  const targets: IosRunTarget[] = [];
324
+ let runtime: string | undefined;
217
325
  for (const line of output.split(/\r?\n/)) {
326
+ const header = line.match(/^--\s*(.+?)\s*--\s*$/);
327
+ if (header) {
328
+ runtime = formatSimctlRuntime(header[1]);
329
+ continue;
330
+ }
218
331
  const match = line.match(/^\s*(.+?)\s+\(([0-9A-Fa-f-]{20,})\)\s+\(([^)]+)\)/);
219
332
  if (!match) continue;
220
- targets.push({ id: match[2], name: match[1].trim(), kind: "simulator", state: match[3] });
333
+ targets.push({ id: match[2], name: match[1].trim(), kind: "simulator", state: match[3], runtime });
221
334
  }
222
335
  return targets;
223
336
  }
@@ -260,6 +373,16 @@ export function buildIosNativeRunCommand({
260
373
 
261
374
  export function classifyIosRunFailure(log: string): IosRunFailureClassification {
262
375
  const lower = log.toLowerCase();
376
+ if (
377
+ lower.includes("swiftuicore") &&
378
+ (lower.includes("library not loaded") || lower.includes("library missing") || lower.includes("dyld"))
379
+ ) {
380
+ return {
381
+ kind: "simulator-runtime",
382
+ title: "App crashed loading SwiftUICore — the iOS runtime is too old.",
383
+ 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).`,
384
+ };
385
+ }
263
386
  if (lower.includes("unknown argument: '-index-store-path'") || lower.includes("compiler was not recognized")) {
264
387
  return {
265
388
  kind: "compiler-toolchain",
@@ -379,6 +502,24 @@ export function sanitizeIosNativeRunEnv(env: MobileCommandEnv): MobileCommandEnv
379
502
  return Object.fromEntries(Object.entries(env).filter(([key]) => !iosNativeBlockedEnvKeys.has(key)));
380
503
  }
381
504
 
505
+ // Bundle IDs that ship as scaffold placeholders (or use an obviously generic org segment) and are
506
+ // almost always already claimed on Apple's developer portal, so device signing fails with
507
+ // "cannot be registered to your development team". A unique reverse-DNS id fixes it.
508
+ export const PLACEHOLDER_APP_IDS = [
509
+ "com.myapp.app",
510
+ "com.myorg.myapp",
511
+ "com.example.app",
512
+ "com.example.myapp",
513
+ ] as const;
514
+ const placeholderAppIdSegment = /^(example|examples|myorg|myapp|mycompany|myorganization|changeme|todo|sample|test)$/;
515
+
516
+ export const isPlaceholderAppId = (appId: string | null | undefined): boolean => {
517
+ const normalized = appId?.trim().toLowerCase() ?? "";
518
+ if (!normalized) return true;
519
+ if ((PLACEHOLDER_APP_IDS as readonly string[]).includes(normalized)) return true;
520
+ return normalized.split(".").some((segment) => placeholderAppIdSegment.test(segment));
521
+ };
522
+
382
523
  const androidReleaseSigningKeys = [
383
524
  "MYAPP_RELEASE_STORE_FILE",
384
525
  "MYAPP_RELEASE_STORE_PASSWORD",
@@ -651,22 +792,46 @@ export class CapacitorApp {
651
792
  }
652
793
 
653
794
  async #selectIosRunTarget(deviceId?: string) {
654
- const targets = await this.#loadIosRunTargets();
795
+ const targets = sortIosRunTargets(await this.#loadIosRunTargets());
655
796
  if (deviceId) {
656
- const found = targets.find((target) => target.id === deviceId);
657
- if (!found) throw new Error(`iOS run target '${deviceId}' was not found.`);
797
+ const needle = deviceId.toLowerCase();
798
+ const found =
799
+ targets.find((target) => target.id === deviceId) ??
800
+ targets.find((target) => target.name.toLowerCase() === needle) ??
801
+ targets.find(
802
+ (target) =>
803
+ target.name.toLowerCase().includes(needle) ||
804
+ target.id.toLowerCase().includes(needle) ||
805
+ (target.runtime?.toLowerCase().includes(needle) ?? false),
806
+ );
807
+ if (!found) {
808
+ const available = targets.map((t) => `${t.name}${t.runtime ? ` (${t.runtime})` : ""}`).join(", ") || "none";
809
+ throw new Error(`iOS run target '${deviceId}' was not found. Available: ${available}`);
810
+ }
811
+ this.#warnIfLegacySimulatorRuntime(found);
658
812
  return found;
659
813
  }
660
814
  if (targets.length === 0) {
661
815
  throw new Error("No iOS run targets found. Open Simulator or connect an iPhone, then retry.");
662
816
  }
663
- return await select<IosRunTarget>({
817
+ const selected = await select<IosRunTarget>({
664
818
  message: "Select iOS run target",
665
819
  choices: targets.map((target) => ({
666
- name: `[${target.kind}] ${target.name}${target.state ? ` (${target.state})` : ""}`,
820
+ name: `[${target.kind}] ${target.name}${target.runtime ? ` — ${target.runtime}` : ""}${target.state ? ` (${target.state})` : ""}`,
667
821
  value: target,
668
822
  })),
669
823
  });
824
+ this.#warnIfLegacySimulatorRuntime(selected);
825
+ return selected;
826
+ }
827
+
828
+ #warnIfLegacySimulatorRuntime(target: IosRunTarget) {
829
+ if (target.kind !== "simulator") return;
830
+ const major = parseIosRuntimeMajor(target.runtime);
831
+ if (major === undefined || major >= SWIFTUICORE_MIN_IOS_MAJOR) return;
832
+ this.app.logger.warn(
833
+ `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.`,
834
+ );
670
835
  }
671
836
 
672
837
  async #loadIosRunTargets() {
@@ -937,7 +1102,13 @@ export class CapacitorApp {
937
1102
  }
938
1103
  async #writeCapacitorConfig({ operation }: Pick<RunConfig, "operation">, commandEnv: MobileCommandEnv) {
939
1104
  await mkdir(this.targetRoot, { recursive: true });
940
- const localIp = operation === "local" ? getLocalIP() : undefined;
1105
+ let localIp: string | undefined;
1106
+ if (operation === "local") {
1107
+ const override = commandEnv.AKAN_PUBLIC_CLIENT_HOST ?? process.env.AKAN_PUBLIC_CLIENT_HOST;
1108
+ const resolution = selectLocalDevHost(os.networkInterfaces(), { override });
1109
+ localIp = resolution.host;
1110
+ this.#logDevHostResolution(resolution, commandEnv);
1111
+ }
941
1112
  const config = materializeCapacitorConfig(this.target, {
942
1113
  operation,
943
1114
  localIp,
@@ -947,6 +1118,21 @@ export class CapacitorApp {
947
1118
  await Bun.write(path.join(this.targetRoot, "capacitor.config.json"), content);
948
1119
  return content;
949
1120
  }
1121
+ // Surface the live-reload URL a physical device must reach, and warn when auto-detection landed on
1122
+ // a likely-unreachable host so a blank WebView is not mistaken for an app bug.
1123
+ #logDevHostResolution(resolution: LocalDevHostResolution, commandEnv: MobileCommandEnv) {
1124
+ this.app.log(`Mobile live-reload server: ${this.#localCsrUrl(resolution.host, commandEnv)}`);
1125
+ if (resolution.source === "override") return;
1126
+ const suspicious = resolution.host === "127.0.0.1" || resolution.host.startsWith("169.254.");
1127
+ const alternatives = resolution.candidates.filter((candidate) => candidate.address !== resolution.host);
1128
+ if (!suspicious && alternatives.length === 0) return;
1129
+ const alternativeText = alternatives.length
1130
+ ? ` Other interfaces: ${alternatives.map((candidate) => `${candidate.address} (${candidate.name})`).join(", ")}.`
1131
+ : "";
1132
+ this.app.logger.warn(
1133
+ `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>.`,
1134
+ );
1135
+ }
950
1136
  async #prepareTargetAssets() {
951
1137
  if (!this.target.assets) return;
952
1138
  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.8",
3
+ "version": "2.4.0",
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.8",
35
+ "akanjs": "2.4.0",
36
36
  "chalk": "^5.6.2",
37
37
  "commander": "^14.0.3",
38
38
  "daisyui": "5.5.23",