create-vidra-app 0.1.9 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,156 @@ 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 meetsMinimumVersion = (version, minimum) => {
404
+ const segments = version.split(".").map((s) => Number.parseInt(s, 10));
405
+ if (segments.slice(0, minimum.length).some(Number.isNaN)) return false;
406
+ for (let i = 0; i < minimum.length; i++) {
407
+ const segment = segments[i] ?? 0;
408
+ if (segment !== minimum[i]) return segment > minimum[i];
409
+ }
410
+ return true;
411
+ };
412
+ var workloadSetSupportsCSharpHotReload = (version) => meetsMinimumVersion(version, MIN_MACCATALYST_WATCH_WORKLOAD_SET);
413
+ var macCatalystHotReloadBlocker = () => {
414
+ const res = run(DOTNET, ["workload", "list"]);
415
+ if (!res.found) return null;
416
+ const version = workloadSetVersion(res.stdout);
417
+ if (!version) return null;
418
+ return workloadSetSupportsCSharpHotReload(version) ? null : version;
419
+ };
420
+ var MISSING_WORKLOAD_SIGNATURES = [
395
421
  /NETSDK1147/i,
396
422
  /workloads?\s+must\s+be\s+installed/i,
397
423
  /maui-maccatalyst/i,
398
424
  /maui-windows/i,
399
425
  /to\s+install\s+the\s+.*workload/i
400
- ].some((re) => re.test(output));
401
- var looksLikeMissingXcode = (output) => [
426
+ ];
427
+ var MISSING_XCODE_SIGNATURES = [
402
428
  /valid\s+Xcode\s+installation\s+was\s+not\s+found/i,
403
429
  /could\s+not\s+find\s+a\s+valid\s+Xcode\s+app\s+bundle/i,
404
430
  /macios-missing-xcode/i
405
- ].some((re) => re.test(output));
431
+ ];
432
+ var OUTDATED_XCODE_SIGNATURES = [
433
+ /error\s+MT0180/i,
434
+ /requires\s+the\s+MacCatalyst\s+\S+\s+SDK\s+\(shipped\s+with\s+Xcode/i
435
+ ];
436
+ var looksLikeMissingWorkload = (output) => matchesAny(output, MISSING_WORKLOAD_SIGNATURES);
437
+ var looksLikeMissingXcode = (output) => matchesAny(output, MISSING_XCODE_SIGNATURES);
438
+ var looksLikeXcodeTooOld = (output) => matchesAny(output, OUTDATED_XCODE_SIGNATURES);
406
439
  var checkDotnetSdk = () => {
440
+ const name = ".NET SDK";
407
441
  const res = run(DOTNET, ["--list-sdks"]);
408
442
  if (!res.found) {
409
443
  return {
410
- name: ".NET SDK",
444
+ name,
411
445
  status: "missing",
412
446
  detail: "`dotnet` was not found on your PATH",
413
- fix: "Install the .NET 10 SDK \u2014 https://dotnet.microsoft.com/download"
447
+ fix: INSTALL_NET_10_FIX
414
448
  };
415
449
  }
416
450
  if (!res.ok && !res.stdout) {
417
- return {
418
- name: ".NET SDK",
419
- status: "unknown",
420
- detail: "could not run `dotnet --list-sdks`"
421
- };
451
+ return { name, status: "unknown", detail: "could not run `dotnet --list-sdks`" };
422
452
  }
423
453
  if (hasNet10Sdk(res.stdout)) {
424
454
  const newest = newestNet10Sdk(res.stdout);
425
- return {
426
- name: ".NET SDK",
427
- status: "ok",
428
- detail: newest ? `found ${newest}` : "found 10.x"
429
- };
455
+ return { name, status: "ok", detail: newest ? `found ${newest}` : "found 10.x" };
430
456
  }
431
457
  return {
432
- name: ".NET SDK",
458
+ name,
433
459
  status: "missing",
434
460
  detail: "no 10.x SDK installed",
435
- fix: "Install the .NET 10 SDK \u2014 https://dotnet.microsoft.com/download"
461
+ fix: INSTALL_NET_10_FIX
436
462
  };
437
463
  };
438
- var checkMauiWorkload = (dotnetOk) => {
439
- if (!dotnetOk) {
440
- return {
441
- name: ".NET MAUI workload",
442
- status: "unknown",
443
- detail: "requires the .NET SDK first"
444
- };
464
+ var checkMauiWorkload = (workloadList) => {
465
+ const name = ".NET MAUI workload";
466
+ if (!workloadList) {
467
+ return { name, status: "unknown", detail: "requires the .NET SDK first" };
445
468
  }
446
- const res = run(DOTNET, ["workload", "list"]);
447
- if (!res.found) {
448
- return {
449
- name: ".NET MAUI workload",
450
- status: "unknown",
451
- detail: "could not query workloads"
452
- };
469
+ if (!workloadList.found) {
470
+ return { name, status: "unknown", detail: "could not query workloads" };
453
471
  }
454
- if (outputMentionsMaui(res.stdout)) {
455
- return { name: ".NET MAUI workload", status: "ok", detail: "installed" };
472
+ if (outputMentionsMaui(workloadList.stdout)) {
473
+ return { name, status: "ok", detail: "installed" };
456
474
  }
457
475
  return {
458
- name: ".NET MAUI workload",
476
+ name,
459
477
  status: "missing",
460
478
  detail: "not installed",
461
479
  fix: "dotnet workload install maui"
462
480
  };
463
481
  };
482
+ var checkCSharpHotReload = (workloadList) => {
483
+ const name = "C# hot reload";
484
+ if (!workloadList) {
485
+ return { name, status: "unknown", detail: "requires the .NET SDK first" };
486
+ }
487
+ if (process.platform !== "darwin") {
488
+ return { name, status: "ok", detail: "dotnet watch supported" };
489
+ }
490
+ const version = workloadSetVersion(workloadList.stdout);
491
+ if (!version) {
492
+ return {
493
+ name,
494
+ status: "unknown",
495
+ detail: "could not read the workload set version"
496
+ };
497
+ }
498
+ if (workloadSetSupportsCSharpHotReload(version)) {
499
+ return { name, status: "ok", detail: `workload set ${version}` };
500
+ }
501
+ return {
502
+ name,
503
+ status: "unknown",
504
+ detail: `workload set ${version} predates Mac Catalyst dotnet-watch support (needs 10.0.203+) \u2014 vidra dev falls back to a classic launch`,
505
+ fix: "dotnet workload update"
506
+ };
507
+ };
464
508
  var checkXcode = () => {
509
+ const name = "Xcode";
465
510
  const res = run("xcode-select", ["-p"]);
466
511
  if (!res.found || !res.ok) {
467
512
  return {
468
- name: "Xcode",
513
+ name,
469
514
  status: "missing",
470
515
  detail: "not found",
471
516
  fix: "Install Xcode from the App Store"
472
517
  };
473
518
  }
474
519
  const devDir = res.stdout.trim();
475
- if (/CommandLineTools/i.test(devDir)) {
520
+ if (COMMAND_LINE_TOOLS_PATH.test(devDir)) {
476
521
  return {
477
- name: "Xcode",
522
+ name,
478
523
  status: "missing",
479
524
  detail: "only Command Line Tools detected (Mac Catalyst needs full Xcode)",
480
525
  fix: "Install Xcode, then: sudo xcode-select -s /Applications/Xcode.app"
481
526
  };
482
527
  }
483
- return { name: "Xcode", status: "ok", detail: devDir };
528
+ return { name, status: "ok", detail: devDir };
484
529
  };
485
530
  var isMauiWorkloadInstalled = () => outputMentionsMaui(run(DOTNET, ["workload", "list"]).stdout);
486
531
  var isInteractive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
487
532
  var collectRequirements = (opts = {}) => {
488
533
  const dotnet = checkDotnetSdk();
489
- const reqs = [dotnet, checkMauiWorkload(dotnet.status === "ok")];
534
+ const workloadList = dotnet.status === "ok" ? run(DOTNET, ["workload", "list"]) : null;
535
+ const reqs = [
536
+ dotnet,
537
+ checkMauiWorkload(workloadList),
538
+ checkCSharpHotReload(workloadList)
539
+ ];
490
540
  if (opts.includeXcode ?? process.platform === "darwin") {
491
541
  reqs.push(checkXcode());
492
542
  }
@@ -508,7 +558,7 @@ var printRequirements = (reqs) => {
508
558
  detail: r.detail ? dim(r.detail) : void 0
509
559
  })
510
560
  );
511
- if (r.status === "missing" && r.fix) {
561
+ if (r.status !== "ok" && r.fix) {
512
562
  console.log(fixLine(r.fix));
513
563
  }
514
564
  }
@@ -645,6 +695,23 @@ var printWorkloadHint = () => {
645
695
  console.error(fixLine("vidra doctor", "check:"));
646
696
  console.error();
647
697
  };
698
+ var printXcodeTooOldHint = () => {
699
+ console.error();
700
+ console.error(
701
+ row({
702
+ glyph: "manual",
703
+ label: "your Xcode is older than the SDK this MAUI workload set expects."
704
+ })
705
+ );
706
+ console.error(
707
+ ` ${dim("\u2022")} ${dim("update Xcode (App Store), then")} ${lime("sudo xcodebuild -runFirstLaunch")}`
708
+ );
709
+ console.error(
710
+ ` ${dim("\u2022")} ${dim("or pin the workloads to your Xcode's era:")} ${lime("dotnet workload update --version <set>")}`
711
+ );
712
+ console.error(fixLine("vidra doctor", "check:"));
713
+ console.error();
714
+ };
648
715
  var printXcodeHint = () => {
649
716
  console.error();
650
717
  console.error(
@@ -948,6 +1015,7 @@ var POLL_INTERVAL_MS = 500;
948
1015
  var POLL_TIMEOUT_MS = 3e4;
949
1016
  var NPM_COMMAND = process.platform === "win32" ? "npm.cmd" : "npm";
950
1017
  var DOTNET_COMMAND = process.platform === "win32" ? "dotnet.exe" : "dotnet";
1018
+ var WATCH_OUTPUT_TAIL_CHARS = 8192;
951
1019
  var TARGETS2 = {
952
1020
  macos: {
953
1021
  name: "macos",
@@ -958,12 +1026,13 @@ var TARGETS2 = {
958
1026
  framework: "net10.0-windows10.0.19041.0"
959
1027
  }
960
1028
  };
961
- var devCommand = (argv) => startSession(argv, { vite: true });
962
- var runCommand = (argv) => startSession(argv, { vite: false });
1029
+ var devCommand = (argv) => startSession(argv, { vite: true, hotReloadDefault: true });
1030
+ var runCommand = (argv) => startSession(argv, { vite: false, hotReloadDefault: false });
963
1031
  var startSession = async (argv, opts) => {
964
1032
  const args = parseArgs(["_", "_", ...argv]);
965
1033
  const targetName = args["target"] || detectPlatform();
966
1034
  const verbose = !!args["verbose"];
1035
+ let hotReload = opts.hotReloadDefault && !args["no-hot-reload"];
967
1036
  const viteUrl = process.env.VIDRA_DEV_URL || "http://localhost:5173";
968
1037
  const target = TARGETS2[targetName];
969
1038
  if (!target) {
@@ -981,16 +1050,68 @@ var startSession = async (argv, opts) => {
981
1050
  if (!await ensureMauiWorkload({ csprojPath: project.csprojPath })) {
982
1051
  process.exit(1);
983
1052
  }
984
- const session = new DevSession(project, target, viteUrl, verbose, opts);
1053
+ if (hotReload && target.name === "macos") {
1054
+ const blocker = macCatalystHotReloadBlocker();
1055
+ if (blocker) {
1056
+ hotReload = false;
1057
+ console.log();
1058
+ console.log(
1059
+ row({
1060
+ glyph: "manual",
1061
+ detail: `${dim("C# hot reload needs workload set 10.0.203+ \u2014 found")} ${value(blocker)}${dim("; using a classic launch")}`
1062
+ })
1063
+ );
1064
+ console.log(fixLine("dotnet workload update"));
1065
+ }
1066
+ }
1067
+ const session = new DevSession(project, target, viteUrl, verbose, {
1068
+ vite: opts.vite,
1069
+ hotReload
1070
+ });
985
1071
  await session.run();
986
1072
  };
1073
+ var buildDotnetWatchArgs = (opts) => [
1074
+ "watch",
1075
+ "--project",
1076
+ opts.csprojPath,
1077
+ // Never prompt on rude edits (our stdio is piped, so a prompt would hang);
1078
+ // paired with DOTNET_WATCH_RESTART_ON_RUDE_EDIT for older SDKs.
1079
+ "--non-interactive",
1080
+ ...opts.verbose ? ["--verbose"] : [],
1081
+ "run",
1082
+ "-f",
1083
+ opts.framework,
1084
+ "-c",
1085
+ opts.buildConfig,
1086
+ // Launch the Mac Catalyst app by exec'ing the binary rather than `open -a`,
1087
+ // so the environment (VIDRA_DEV_URL, DOTNET_STARTUP_HOOKS) propagates and
1088
+ // stdout streams back. Ignored by targets that don't define the property.
1089
+ ...opts.targetName === "macos" ? ["--property:RunWithOpen=false"] : []
1090
+ ];
1091
+ var dotnetWatchEnv = (devUrl) => ({
1092
+ VIDRA_DEV_URL: devUrl,
1093
+ DOTNET_WATCH_RESTART_ON_RUDE_EDIT: "1",
1094
+ DOTNET_WATCH_SUPPRESS_EMOJIS: "1",
1095
+ DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER: "1"
1096
+ });
1097
+ var HOST_READY_SENTINEL = "[vidra] host ready";
1098
+ var classifyWatchLine = (line) => {
1099
+ if (/waiting for a file to change/i.test(line)) return "appWaiting";
1100
+ if (/fix the error to continue/i.test(line)) return "buildBlocked";
1101
+ if (line.includes(HOST_READY_SENTINEL)) return "appStarted";
1102
+ if (/\bwatch\b/i.test(line) && /\b(?:started|launched)\b/i.test(line)) {
1103
+ return "appStarted";
1104
+ }
1105
+ return null;
1106
+ };
987
1107
  var DevSession = class {
988
- constructor(project, target, viteUrl, verbose, options = {}) {
1108
+ constructor(project, target, viteUrl, verbose, options) {
989
1109
  this.project = project;
990
1110
  this.target = target;
991
1111
  this.viteUrl = viteUrl;
992
1112
  this.verbose = verbose;
993
- this.vite = options.vite ?? true;
1113
+ this.vite = options.vite;
1114
+ this.hotReload = options.hotReload;
994
1115
  }
995
1116
  project;
996
1117
  target;
@@ -999,7 +1120,19 @@ var DevSession = class {
999
1120
  children = [];
1000
1121
  buildConfig = process.env.VIDRA_BUILD_CONFIG || "Debug";
1001
1122
  vite;
1123
+ hotReload;
1002
1124
  shuttingDown = false;
1125
+ // Watch-mode state: `watchChild` is the `dotnet watch` process (a process
1126
+ // group leader on unix, so shutdown can signal it and the app it launched
1127
+ // together); `watchReady` flips once the app has started at least once.
1128
+ watchChild;
1129
+ watchReady = false;
1130
+ watchOutputTail = "";
1131
+ endSession = () => {
1132
+ };
1133
+ sessionDone = new Promise((resolve) => {
1134
+ this.endSession = resolve;
1135
+ });
1003
1136
  async run() {
1004
1137
  this.installSignalHandlers();
1005
1138
  console.log();
@@ -1007,9 +1140,8 @@ var DevSession = class {
1007
1140
  console.log(kv("project", this.project.projectName));
1008
1141
  console.log(kv("target", this.target.framework));
1009
1142
  console.log();
1010
- let vite;
1011
1143
  if (this.vite) {
1012
- vite = this.startVite();
1144
+ this.startVite();
1013
1145
  try {
1014
1146
  await waitForServer(this.viteUrl, POLL_TIMEOUT_MS);
1015
1147
  } catch (error) {
@@ -1028,19 +1160,22 @@ var DevSession = class {
1028
1160
  )
1029
1161
  );
1030
1162
  }
1031
- const host = this.target.name === "macos" ? this.launchMacosHost() : this.launchWindowsHost();
1163
+ this.launchHost();
1164
+ const hostDirLabel = `src/${path6.basename(this.project.hostDir)}`;
1032
1165
  if (this.vite) {
1033
1166
  console.log(
1034
1167
  taggedRow(
1035
1168
  "active",
1036
1169
  null,
1037
- `${lime("hot reload active")} ${dim("\u2014 edit ui/src and save")}`
1170
+ 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
1171
  )
1039
1172
  );
1040
1173
  console.log();
1041
1174
  console.log(
1042
1175
  footer(
1043
- `${dim("watching")} ${value("ui/")} ${dim(
1176
+ this.hotReload ? `${dim("watching")} ${value("ui/")} ${dim("\xB7")} ${value(`${hostDirLabel}/`)} ${dim(
1177
+ "\xB7 hot reload on save \xB7 ctrl-c to stop"
1178
+ )}` : `${dim("watching")} ${value("ui/")} ${dim(
1044
1179
  "\xB7 hot reload on save \xB7 ctrl-c to stop"
1045
1180
  )}`
1046
1181
  )
@@ -1052,7 +1187,7 @@ var DevSession = class {
1052
1187
  );
1053
1188
  }
1054
1189
  console.log();
1055
- await waitForExit(...vite ? [vite, host] : [host]);
1190
+ await this.sessionDone;
1056
1191
  }
1057
1192
  installSignalHandlers() {
1058
1193
  process.on("SIGINT", () => {
@@ -1072,6 +1207,166 @@ var DevSession = class {
1072
1207
  });
1073
1208
  return this.registerChild(vite, "ui", "Vite dev server");
1074
1209
  }
1210
+ launchHost() {
1211
+ if (this.hotReload) {
1212
+ return this.launchHostWithWatch();
1213
+ }
1214
+ return this.target.name === "macos" ? this.launchMacosHost() : this.launchWindowsHost();
1215
+ }
1216
+ // --- dotnet watch launch (C# hot reload) -----------------------------------
1217
+ launchHostWithWatch() {
1218
+ console.log(
1219
+ taggedRow(
1220
+ "active",
1221
+ "host",
1222
+ `${dim("dotnet watch \u2014 building")} ${value(this.target.framework)} ${dim("\u2026")}`
1223
+ )
1224
+ );
1225
+ const watch = spawn2(
1226
+ DOTNET_COMMAND,
1227
+ buildDotnetWatchArgs({
1228
+ csprojPath: this.project.csprojPath,
1229
+ framework: this.target.framework,
1230
+ buildConfig: this.buildConfig,
1231
+ verbose: this.verbose,
1232
+ targetName: this.target.name
1233
+ }),
1234
+ {
1235
+ cwd: this.project.root,
1236
+ stdio: ["ignore", "pipe", "pipe"],
1237
+ env: { ...process.env, ...dotnetWatchEnv(this.viteUrl) },
1238
+ // Unix: make dotnet watch its own process-group leader so shutdown can
1239
+ // SIGTERM the group — taking down both watch and the app it launched.
1240
+ // Windows cleanup goes through `taskkill /T` instead.
1241
+ detached: process.platform !== "win32"
1242
+ }
1243
+ );
1244
+ this.watchChild = watch;
1245
+ this.children.push(watch);
1246
+ const notSentinel = (line) => !line.includes(HOST_READY_SENTINEL);
1247
+ prefixStream(watch.stdout, "host", notSentinel);
1248
+ prefixStream(watch.stderr, "host", notSentinel);
1249
+ scanStream(watch.stdout, (line) => this.onWatchLine(line));
1250
+ scanStream(watch.stderr, (line) => this.onWatchLine(line));
1251
+ watch.on("exit", (code, signal) => {
1252
+ if (this.shuttingDown) return;
1253
+ if (this.watchReady) {
1254
+ const failed = code !== null && code !== 0 || signal !== null;
1255
+ if (failed) {
1256
+ console.error(
1257
+ "\n" + row({
1258
+ glyph: "error",
1259
+ detail: dim(
1260
+ `dotnet watch exited with ${signal ? `signal ${signal}` : `code ${code}`}`
1261
+ )
1262
+ })
1263
+ );
1264
+ }
1265
+ this.shutdown(code ?? (signal ? 1 : 0));
1266
+ return;
1267
+ }
1268
+ this.fallBackToClassicLaunch(code, signal);
1269
+ });
1270
+ watch.on("error", (error) => {
1271
+ if (this.shuttingDown) return;
1272
+ console.error(
1273
+ "\n" + row({
1274
+ glyph: "error",
1275
+ detail: dim(`failed to start dotnet watch: ${error.message}`)
1276
+ })
1277
+ );
1278
+ this.shutdown(1);
1279
+ });
1280
+ return watch;
1281
+ }
1282
+ onWatchLine(line) {
1283
+ this.watchOutputTail = (this.watchOutputTail + line + "\n").slice(
1284
+ -WATCH_OUTPUT_TAIL_CHARS
1285
+ );
1286
+ const event = classifyWatchLine(line);
1287
+ if (event === "appStarted" && !this.watchReady) {
1288
+ this.watchReady = true;
1289
+ console.log(
1290
+ taggedRow(
1291
+ "done",
1292
+ "host",
1293
+ `${dim("launched")} ${value(this.project.projectName)} ${dim("\u2014 C# hot reload active")}`
1294
+ )
1295
+ );
1296
+ } else if (event === "appWaiting" && !this.shuttingDown) {
1297
+ console.log(
1298
+ taggedRow(
1299
+ "manual",
1300
+ "host",
1301
+ dim("app not running \u2014 save a C# file to relaunch, or ctrl-c to stop")
1302
+ )
1303
+ );
1304
+ } else if (event === "buildBlocked" && !this.shuttingDown) {
1305
+ if (!this.watchReady) {
1306
+ if (looksLikeMissingWorkload(this.watchOutputTail)) printWorkloadHint();
1307
+ else if (looksLikeXcodeTooOld(this.watchOutputTail)) printXcodeTooOldHint();
1308
+ else if (looksLikeMissingXcode(this.watchOutputTail)) printXcodeHint();
1309
+ }
1310
+ console.log(
1311
+ taggedRow(
1312
+ "manual",
1313
+ "host",
1314
+ dim("build failed \u2014 fix the error and save to retry, or ctrl-c to stop")
1315
+ )
1316
+ );
1317
+ }
1318
+ }
1319
+ /**
1320
+ * `dotnet watch` exited before the app ever started — typically an SDK or
1321
+ * MAUI workload set that predates `dotnet run`/`dotnet watch` support for
1322
+ * this target. Explain why, then launch the host the classic way (one
1323
+ * build + direct spawn) so the dev session still works, minus C# hot reload.
1324
+ */
1325
+ fallBackToClassicLaunch(code, signal) {
1326
+ console.log();
1327
+ console.log(
1328
+ taggedRow(
1329
+ "manual",
1330
+ "host",
1331
+ `${dim("C# hot reload unavailable \u2014 dotnet watch exited with")} ${value(
1332
+ signal ? `signal ${signal}` : `code ${code ?? "unknown"}`
1333
+ )}`
1334
+ )
1335
+ );
1336
+ if (looksLikeMissingWorkload(this.watchOutputTail)) {
1337
+ printWorkloadHint();
1338
+ } else if (looksLikeXcodeTooOld(this.watchOutputTail)) {
1339
+ printXcodeTooOldHint();
1340
+ } else if (looksLikeMissingXcode(this.watchOutputTail)) {
1341
+ printXcodeHint();
1342
+ } else {
1343
+ console.log(
1344
+ footer(
1345
+ dim(
1346
+ "C# hot reload needs the .NET 10.0.203+ workload set (Mac Catalyst dotnet-watch support) \u2014 try:"
1347
+ )
1348
+ )
1349
+ );
1350
+ console.log(fixLine("dotnet workload update"));
1351
+ if (!this.verbose) {
1352
+ console.log(footer(dim("re-run with --verbose for the full watch log.")));
1353
+ }
1354
+ }
1355
+ console.log(
1356
+ taggedRow(
1357
+ "active",
1358
+ "host",
1359
+ dim("falling back to a classic launch (frontend hot reload still active)\u2026")
1360
+ )
1361
+ );
1362
+ console.log();
1363
+ if (this.target.name === "macos") {
1364
+ this.launchMacosHost();
1365
+ } else {
1366
+ this.launchWindowsHost();
1367
+ }
1368
+ }
1369
+ // --- classic launch (one build + direct spawn) ------------------------------
1075
1370
  // Builds the MAUI host as a discrete step (a plain `dotnet build`, never
1076
1371
  // MSBuild's `-t:Run`) so the per-OS launch paths can spawn the produced
1077
1372
  // binary directly. `-t:Run` shells out in ways that break for both locally
@@ -1194,8 +1489,9 @@ var DevSession = class {
1194
1489
  }
1195
1490
  registerChild(child, tag2, label) {
1196
1491
  this.children.push(child);
1197
- prefixStream(child.stdout, tag2);
1198
- prefixStream(child.stderr, tag2);
1492
+ const include = tag2 === "host" ? (line) => !line.includes(HOST_READY_SENTINEL) : void 0;
1493
+ prefixStream(child.stdout, tag2, include);
1494
+ prefixStream(child.stderr, tag2, include);
1199
1495
  child.on("exit", (code, signal) => {
1200
1496
  if (this.shuttingDown) return;
1201
1497
  if (tag2 === "ui") {
@@ -1238,8 +1534,9 @@ var DevSession = class {
1238
1534
  if (this.shuttingDown) return;
1239
1535
  this.shuttingDown = true;
1240
1536
  for (const child of this.children) {
1241
- killChild(child);
1537
+ killChild(child, { processGroup: child === this.watchChild });
1242
1538
  }
1539
+ this.endSession();
1243
1540
  process.exit(exitCode);
1244
1541
  }
1245
1542
  };
@@ -1257,19 +1554,32 @@ var ensureTargetMatchesHostOs = (targetName) => {
1257
1554
  process.exit(1);
1258
1555
  }
1259
1556
  };
1260
- var prefixStream = (stream, tag2) => {
1557
+ var prefixStream = (stream, tag2, include = () => true) => {
1261
1558
  if (!stream) return;
1262
1559
  const prefix = streamPrefix(tag2);
1263
1560
  stream.on("data", (chunk) => {
1264
1561
  const lines = chunk.toString().split("\n");
1265
1562
  for (const line of lines) {
1266
- if (line.length > 0) {
1563
+ if (line.length > 0 && include(line)) {
1267
1564
  process.stdout.write(`${prefix} ${line}
1268
1565
  `);
1269
1566
  }
1270
1567
  }
1271
1568
  });
1272
1569
  };
1570
+ var scanStream = (stream, onLine) => {
1571
+ if (!stream) return;
1572
+ let pending = "";
1573
+ stream.on("data", (chunk) => {
1574
+ pending += chunk.toString();
1575
+ const lines = pending.split("\n");
1576
+ pending = lines.pop() ?? "";
1577
+ for (const line of lines) onLine(line);
1578
+ });
1579
+ stream.on("end", () => {
1580
+ if (pending) onLine(pending);
1581
+ });
1582
+ };
1273
1583
  var waitForServer = (url, timeoutMs) => {
1274
1584
  const { hostname, port, pathname } = new URL(url);
1275
1585
  const start = Date.now();
@@ -1301,14 +1611,6 @@ var waitForServer = (url, timeoutMs) => {
1301
1611
  poll();
1302
1612
  });
1303
1613
  };
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
1614
  var findMacAppBundle = (hostDir, framework, buildConfig) => {
1313
1615
  const outputDir = path6.join(hostDir, "bin", buildConfig, framework);
1314
1616
  return findAppBundleRecursive(outputDir);
@@ -1362,7 +1664,7 @@ var findFileRecursive = (dir, match) => {
1362
1664
  }
1363
1665
  return null;
1364
1666
  };
1365
- var killChild = (child) => {
1667
+ var killChild = (child, opts = {}) => {
1366
1668
  if (!child.pid || child.exitCode !== null) return;
1367
1669
  if (process.platform === "win32") {
1368
1670
  try {
@@ -1374,6 +1676,13 @@ var killChild = (child) => {
1374
1676
  }
1375
1677
  return;
1376
1678
  }
1679
+ if (opts.processGroup) {
1680
+ try {
1681
+ process.kill(-child.pid, "SIGTERM");
1682
+ return;
1683
+ } catch {
1684
+ }
1685
+ }
1377
1686
  child.kill("SIGTERM");
1378
1687
  };
1379
1688
  var printMacLaunchHint = () => {
@@ -1411,7 +1720,7 @@ var printHelp = () => {
1411
1720
  ${lime("vidra")} ${dim("<command> [options]")}
1412
1721
 
1413
1722
  ${dim("commands")}
1414
- ${cmd("dev", "start vite + the native host (hot reload)")}
1723
+ ${cmd("dev", "start vite + the native host (UI + C# hot reload)")}
1415
1724
  ${cmd("run", "launch the native host only")}
1416
1725
  ${cmd("build", "build & package for distribution")}
1417
1726
  ${cmd("doctor", "check your environment")}
@@ -1419,6 +1728,7 @@ ${cmd("help", "show this message")}
1419
1728
 
1420
1729
  ${dim("examples")}
1421
1730
  ${ex("dev --target windows", "run the windows host")}
1731
+ ${ex("dev --no-hot-reload", "skip dotnet watch, classic launch")}
1422
1732
  ${ex("build --plan", "preview the build, run nothing")}
1423
1733
  ${ex("build --target macos", "build & package a macOS DMG")}
1424
1734
  ${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.1";
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.1.9",
3
+ "version": "0.2.1",
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
  }
@@ -1,5 +1,5 @@
1
- import { useEffect, useRef, useState } from "react";
2
- import { app, appWindow, clipboard, notifications, vidra } from "@vidra-dev/sdk";
1
+ import { type MouseEvent, useEffect, useRef, useState } from "react";
2
+ import { app, appWindow, browser, clipboard, notifications, vidra } from "@vidra-dev/sdk";
3
3
  import type { WindowInfo, WindowSupport } from "@vidra-dev/sdk";
4
4
 
5
5
  const describeWindow = (windowInfo: WindowInfo): string => {
@@ -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
 
@@ -172,6 +190,18 @@ const App = () => {
172
190
  }
173
191
  };
174
192
 
193
+ const handleOpenVidraSite = async (
194
+ event: MouseEvent<HTMLAnchorElement>,
195
+ ) => {
196
+ // Open in the user's default browser instead of navigating the webview.
197
+ event.preventDefault();
198
+ try {
199
+ await browser.open({ url: "https://vidra.build", mode: "external" });
200
+ } catch (error: unknown) {
201
+ setInfo(`Error: ${formatError(error)}`);
202
+ }
203
+ };
204
+
175
205
  const showsAdvancedWindowActions =
176
206
  !!windowSupport &&
177
207
  (windowSupport.center ||
@@ -188,6 +218,12 @@ const App = () => {
188
218
  <span className="dot" />
189
219
  React + .NET MAUI
190
220
  </span>
221
+ {csReloaded && (
222
+ <span className="badge reload-badge">
223
+ <span className="dot" />
224
+ {csReloaded}
225
+ </span>
226
+ )}
191
227
  <h1 className="title">{{appTitle}}</h1>
192
228
  <p className="tagline">
193
229
  A cross-platform desktop app with a native bridge.
@@ -254,9 +290,10 @@ const App = () => {
254
290
  <footer className="footer">
255
291
  Built with{" "}
256
292
  <a
257
- href="https://github.com/rzamfiriu/vidra"
293
+ href="https://vidra.dev"
258
294
  target="_blank"
259
295
  rel="noreferrer"
296
+ onClick={handleOpenVidraSite}
260
297
  >
261
298
  Vidra
262
299
  </a>
@@ -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);