create-vidra-app 0.2.0 → 0.2.2

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/dist/cli.js CHANGED
@@ -368,6 +368,7 @@ import { execFileSync as execFileSync3 } from "child_process";
368
368
  import prompts from "prompts";
369
369
  var DOTNET = process.platform === "win32" ? "dotnet.exe" : "dotnet";
370
370
  var MAUI_DOCS = "https://learn.microsoft.com/dotnet/maui/get-started/installation";
371
+ var INSTALL_NET_10_FIX = "Install the .NET 10 SDK \u2014 https://dotnet.microsoft.com/download";
371
372
  var bufToStr = (v) => v == null ? "" : Buffer.isBuffer(v) ? v.toString() : v;
372
373
  var run = (cmd, args) => {
373
374
  try {
@@ -386,107 +387,179 @@ var run = (cmd, args) => {
386
387
  };
387
388
  }
388
389
  };
389
- var hasNet10Sdk = (listSdksOutput) => listSdksOutput.split(/\r?\n/).some((line) => /^10\./.test(line.trim()));
390
- var newestNet10Sdk = (listSdksOutput) => listSdksOutput.split(/\r?\n/).map((line) => line.trim().split(/\s+/)[0]).filter((v) => /^10\./.test(v)).sort(
390
+ var splitLines = (text) => text.split(/\r?\n/);
391
+ var matchesAny = (text, patterns) => patterns.some((pattern) => pattern.test(text));
392
+ var NET_10_VERSION = /^10\./;
393
+ var WORKLOAD_SET_VERSION_LINE = /Workload version:\s*([\w.-]+)/i;
394
+ var MAUI_WORKLOAD = /\bmaui\b/i;
395
+ var COMMAND_LINE_TOOLS_PATH = /CommandLineTools/i;
396
+ var hasNet10Sdk = (listSdksOutput) => splitLines(listSdksOutput).some((line) => NET_10_VERSION.test(line.trim()));
397
+ var newestNet10Sdk = (listSdksOutput) => splitLines(listSdksOutput).map((line) => line.trim().split(/\s+/)[0]).filter((version) => NET_10_VERSION.test(version)).sort(
391
398
  (a, b) => a.localeCompare(b, void 0, { numeric: true, sensitivity: "base" })
392
399
  ).pop();
393
- var outputMentionsMaui = (workloadListOutput) => /\bmaui\b/i.test(workloadListOutput);
394
- var looksLikeMissingWorkload = (output) => [
400
+ var outputMentionsMaui = (workloadListOutput) => MAUI_WORKLOAD.test(workloadListOutput);
401
+ var workloadSetVersion = (workloadListOutput) => workloadListOutput.match(WORKLOAD_SET_VERSION_LINE)?.[1];
402
+ var MIN_MACCATALYST_WATCH_WORKLOAD_SET = [10, 0, 203];
403
+ var MIN_MACCATALYST_WATCH_SDK = [10, 0, 300];
404
+ var meetsMinimumVersion = (version, minimum) => {
405
+ const segments = version.split(".").map((s) => Number.parseInt(s, 10));
406
+ if (segments.slice(0, minimum.length).some(Number.isNaN)) return false;
407
+ for (let i = 0; i < minimum.length; i++) {
408
+ const segment = segments[i] ?? 0;
409
+ if (segment !== minimum[i]) return segment > minimum[i];
410
+ }
411
+ return true;
412
+ };
413
+ var workloadSetSupportsCSharpHotReload = (version) => meetsMinimumVersion(version, MIN_MACCATALYST_WATCH_WORKLOAD_SET);
414
+ var sdkSupportsCSharpHotReload = (version) => meetsMinimumVersion(version, MIN_MACCATALYST_WATCH_SDK);
415
+ var macCatalystHotReloadBlocker = () => {
416
+ const sdkRes = run(DOTNET, ["--version"]);
417
+ if (!sdkRes.found) return null;
418
+ const sdkVersion = sdkRes.stdout.trim();
419
+ if (sdkVersion && !sdkSupportsCSharpHotReload(sdkVersion)) {
420
+ return {
421
+ reason: `C# hot reload needs the .NET SDK 10.0.300+ \u2014 found ${sdkVersion}`,
422
+ fix: INSTALL_NET_10_FIX
423
+ };
424
+ }
425
+ const res = run(DOTNET, ["workload", "list"]);
426
+ if (!res.found) return null;
427
+ const version = workloadSetVersion(res.stdout);
428
+ if (!version || workloadSetSupportsCSharpHotReload(version)) return null;
429
+ return {
430
+ reason: `C# hot reload needs workload set 10.0.203+ \u2014 found ${version}`,
431
+ fix: "dotnet workload update"
432
+ };
433
+ };
434
+ var MISSING_WORKLOAD_SIGNATURES = [
395
435
  /NETSDK1147/i,
396
436
  /workloads?\s+must\s+be\s+installed/i,
397
437
  /maui-maccatalyst/i,
398
438
  /maui-windows/i,
399
439
  /to\s+install\s+the\s+.*workload/i
400
- ].some((re) => re.test(output));
401
- var looksLikeMissingXcode = (output) => [
440
+ ];
441
+ var MISSING_XCODE_SIGNATURES = [
402
442
  /valid\s+Xcode\s+installation\s+was\s+not\s+found/i,
403
443
  /could\s+not\s+find\s+a\s+valid\s+Xcode\s+app\s+bundle/i,
404
444
  /macios-missing-xcode/i
405
- ].some((re) => re.test(output));
445
+ ];
446
+ var OUTDATED_XCODE_SIGNATURES = [
447
+ /error\s+MT0180/i,
448
+ /requires\s+the\s+MacCatalyst\s+\S+\s+SDK\s+\(shipped\s+with\s+Xcode/i
449
+ ];
450
+ var looksLikeMissingWorkload = (output) => matchesAny(output, MISSING_WORKLOAD_SIGNATURES);
451
+ var looksLikeMissingXcode = (output) => matchesAny(output, MISSING_XCODE_SIGNATURES);
452
+ var looksLikeXcodeTooOld = (output) => matchesAny(output, OUTDATED_XCODE_SIGNATURES);
406
453
  var checkDotnetSdk = () => {
454
+ const name = ".NET SDK";
407
455
  const res = run(DOTNET, ["--list-sdks"]);
408
456
  if (!res.found) {
409
457
  return {
410
- name: ".NET SDK",
458
+ name,
411
459
  status: "missing",
412
460
  detail: "`dotnet` was not found on your PATH",
413
- fix: "Install the .NET 10 SDK \u2014 https://dotnet.microsoft.com/download"
461
+ fix: INSTALL_NET_10_FIX
414
462
  };
415
463
  }
416
464
  if (!res.ok && !res.stdout) {
417
- return {
418
- name: ".NET SDK",
419
- status: "unknown",
420
- detail: "could not run `dotnet --list-sdks`"
421
- };
465
+ return { name, status: "unknown", detail: "could not run `dotnet --list-sdks`" };
422
466
  }
423
467
  if (hasNet10Sdk(res.stdout)) {
424
468
  const newest = newestNet10Sdk(res.stdout);
425
- return {
426
- name: ".NET SDK",
427
- status: "ok",
428
- detail: newest ? `found ${newest}` : "found 10.x"
429
- };
469
+ return { name, status: "ok", detail: newest ? `found ${newest}` : "found 10.x" };
430
470
  }
431
471
  return {
432
- name: ".NET SDK",
472
+ name,
433
473
  status: "missing",
434
474
  detail: "no 10.x SDK installed",
435
- fix: "Install the .NET 10 SDK \u2014 https://dotnet.microsoft.com/download"
475
+ fix: INSTALL_NET_10_FIX
476
+ };
477
+ };
478
+ var checkMauiWorkload = (workloadList) => {
479
+ const name = ".NET MAUI workload";
480
+ if (!workloadList) {
481
+ return { name, status: "unknown", detail: "requires the .NET SDK first" };
482
+ }
483
+ if (!workloadList.found) {
484
+ return { name, status: "unknown", detail: "could not query workloads" };
485
+ }
486
+ if (outputMentionsMaui(workloadList.stdout)) {
487
+ return { name, status: "ok", detail: "installed" };
488
+ }
489
+ return {
490
+ name,
491
+ status: "missing",
492
+ detail: "not installed",
493
+ fix: "dotnet workload install maui"
436
494
  };
437
495
  };
438
- var checkMauiWorkload = (dotnetOk) => {
439
- if (!dotnetOk) {
496
+ var checkCSharpHotReload = (workloadList) => {
497
+ const name = "C# hot reload";
498
+ if (!workloadList) {
499
+ return { name, status: "unknown", detail: "requires the .NET SDK first" };
500
+ }
501
+ if (process.platform !== "darwin") {
502
+ return { name, status: "ok", detail: "dotnet watch supported" };
503
+ }
504
+ const sdkVersion = run(DOTNET, ["--version"]).stdout.trim();
505
+ if (sdkVersion && !sdkSupportsCSharpHotReload(sdkVersion)) {
440
506
  return {
441
- name: ".NET MAUI workload",
507
+ name,
442
508
  status: "unknown",
443
- detail: "requires the .NET SDK first"
509
+ detail: `SDK ${sdkVersion} \u2014 its dotnet-watch crashes Mac Catalyst apps on launch (needs 10.0.300+) \u2014 vidra dev falls back to a classic launch`,
510
+ fix: INSTALL_NET_10_FIX
444
511
  };
445
512
  }
446
- const res = run(DOTNET, ["workload", "list"]);
447
- if (!res.found) {
513
+ const version = workloadSetVersion(workloadList.stdout);
514
+ if (!version) {
448
515
  return {
449
- name: ".NET MAUI workload",
516
+ name,
450
517
  status: "unknown",
451
- detail: "could not query workloads"
518
+ detail: "could not read the workload set version"
452
519
  };
453
520
  }
454
- if (outputMentionsMaui(res.stdout)) {
455
- return { name: ".NET MAUI workload", status: "ok", detail: "installed" };
521
+ if (workloadSetSupportsCSharpHotReload(version)) {
522
+ return { name, status: "ok", detail: `workload set ${version}` };
456
523
  }
457
524
  return {
458
- name: ".NET MAUI workload",
459
- status: "missing",
460
- detail: "not installed",
461
- fix: "dotnet workload install maui"
525
+ name,
526
+ status: "unknown",
527
+ detail: `workload set ${version} predates Mac Catalyst dotnet-watch support (needs 10.0.203+) \u2014 vidra dev falls back to a classic launch`,
528
+ fix: "dotnet workload update"
462
529
  };
463
530
  };
464
531
  var checkXcode = () => {
532
+ const name = "Xcode";
465
533
  const res = run("xcode-select", ["-p"]);
466
534
  if (!res.found || !res.ok) {
467
535
  return {
468
- name: "Xcode",
536
+ name,
469
537
  status: "missing",
470
538
  detail: "not found",
471
539
  fix: "Install Xcode from the App Store"
472
540
  };
473
541
  }
474
542
  const devDir = res.stdout.trim();
475
- if (/CommandLineTools/i.test(devDir)) {
543
+ if (COMMAND_LINE_TOOLS_PATH.test(devDir)) {
476
544
  return {
477
- name: "Xcode",
545
+ name,
478
546
  status: "missing",
479
547
  detail: "only Command Line Tools detected (Mac Catalyst needs full Xcode)",
480
548
  fix: "Install Xcode, then: sudo xcode-select -s /Applications/Xcode.app"
481
549
  };
482
550
  }
483
- return { name: "Xcode", status: "ok", detail: devDir };
551
+ return { name, status: "ok", detail: devDir };
484
552
  };
485
553
  var isMauiWorkloadInstalled = () => outputMentionsMaui(run(DOTNET, ["workload", "list"]).stdout);
486
554
  var isInteractive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
487
555
  var collectRequirements = (opts = {}) => {
488
556
  const dotnet = checkDotnetSdk();
489
- const reqs = [dotnet, checkMauiWorkload(dotnet.status === "ok")];
557
+ const workloadList = dotnet.status === "ok" ? run(DOTNET, ["workload", "list"]) : null;
558
+ const reqs = [
559
+ dotnet,
560
+ checkMauiWorkload(workloadList),
561
+ checkCSharpHotReload(workloadList)
562
+ ];
490
563
  if (opts.includeXcode ?? process.platform === "darwin") {
491
564
  reqs.push(checkXcode());
492
565
  }
@@ -508,7 +581,7 @@ var printRequirements = (reqs) => {
508
581
  detail: r.detail ? dim(r.detail) : void 0
509
582
  })
510
583
  );
511
- if (r.status === "missing" && r.fix) {
584
+ if (r.status !== "ok" && r.fix) {
512
585
  console.log(fixLine(r.fix));
513
586
  }
514
587
  }
@@ -645,6 +718,23 @@ var printWorkloadHint = () => {
645
718
  console.error(fixLine("vidra doctor", "check:"));
646
719
  console.error();
647
720
  };
721
+ var printXcodeTooOldHint = () => {
722
+ console.error();
723
+ console.error(
724
+ row({
725
+ glyph: "manual",
726
+ label: "your Xcode is older than the SDK this MAUI workload set expects."
727
+ })
728
+ );
729
+ console.error(
730
+ ` ${dim("\u2022")} ${dim("update Xcode (App Store), then")} ${lime("sudo xcodebuild -runFirstLaunch")}`
731
+ );
732
+ console.error(
733
+ ` ${dim("\u2022")} ${dim("or pin the workloads to your Xcode's era:")} ${lime("dotnet workload update --version <set>")}`
734
+ );
735
+ console.error(fixLine("vidra doctor", "check:"));
736
+ console.error();
737
+ };
648
738
  var printXcodeHint = () => {
649
739
  console.error();
650
740
  console.error(
@@ -948,6 +1038,7 @@ var POLL_INTERVAL_MS = 500;
948
1038
  var POLL_TIMEOUT_MS = 3e4;
949
1039
  var NPM_COMMAND = process.platform === "win32" ? "npm.cmd" : "npm";
950
1040
  var DOTNET_COMMAND = process.platform === "win32" ? "dotnet.exe" : "dotnet";
1041
+ var WATCH_OUTPUT_TAIL_CHARS = 8192;
951
1042
  var TARGETS2 = {
952
1043
  macos: {
953
1044
  name: "macos",
@@ -958,12 +1049,13 @@ var TARGETS2 = {
958
1049
  framework: "net10.0-windows10.0.19041.0"
959
1050
  }
960
1051
  };
961
- var devCommand = (argv) => startSession(argv, { vite: true });
962
- var runCommand = (argv) => startSession(argv, { vite: false });
1052
+ var devCommand = (argv) => startSession(argv, { vite: true, hotReloadDefault: true });
1053
+ var runCommand = (argv) => startSession(argv, { vite: false, hotReloadDefault: false });
963
1054
  var startSession = async (argv, opts) => {
964
1055
  const args = parseArgs(["_", "_", ...argv]);
965
1056
  const targetName = args["target"] || detectPlatform();
966
1057
  const verbose = !!args["verbose"];
1058
+ let hotReload = opts.hotReloadDefault && !args["no-hot-reload"];
967
1059
  const viteUrl = process.env.VIDRA_DEV_URL || "http://localhost:5173";
968
1060
  const target = TARGETS2[targetName];
969
1061
  if (!target) {
@@ -981,16 +1073,68 @@ var startSession = async (argv, opts) => {
981
1073
  if (!await ensureMauiWorkload({ csprojPath: project.csprojPath })) {
982
1074
  process.exit(1);
983
1075
  }
984
- const session = new DevSession(project, target, viteUrl, verbose, opts);
1076
+ if (hotReload && target.name === "macos") {
1077
+ const blocker = macCatalystHotReloadBlocker();
1078
+ if (blocker) {
1079
+ hotReload = false;
1080
+ console.log();
1081
+ console.log(
1082
+ row({
1083
+ glyph: "manual",
1084
+ detail: `${dim(blocker.reason + "; using a classic launch")}`
1085
+ })
1086
+ );
1087
+ console.log(fixLine(blocker.fix));
1088
+ }
1089
+ }
1090
+ const session = new DevSession(project, target, viteUrl, verbose, {
1091
+ vite: opts.vite,
1092
+ hotReload
1093
+ });
985
1094
  await session.run();
986
1095
  };
1096
+ var buildDotnetWatchArgs = (opts) => [
1097
+ "watch",
1098
+ "--project",
1099
+ opts.csprojPath,
1100
+ // Never prompt on rude edits (our stdio is piped, so a prompt would hang);
1101
+ // paired with DOTNET_WATCH_RESTART_ON_RUDE_EDIT for older SDKs.
1102
+ "--non-interactive",
1103
+ ...opts.verbose ? ["--verbose"] : [],
1104
+ "run",
1105
+ "-f",
1106
+ opts.framework,
1107
+ "-c",
1108
+ opts.buildConfig,
1109
+ // Launch the Mac Catalyst app by exec'ing the binary rather than `open -a`,
1110
+ // so the environment (VIDRA_DEV_URL, DOTNET_STARTUP_HOOKS) propagates and
1111
+ // stdout streams back. Ignored by targets that don't define the property.
1112
+ ...opts.targetName === "macos" ? ["--property:RunWithOpen=false"] : []
1113
+ ];
1114
+ var dotnetWatchEnv = (devUrl) => ({
1115
+ VIDRA_DEV_URL: devUrl,
1116
+ DOTNET_WATCH_RESTART_ON_RUDE_EDIT: "1",
1117
+ DOTNET_WATCH_SUPPRESS_EMOJIS: "1",
1118
+ DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER: "1"
1119
+ });
1120
+ var HOST_READY_SENTINEL = "[vidra] host ready";
1121
+ var classifyWatchLine = (line) => {
1122
+ if (/waiting for a file to change/i.test(line)) return "appWaiting";
1123
+ if (/fix the error to continue/i.test(line)) return "buildBlocked";
1124
+ if (line.includes(HOST_READY_SENTINEL)) return "appStarted";
1125
+ if (/\bwatch\b/i.test(line) && /\b(?:started|launched)\b/i.test(line)) {
1126
+ return "appStarted";
1127
+ }
1128
+ return null;
1129
+ };
987
1130
  var DevSession = class {
988
- constructor(project, target, viteUrl, verbose, options = {}) {
1131
+ constructor(project, target, viteUrl, verbose, options) {
989
1132
  this.project = project;
990
1133
  this.target = target;
991
1134
  this.viteUrl = viteUrl;
992
1135
  this.verbose = verbose;
993
- this.vite = options.vite ?? true;
1136
+ this.vite = options.vite;
1137
+ this.hotReload = options.hotReload;
994
1138
  }
995
1139
  project;
996
1140
  target;
@@ -999,7 +1143,19 @@ var DevSession = class {
999
1143
  children = [];
1000
1144
  buildConfig = process.env.VIDRA_BUILD_CONFIG || "Debug";
1001
1145
  vite;
1146
+ hotReload;
1002
1147
  shuttingDown = false;
1148
+ // Watch-mode state: `watchChild` is the `dotnet watch` process (a process
1149
+ // group leader on unix, so shutdown can signal it and the app it launched
1150
+ // together); `watchReady` flips once the app has started at least once.
1151
+ watchChild;
1152
+ watchReady = false;
1153
+ watchOutputTail = "";
1154
+ endSession = () => {
1155
+ };
1156
+ sessionDone = new Promise((resolve) => {
1157
+ this.endSession = resolve;
1158
+ });
1003
1159
  async run() {
1004
1160
  this.installSignalHandlers();
1005
1161
  console.log();
@@ -1007,9 +1163,8 @@ var DevSession = class {
1007
1163
  console.log(kv("project", this.project.projectName));
1008
1164
  console.log(kv("target", this.target.framework));
1009
1165
  console.log();
1010
- let vite;
1011
1166
  if (this.vite) {
1012
- vite = this.startVite();
1167
+ this.startVite();
1013
1168
  try {
1014
1169
  await waitForServer(this.viteUrl, POLL_TIMEOUT_MS);
1015
1170
  } catch (error) {
@@ -1028,19 +1183,22 @@ var DevSession = class {
1028
1183
  )
1029
1184
  );
1030
1185
  }
1031
- const host = this.target.name === "macos" ? this.launchMacosHost() : this.launchWindowsHost();
1186
+ this.launchHost();
1187
+ const hostDirLabel = `src/${path6.basename(this.project.hostDir)}`;
1032
1188
  if (this.vite) {
1033
1189
  console.log(
1034
1190
  taggedRow(
1035
1191
  "active",
1036
1192
  null,
1037
- `${lime("hot reload active")} ${dim("\u2014 edit ui/src and save")}`
1193
+ this.hotReload ? `${lime("hot reload active")} ${dim(`\u2014 edit ui/src or ${hostDirLabel} and save`)}` : `${lime("hot reload active")} ${dim("\u2014 edit ui/src and save")}`
1038
1194
  )
1039
1195
  );
1040
1196
  console.log();
1041
1197
  console.log(
1042
1198
  footer(
1043
- `${dim("watching")} ${value("ui/")} ${dim(
1199
+ this.hotReload ? `${dim("watching")} ${value("ui/")} ${dim("\xB7")} ${value(`${hostDirLabel}/`)} ${dim(
1200
+ "\xB7 hot reload on save \xB7 ctrl-c to stop"
1201
+ )}` : `${dim("watching")} ${value("ui/")} ${dim(
1044
1202
  "\xB7 hot reload on save \xB7 ctrl-c to stop"
1045
1203
  )}`
1046
1204
  )
@@ -1052,7 +1210,7 @@ var DevSession = class {
1052
1210
  );
1053
1211
  }
1054
1212
  console.log();
1055
- await waitForExit(...vite ? [vite, host] : [host]);
1213
+ await this.sessionDone;
1056
1214
  }
1057
1215
  installSignalHandlers() {
1058
1216
  process.on("SIGINT", () => {
@@ -1072,6 +1230,181 @@ var DevSession = class {
1072
1230
  });
1073
1231
  return this.registerChild(vite, "ui", "Vite dev server");
1074
1232
  }
1233
+ launchHost() {
1234
+ if (this.hotReload) {
1235
+ return this.launchHostWithWatch();
1236
+ }
1237
+ return this.target.name === "macos" ? this.launchMacosHost() : this.launchWindowsHost();
1238
+ }
1239
+ // --- dotnet watch launch (C# hot reload) -----------------------------------
1240
+ launchHostWithWatch() {
1241
+ console.log(
1242
+ taggedRow(
1243
+ "active",
1244
+ "host",
1245
+ `${dim("dotnet watch \u2014 building")} ${value(this.target.framework)} ${dim("\u2026")}`
1246
+ )
1247
+ );
1248
+ const watch = spawn2(
1249
+ DOTNET_COMMAND,
1250
+ buildDotnetWatchArgs({
1251
+ csprojPath: this.project.csprojPath,
1252
+ framework: this.target.framework,
1253
+ buildConfig: this.buildConfig,
1254
+ verbose: this.verbose,
1255
+ targetName: this.target.name
1256
+ }),
1257
+ {
1258
+ cwd: this.project.root,
1259
+ stdio: ["ignore", "pipe", "pipe"],
1260
+ env: { ...process.env, ...dotnetWatchEnv(this.viteUrl) },
1261
+ // Unix: make dotnet watch its own process-group leader so shutdown can
1262
+ // SIGTERM the group — taking down both watch and the app it launched.
1263
+ // Windows cleanup goes through `taskkill /T` instead.
1264
+ detached: process.platform !== "win32"
1265
+ }
1266
+ );
1267
+ this.watchChild = watch;
1268
+ this.children.push(watch);
1269
+ const notSentinel = (line) => !line.includes(HOST_READY_SENTINEL);
1270
+ prefixStream(watch.stdout, "host", notSentinel);
1271
+ prefixStream(watch.stderr, "host", notSentinel);
1272
+ scanStream(watch.stdout, (line) => this.onWatchLine(line));
1273
+ scanStream(watch.stderr, (line) => this.onWatchLine(line));
1274
+ watch.on("exit", (code, signal) => {
1275
+ if (this.shuttingDown) return;
1276
+ if (this.watchReady) {
1277
+ const failed = code !== null && code !== 0 || signal !== null;
1278
+ if (failed) {
1279
+ console.error(
1280
+ "\n" + row({
1281
+ glyph: "error",
1282
+ detail: dim(
1283
+ `dotnet watch exited with ${signal ? `signal ${signal}` : `code ${code}`}`
1284
+ )
1285
+ })
1286
+ );
1287
+ }
1288
+ this.shutdown(code ?? (signal ? 1 : 0));
1289
+ return;
1290
+ }
1291
+ this.fallBackToClassicLaunch(code, signal);
1292
+ });
1293
+ watch.on("error", (error) => {
1294
+ if (this.shuttingDown) return;
1295
+ console.error(
1296
+ "\n" + row({
1297
+ glyph: "error",
1298
+ detail: dim(`failed to start dotnet watch: ${error.message}`)
1299
+ })
1300
+ );
1301
+ this.shutdown(1);
1302
+ });
1303
+ return watch;
1304
+ }
1305
+ onWatchLine(line) {
1306
+ this.watchOutputTail = (this.watchOutputTail + line + "\n").slice(
1307
+ -WATCH_OUTPUT_TAIL_CHARS
1308
+ );
1309
+ const event = classifyWatchLine(line);
1310
+ if (event === "appStarted" && !this.watchReady) {
1311
+ this.watchReady = true;
1312
+ console.log(
1313
+ taggedRow(
1314
+ "done",
1315
+ "host",
1316
+ `${dim("launched")} ${value(this.project.projectName)} ${dim("\u2014 C# hot reload active")}`
1317
+ )
1318
+ );
1319
+ return;
1320
+ }
1321
+ if (event !== "appWaiting" && event !== "buildBlocked" || this.shuttingDown) {
1322
+ return;
1323
+ }
1324
+ if (this.watchReady) {
1325
+ console.log(
1326
+ taggedRow(
1327
+ "manual",
1328
+ "host",
1329
+ dim("app not running \u2014 save a C# file to relaunch, or ctrl-c to stop")
1330
+ )
1331
+ );
1332
+ return;
1333
+ }
1334
+ if (!/Build FAILED/i.test(this.watchOutputTail)) {
1335
+ console.log(
1336
+ taggedRow(
1337
+ "manual",
1338
+ "host",
1339
+ dim(
1340
+ "app exited before it was ready \u2014 save a C# file to relaunch, or ctrl-c to stop"
1341
+ )
1342
+ )
1343
+ );
1344
+ return;
1345
+ }
1346
+ if (looksLikeMissingWorkload(this.watchOutputTail)) printWorkloadHint();
1347
+ else if (looksLikeXcodeTooOld(this.watchOutputTail)) printXcodeTooOldHint();
1348
+ else if (looksLikeMissingXcode(this.watchOutputTail)) printXcodeHint();
1349
+ console.log(
1350
+ taggedRow(
1351
+ "manual",
1352
+ "host",
1353
+ dim("build failed \u2014 fix the error and save to retry, or ctrl-c to stop")
1354
+ )
1355
+ );
1356
+ }
1357
+ /**
1358
+ * `dotnet watch` exited before the app ever started — typically an SDK or
1359
+ * MAUI workload set that predates `dotnet run`/`dotnet watch` support for
1360
+ * this target. Explain why, then launch the host the classic way (one
1361
+ * build + direct spawn) so the dev session still works, minus C# hot reload.
1362
+ */
1363
+ fallBackToClassicLaunch(code, signal) {
1364
+ console.log();
1365
+ console.log(
1366
+ taggedRow(
1367
+ "manual",
1368
+ "host",
1369
+ `${dim("C# hot reload unavailable \u2014 dotnet watch exited with")} ${value(
1370
+ signal ? `signal ${signal}` : `code ${code ?? "unknown"}`
1371
+ )}`
1372
+ )
1373
+ );
1374
+ if (looksLikeMissingWorkload(this.watchOutputTail)) {
1375
+ printWorkloadHint();
1376
+ } else if (looksLikeXcodeTooOld(this.watchOutputTail)) {
1377
+ printXcodeTooOldHint();
1378
+ } else if (looksLikeMissingXcode(this.watchOutputTail)) {
1379
+ printXcodeHint();
1380
+ } else {
1381
+ console.log(
1382
+ footer(
1383
+ dim(
1384
+ "C# hot reload needs the .NET 10.0.203+ workload set (Mac Catalyst dotnet-watch support) \u2014 try:"
1385
+ )
1386
+ )
1387
+ );
1388
+ console.log(fixLine("dotnet workload update"));
1389
+ if (!this.verbose) {
1390
+ console.log(footer(dim("re-run with --verbose for the full watch log.")));
1391
+ }
1392
+ }
1393
+ console.log(
1394
+ taggedRow(
1395
+ "active",
1396
+ "host",
1397
+ dim("falling back to a classic launch (frontend hot reload still active)\u2026")
1398
+ )
1399
+ );
1400
+ console.log();
1401
+ if (this.target.name === "macos") {
1402
+ this.launchMacosHost();
1403
+ } else {
1404
+ this.launchWindowsHost();
1405
+ }
1406
+ }
1407
+ // --- classic launch (one build + direct spawn) ------------------------------
1075
1408
  // Builds the MAUI host as a discrete step (a plain `dotnet build`, never
1076
1409
  // MSBuild's `-t:Run`) so the per-OS launch paths can spawn the produced
1077
1410
  // binary directly. `-t:Run` shells out in ways that break for both locally
@@ -1194,8 +1527,9 @@ var DevSession = class {
1194
1527
  }
1195
1528
  registerChild(child, tag2, label) {
1196
1529
  this.children.push(child);
1197
- prefixStream(child.stdout, tag2);
1198
- prefixStream(child.stderr, tag2);
1530
+ const include = tag2 === "host" ? (line) => !line.includes(HOST_READY_SENTINEL) : void 0;
1531
+ prefixStream(child.stdout, tag2, include);
1532
+ prefixStream(child.stderr, tag2, include);
1199
1533
  child.on("exit", (code, signal) => {
1200
1534
  if (this.shuttingDown) return;
1201
1535
  if (tag2 === "ui") {
@@ -1238,8 +1572,9 @@ var DevSession = class {
1238
1572
  if (this.shuttingDown) return;
1239
1573
  this.shuttingDown = true;
1240
1574
  for (const child of this.children) {
1241
- killChild(child);
1575
+ killChild(child, { processGroup: child === this.watchChild });
1242
1576
  }
1577
+ this.endSession();
1243
1578
  process.exit(exitCode);
1244
1579
  }
1245
1580
  };
@@ -1257,19 +1592,32 @@ var ensureTargetMatchesHostOs = (targetName) => {
1257
1592
  process.exit(1);
1258
1593
  }
1259
1594
  };
1260
- var prefixStream = (stream, tag2) => {
1595
+ var prefixStream = (stream, tag2, include = () => true) => {
1261
1596
  if (!stream) return;
1262
1597
  const prefix = streamPrefix(tag2);
1263
1598
  stream.on("data", (chunk) => {
1264
1599
  const lines = chunk.toString().split("\n");
1265
1600
  for (const line of lines) {
1266
- if (line.length > 0) {
1601
+ if (line.length > 0 && include(line)) {
1267
1602
  process.stdout.write(`${prefix} ${line}
1268
1603
  `);
1269
1604
  }
1270
1605
  }
1271
1606
  });
1272
1607
  };
1608
+ var scanStream = (stream, onLine) => {
1609
+ if (!stream) return;
1610
+ let pending = "";
1611
+ stream.on("data", (chunk) => {
1612
+ pending += chunk.toString();
1613
+ const lines = pending.split("\n");
1614
+ pending = lines.pop() ?? "";
1615
+ for (const line of lines) onLine(line);
1616
+ });
1617
+ stream.on("end", () => {
1618
+ if (pending) onLine(pending);
1619
+ });
1620
+ };
1273
1621
  var waitForServer = (url, timeoutMs) => {
1274
1622
  const { hostname, port, pathname } = new URL(url);
1275
1623
  const start = Date.now();
@@ -1301,14 +1649,6 @@ var waitForServer = (url, timeoutMs) => {
1301
1649
  poll();
1302
1650
  });
1303
1651
  };
1304
- var waitForExit = (...children) => {
1305
- return new Promise((resolve) => {
1306
- const resolveOnce = () => resolve();
1307
- for (const child of children) {
1308
- child.once("exit", resolveOnce);
1309
- }
1310
- });
1311
- };
1312
1652
  var findMacAppBundle = (hostDir, framework, buildConfig) => {
1313
1653
  const outputDir = path6.join(hostDir, "bin", buildConfig, framework);
1314
1654
  return findAppBundleRecursive(outputDir);
@@ -1362,7 +1702,7 @@ var findFileRecursive = (dir, match) => {
1362
1702
  }
1363
1703
  return null;
1364
1704
  };
1365
- var killChild = (child) => {
1705
+ var killChild = (child, opts = {}) => {
1366
1706
  if (!child.pid || child.exitCode !== null) return;
1367
1707
  if (process.platform === "win32") {
1368
1708
  try {
@@ -1374,6 +1714,13 @@ var killChild = (child) => {
1374
1714
  }
1375
1715
  return;
1376
1716
  }
1717
+ if (opts.processGroup) {
1718
+ try {
1719
+ process.kill(-child.pid, "SIGTERM");
1720
+ return;
1721
+ } catch {
1722
+ }
1723
+ }
1377
1724
  child.kill("SIGTERM");
1378
1725
  };
1379
1726
  var printMacLaunchHint = () => {
@@ -1411,7 +1758,7 @@ var printHelp = () => {
1411
1758
  ${lime("vidra")} ${dim("<command> [options]")}
1412
1759
 
1413
1760
  ${dim("commands")}
1414
- ${cmd("dev", "start vite + the native host (hot reload)")}
1761
+ ${cmd("dev", "start vite + the native host (UI + C# hot reload)")}
1415
1762
  ${cmd("run", "launch the native host only")}
1416
1763
  ${cmd("build", "build & package for distribution")}
1417
1764
  ${cmd("doctor", "check your environment")}
@@ -1419,6 +1766,7 @@ ${cmd("help", "show this message")}
1419
1766
 
1420
1767
  ${dim("examples")}
1421
1768
  ${ex("dev --target windows", "run the windows host")}
1769
+ ${ex("dev --no-hot-reload", "skip dotnet watch, classic launch")}
1422
1770
  ${ex("build --plan", "preview the build, run nothing")}
1423
1771
  ${ex("build --target macos", "build & package a macOS DMG")}
1424
1772
  ${ex("doctor", "verify .NET SDK + MAUI workload")}
package/dist/index.js CHANGED
@@ -174,6 +174,7 @@ import { execFileSync } from "child_process";
174
174
  import prompts from "prompts";
175
175
  var DOTNET = process.platform === "win32" ? "dotnet.exe" : "dotnet";
176
176
  var MAUI_DOCS = "https://learn.microsoft.com/dotnet/maui/get-started/installation";
177
+ var INSTALL_NET_10_FIX = "Install the .NET 10 SDK \u2014 https://dotnet.microsoft.com/download";
177
178
  var bufToStr = (v) => v == null ? "" : Buffer.isBuffer(v) ? v.toString() : v;
178
179
  var run = (cmd, args) => {
179
180
  try {
@@ -192,41 +193,37 @@ var run = (cmd, args) => {
192
193
  };
193
194
  }
194
195
  };
195
- var hasNet10Sdk = (listSdksOutput) => listSdksOutput.split(/\r?\n/).some((line) => /^10\./.test(line.trim()));
196
- var newestNet10Sdk = (listSdksOutput) => listSdksOutput.split(/\r?\n/).map((line) => line.trim().split(/\s+/)[0]).filter((v) => /^10\./.test(v)).sort(
196
+ var splitLines = (text) => text.split(/\r?\n/);
197
+ var NET_10_VERSION = /^10\./;
198
+ var MAUI_WORKLOAD = /\bmaui\b/i;
199
+ var hasNet10Sdk = (listSdksOutput) => splitLines(listSdksOutput).some((line) => NET_10_VERSION.test(line.trim()));
200
+ var newestNet10Sdk = (listSdksOutput) => splitLines(listSdksOutput).map((line) => line.trim().split(/\s+/)[0]).filter((version) => NET_10_VERSION.test(version)).sort(
197
201
  (a, b) => a.localeCompare(b, void 0, { numeric: true, sensitivity: "base" })
198
202
  ).pop();
199
- var outputMentionsMaui = (workloadListOutput) => /\bmaui\b/i.test(workloadListOutput);
203
+ var outputMentionsMaui = (workloadListOutput) => MAUI_WORKLOAD.test(workloadListOutput);
200
204
  var checkDotnetSdk = () => {
205
+ const name = ".NET SDK";
201
206
  const res = run(DOTNET, ["--list-sdks"]);
202
207
  if (!res.found) {
203
208
  return {
204
- name: ".NET SDK",
209
+ name,
205
210
  status: "missing",
206
211
  detail: "`dotnet` was not found on your PATH",
207
- fix: "Install the .NET 10 SDK \u2014 https://dotnet.microsoft.com/download"
212
+ fix: INSTALL_NET_10_FIX
208
213
  };
209
214
  }
210
215
  if (!res.ok && !res.stdout) {
211
- return {
212
- name: ".NET SDK",
213
- status: "unknown",
214
- detail: "could not run `dotnet --list-sdks`"
215
- };
216
+ return { name, status: "unknown", detail: "could not run `dotnet --list-sdks`" };
216
217
  }
217
218
  if (hasNet10Sdk(res.stdout)) {
218
219
  const newest = newestNet10Sdk(res.stdout);
219
- return {
220
- name: ".NET SDK",
221
- status: "ok",
222
- detail: newest ? `found ${newest}` : "found 10.x"
223
- };
220
+ return { name, status: "ok", detail: newest ? `found ${newest}` : "found 10.x" };
224
221
  }
225
222
  return {
226
- name: ".NET SDK",
223
+ name,
227
224
  status: "missing",
228
225
  detail: "no 10.x SDK installed",
229
- fix: "Install the .NET 10 SDK \u2014 https://dotnet.microsoft.com/download"
226
+ fix: INSTALL_NET_10_FIX
230
227
  };
231
228
  };
232
229
  var isMauiWorkloadInstalled = () => outputMentionsMaui(run(DOTNET, ["workload", "list"]).stdout);
@@ -332,7 +329,7 @@ var VIDRA_REPO_ROOT = path2.resolve(CLI_ROOT, "..", "..", "..");
332
329
  var LOCAL_FEED_DIR = path2.join(VIDRA_REPO_ROOT, "dist", "packages");
333
330
  var LOCAL_CLI_DIR = CLI_ROOT;
334
331
  var LOCAL_SDK_DIR = path2.join(VIDRA_REPO_ROOT, "src", "sdk", "vidra-js");
335
- var VIDRA_VERSION = "0.2.0";
332
+ var VIDRA_VERSION = "0.2.2";
336
333
  var SDK_VERSION = "0.1.0";
337
334
  var main = async () => {
338
335
  console.log();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-vidra-app",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Scaffold a new Vidra application (React + .NET MAUI)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,22 @@ exact command to fix anything that's missing.
30
30
  npm run dev
31
31
  ```
32
32
 
33
- This starts the Vite dev server and launches the native host for the current OS.
33
+ This starts the Vite dev server and launches the native host for the current OS
34
+ under `dotnet watch`. Both sides of the app hot reload:
35
+
36
+ - **UI**: edit anything in `ui/src` — Vite HMR updates the WebView instantly.
37
+ - **C#**: edit the host (for example `OnTickAsync` in `MainPage.cs`) — supported
38
+ edits apply to the running app in seconds, and the UI flashes a
39
+ "C# reloaded" badge. Edits hot reload can't express (new fields, changed
40
+ signatures, …) trigger an automatic rebuild and relaunch.
41
+
42
+ C# hot reload needs a recent toolchain (on macOS, the .NET 10.0.203+ workload
43
+ set — run `npm run doctor` to check). When unavailable, `vidra dev` falls back
44
+ to a one-shot build and launch. To skip `dotnet watch` explicitly:
45
+
46
+ ```bash
47
+ npx vidra dev --no-hot-reload
48
+ ```
34
49
 
35
50
  To target a specific desktop platform explicitly:
36
51
 
@@ -14,15 +14,20 @@ public class MainPage : VidraPage
14
14
  using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
15
15
  while (await timer.WaitForNextTickAsync())
16
16
  {
17
- try
18
- {
19
- var count = await Bridge.CallJsAsync<int>("counter.increment");
20
- System.Diagnostics.Debug.WriteLine($"[MainPage] Counter is now {count}");
21
- }
22
- catch (Exception ex)
23
- {
24
- System.Diagnostics.Debug.WriteLine($"[MainPage] Counter increment failed: {ex.Message}");
25
- }
17
+ await OnTickAsync();
18
+ }
19
+ }
20
+
21
+ private async Task OnTickAsync()
22
+ {
23
+ try
24
+ {
25
+ var count = await Bridge.CallJsAsync<int>("counter.increment");
26
+ System.Diagnostics.Debug.WriteLine($"[MainPage] Counter is now {count}");
27
+ }
28
+ catch (Exception ex)
29
+ {
30
+ System.Diagnostics.Debug.WriteLine($"[MainPage] Counter increment failed: {ex.Message}");
26
31
  }
27
32
  }
28
33
  }
@@ -19,7 +19,9 @@ const App = () => {
19
19
  const [windowSupport, setWindowSupport] = useState<WindowSupport | null>(null);
20
20
  const [caps, setCaps] = useState<Record<string, string[]> | null>(null);
21
21
  const [count, setCount] = useState(0);
22
+ const [csReloaded, setCsReloaded] = useState<string | null>(null);
22
23
  const countRef = useRef(0);
24
+ const reloadFlashTimer = useRef<number | undefined>(undefined);
23
25
 
24
26
  useEffect(() => {
25
27
  const unsubscribeCounter = vidra.handle<void, number>("counter.increment", () => {
@@ -28,6 +30,20 @@ const App = () => {
28
30
  return countRef.current;
29
31
  });
30
32
 
33
+ // Emitted by the native host when `vidra dev` hot reloads edited C#.
34
+ const unsubscribeHotReloaded = vidra.on<{ updatedTypes?: string[] }>(
35
+ "vidra.hotReloaded",
36
+ (data) => {
37
+ const typeName = data?.updatedTypes?.[0]?.split(".").pop();
38
+ setCsReloaded(typeName ? `C# reloaded · ${typeName}` : "C# reloaded");
39
+ window.clearTimeout(reloadFlashTimer.current);
40
+ reloadFlashTimer.current = window.setTimeout(
41
+ () => setCsReloaded(null),
42
+ 2500,
43
+ );
44
+ },
45
+ );
46
+
31
47
  const unsubscribeResized = appWindow.onResized((windowInfo) => {
32
48
  setWindowSummary(`Window resized: ${describeWindow(windowInfo)}`);
33
49
  });
@@ -47,8 +63,10 @@ const App = () => {
47
63
 
48
64
  return () => {
49
65
  unsubscribeCounter();
66
+ unsubscribeHotReloaded();
50
67
  unsubscribeResized();
51
68
  unsubscribeStateChanged();
69
+ window.clearTimeout(reloadFlashTimer.current);
52
70
  };
53
71
  }, []);
54
72
 
@@ -200,6 +218,12 @@ const App = () => {
200
218
  <span className="dot" />
201
219
  React + .NET MAUI
202
220
  </span>
221
+ {csReloaded && (
222
+ <span className="badge reload-badge">
223
+ <span className="dot" />
224
+ {csReloaded}
225
+ </span>
226
+ )}
203
227
  <h1 className="title">{{appTitle}}</h1>
204
228
  <p className="tagline">
205
229
  A cross-platform desktop app with a native bridge.
@@ -146,6 +146,22 @@ body::before {
146
146
  box-shadow: 0 0 0 3px var(--dot-glow);
147
147
  }
148
148
 
149
+ /* Flashes when the native host hot reloads edited C# (vidra.hotReloaded). */
150
+ .reload-badge {
151
+ margin-left: 0.5rem;
152
+ color: var(--accent);
153
+ background: var(--accent-soft);
154
+ border-color: var(--accent-border);
155
+ animation: reload-pop 0.25s cubic-bezier(0.2, 0.7, 0.2, 1) both;
156
+ }
157
+
158
+ @keyframes reload-pop {
159
+ from {
160
+ opacity: 0;
161
+ transform: scale(0.9);
162
+ }
163
+ }
164
+
149
165
  .title {
150
166
  margin-top: 1.1rem;
151
167
  font-size: clamp(2.4rem, 6vw, 3.4rem);