Repository navigation
Expand file tree
/
Copy pathProgram.cs
More file actions
3753 lines (3318 loc) · 204 KB
/
Copy pathProgram.cs
File metadata and controls
3753 lines (3318 loc) · 204 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
using CefSharp;
using CefSharp.OffScreen;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using NewTek;
using NewTek.NDI;
using Serilog;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Tractus.HtmlToNdi.Chromium;
using Tractus.HtmlToNdi.Chromium.Inject;
using Tractus.HtmlToNdi.Chromium.Monitor;
using Tractus.HtmlToNdi.Models;
namespace Tractus.HtmlToNdi;
public class Program
{
public static nint NdiSenderPtr;
public static CefWrapper browserWrapper;
// D-06/D-21: the recipe store + the launch posture captured at startup. The store backs both the
// /recipe GET/POST endpoints and the /seturl recipe re-match; the launch posture (the
// ExpectsCrossOriginIframes value that gated the FROZEN site-isolation flags at Cef.Initialize) is
// the reference the /recipe + /seturl swaps reject a mismatch against (D-21 — site-iso cannot change
// at runtime).
public static RecipeStore recipeStore;
public static bool launchExpectsCrossOriginIframes;
public static void Main(string[] args)
{
var launchCachePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "cache", Guid.NewGuid().ToString());
var exeDirectory = AppDomain.CurrentDomain.BaseDirectory;
Directory.SetCurrentDirectory(exeDirectory);
AppManagement.Initialize(args);
// D-15a: detect --smoke BEFORE the interactive --ndiname/--port prompts below (both call
// Console.ReadLine() when their arg is absent, which would HANG a headless CI run with no
// stdin). The smoke path sets its own defaults and short-circuits the whole normal flow
// (interactive prompts + ASP.NET host + KVM thread). The normal --url= path is unchanged.
if (args.Any(x => x.StartsWith("--inject-smoke")))
{
// D-14/D-22: the inject acceptance gate. Detected BEFORE --smoke (StartsWith("--smoke")
// would NOT alias "--inject-smoke", but check inject first for clarity) and before the
// interactive prompts (same stdin-hang hazard). Always Environment.Exit.
RunInjectSmoke(args, launchCachePath);
return; // unreachable — RunInjectSmoke always calls Environment.Exit.
}
if (args.Any(x => x.StartsWith("--monitor-smoke")))
{
// D-09/D-34 (MON-02..05): the monitor self-healing acceptance gate. Detected BEFORE --smoke
// (StartsWith("--smoke") would NOT alias "--monitor-smoke") and before the interactive prompts
// (same stdin-hang hazard). Serves an rAF-canvas fixture that freezes on command over a loopback
// listener and drives the REAL CEF->FrameMonitor->FramePump pipeline through freeze->fallback->
// refresh+re-inject->recovery via a DEDICATED non-colliding recipe with smoke-scale tiny
// thresholds (D-34). Always Environment.Exit (0 on the full path succeeding, 1 otherwise).
RunMonitorSmoke(args, launchCachePath);
return; // unreachable — RunMonitorSmoke always calls Environment.Exit.
}
if (args.Any(x => x.StartsWith("--onpaint-format")))
{
// D-22/D-29 (MON-01): the OnPaint pixel-format readback gate — the literal FIRST task of
// Phase 2. Detected BEFORE --smoke (StartsWith("--smoke") would NOT alias
// "--onpaint-format") and before the interactive --ndiname/--port prompts (same stdin-hang
// hazard). Renders a known 3-region semi-transparent fixture, reads back the REAL pre-send
// OnPaint bytes, asserts BGRA channel order + per-region alpha value + premultiplied-vs-
// straight + the all-alpha-0 blank case. Always Environment.Exit (exit 0 on a clean STRAIGHT
// determination, non-zero on fail/drift).
RunOnPaintFormatGate(args, launchCachePath);
return; // unreachable — RunOnPaintFormatGate always calls Environment.Exit.
}
if (args.Any(x => x.StartsWith("--accuweather-probe")))
{
// D-01/D-16 (VAL-01): the posture+CMP probe gate. Detected BEFORE --smoke (StartsWith("--smoke")
// would NOT alias "--accuweather-probe") and before the interactive --ndiname/--port prompts
// (same stdin-hang hazard). Runs site-isolation OFF (D-03) against the operator-supplied radar
// URL, resolves per-frame contexts via Page.createIsolatedWorld, re-arms readback on frame
// re-attach (executionContextDestroyed/frameNavigated), classifies the cross-origin posture +
// identifies the live CMP, and emits a structured decision the operator transcribes into the
// Task-2 decision record. Always Environment.Exit (0 on a clean classified run, non-zero on
// env/CDP failure). NOT a throwaway spike — re-runnable on AccuWeather redesign (D-16).
RunAccuWeatherProbe(args, launchCachePath);
return; // unreachable — RunAccuWeatherProbe always calls Environment.Exit.
}
if (args.Any(x => x.StartsWith("--accuweather-capture")))
{
// 03-04 D-07/D-24/D-26 (VAL-04): the idle-gap capture gate. Detected BEFORE --smoke
// (StartsWith("--smoke") would NOT alias "--accuweather-capture") and before the interactive
// --ndiname/--port prompts (same stdin-hang hazard). Launches the FULL live pipeline (the SAME
// composition root the interactive --url=/--recipe path uses — CefWrapper + FrameMonitor +
// FramePump + NDI send) against the AccuWeather recipe (expectMotion=true), then arms a per-sample
// LOGGING SIDE-CHANNEL that CONSUMES the plan-03 read-only SampleObserved telemetry seam (D-24) on
// the EXISTING 5 Hz OnSampleTick (SampleIntervalMs=200 — NO new timer, NO recomputed detector, NO
// reach into private FrameMonitor fields). Each sample → one CSV line of six columns:
// tMs/dHash/hammingFromPrev/lastPaintAgeMs/beaconState/targetPresent. Runs for a bounded
// --duration then flushes + Environment.Exit(0). The operator runs this on the GPU Session-2 host
// (Task 2) to derive the longest BEACON-GATED normal idle gap and lock freezeTimeoutMs ≈≥3× it.
// CRITICAL: NDI is wired exactly like the interactive path because CefWrapper.OnBrowserPaint
// early-returns at `if (Program.NdiSenderPtr == nint.Zero) return;` BEFORE FrameReady?.Invoke —
// FrameReady is the FrameMonitor's only frame feed, so a capture that skips NDI would STARVE the
// monitor and SampleObserved would produce nothing.
RunAccuWeatherCapture(args, launchCachePath);
return; // unreachable — RunAccuWeatherCapture always calls Environment.Exit.
}
if (args.Any(x => x.StartsWith("--beacon-damage-check")))
{
// 03-03 (Q2 / A4): the alpha-0-damage validation gate — the ONE genuine empirical unknown of
// Phase 3 (research §19): does an alpha-0 corner whose OPAQUE RGB mutates each rAF tick produce
// CAPTURED OnPaint damage on CefSharp 148, surviving the un-premult LUT into the straight buffer
// the FrameMonitor samples? Detected BEFORE --smoke (StartsWith("--smoke") would NOT alias
// "--beacon-damage-check") and before the interactive --ndiname/--port prompts (same stdin-hang
// hazard). Captures TWO non-blank OnPaint buffers a few frames apart and asserts the beacon-region
// RGB DIFFERS. Differs => alpha-0 RGB mutation produces captured damage => the D-13 beacon design
// is valid (exit 0). Identical => damage-gating swallowed it => exit non-zero pointing to the A=1
// low-alpha fallback. Mirrors RunOnPaintFormatGate; always Environment.Exit.
RunBeaconDamageGate(args, launchCachePath);
return; // unreachable — RunBeaconDamageGate always calls Environment.Exit.
}
if (args.Any(x => x.StartsWith("--accuweather-validate")))
{
// 03-05 D-06/D-27 (VAL-01/VAL-03/VAL-04): the SEMI-AUTOMATED live content-proof gate — the
// runnable spine of the Phase-3 acceptance. Detected BEFORE --smoke (StartsWith("--smoke") would
// NOT alias "--accuweather-validate") and before the interactive --ndiname/--port prompts (same
// stdin-hang hazard). Authored as a SIBLING of --monitor-smoke/--accuweather-capture (D-27): it
// stands up the SAME single-authority composition root the interactive --url=/--recipe path uses
// (CefWrapper + FrameMonitor + FramePump + NDI send + a real ASP.NET-free in-process /health read
// via SnapshotHealth + the sibling proof-marker probe), SELF-DRIVES the live AccuWeather recipe,
// runs a four-point ENV PRE-ASSERTION (D-06/D-19, FORK-04 §6 shape), then asserts VAL-01 (all five
// proof-markers), VAL-03 (blank + a genuine all-stop freeze → fallback whose on-air frame MATCHES
// the slate.png signature, D-29b/D-31), and VAL-04 (no false-trip over the live idle-hold; the
// freezeTimeoutMs backstop is the v1.0 trip authority, the beacon best-effort behind the D-31
// disable-on-false-trip guard). Fault injection uses ONLY the existing control plane (SetUrlAsync /
// SwapRecipeAsync — the in-process equivalents of /seturl + /recipe); NO new endpoint (D-10).
// Always Environment.Exit (0 on a full pass, non-zero with the failing assertion named).
RunAccuWeatherValidate(args, launchCachePath);
return; // unreachable — RunAccuWeatherValidate always calls Environment.Exit.
}
if (args.Any(x => x.StartsWith("--smoke")))
{
RunSmoke(args, launchCachePath);
return; // unreachable — RunSmoke always calls Environment.Exit.
}
var ndiName = "HTML5";
if (args.Any(x => x.StartsWith("--ndiname")))
{
try
{
ndiName = args.FirstOrDefault(x => x.StartsWith("--ndiname")).Split("=")[1];
if (string.IsNullOrWhiteSpace(ndiName))
{
throw new ArgumentException();
}
}
catch
{
Log.Error("Invalid NDI source name. Exiting.");
return;
}
}
else
{
ndiName = "";
while (string.IsNullOrWhiteSpace(ndiName))
{
Console.Write("NDI source name >");
ndiName = Console.ReadLine()?.Trim();
}
}
var port = 9999;
if (args.Any(x => x.StartsWith("--port")))
{
try
{
port = int.Parse(args.FirstOrDefault(x => x.StartsWith("--port")).Split("=")[1]);
}
catch (Exception)
{
Log.Error("Could not parse the --port parameter. Exiting.");
return;
}
}
else
{
var portNumber = "";
while (string.IsNullOrWhiteSpace(portNumber) || !int.TryParse(portNumber, out port))
{
Console.Write("HTTP API port # >");
portNumber = Console.ReadLine()?.Trim();
}
}
var startUrl = "https://testpattern.tractusevents.com/";
if (args.Any(x => x.StartsWith("--url")))
{
try
{
startUrl = args.FirstOrDefault(x => x.StartsWith("--url")).Split("=")[1];
}
catch (Exception)
{
Log.Error("Could not parse the --url parameter. Exiting.");
return;
}
}
var width = 1920;
var height = 1080;
if (args.Any(x => x.StartsWith("--w")))
{
try
{
width = int.Parse(args.FirstOrDefault(x => x.StartsWith("--w")).Split("=")[1]);
}
catch (Exception)
{
Log.Error("Could not parse the --w (width) parameter. Exiting.");
return;
}
}
if (args.Any(x => x.StartsWith("--h")))
{
try
{
height = int.Parse(args.FirstOrDefault(x => x.StartsWith("--h")).Split("=")[1]);
}
catch (Exception)
{
Log.Error("Could not parse the --h (height) parameter. Exiting.");
return;
}
}
// D-06/D-09: --recipe-dir <dir> defaults to the bundle-relative recipes/ path (the publish step
// copies the parent recipes/ into the bundle). --recipe <name> is an EXPLICIT recipe file (D-20:
// an explicit recipe that fails validation MUST fail startup loud).
var recipeDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "recipes");
if (args.Any(x => x.StartsWith("--recipe-dir")))
{
try
{
recipeDir = args.FirstOrDefault(x => x.StartsWith("--recipe-dir")).Split("=")[1];
if (string.IsNullOrWhiteSpace(recipeDir))
{
throw new ArgumentException();
}
}
catch (Exception)
{
Log.Error("Could not parse the --recipe-dir parameter. Exiting.");
return;
}
}
string? explicitRecipeName = null;
// Match --recipe but NOT --recipe-dir (StartsWith would alias them).
if (args.Any(x => x.StartsWith("--recipe") && !x.StartsWith("--recipe-dir")))
{
try
{
explicitRecipeName = args.First(x => x.StartsWith("--recipe") && !x.StartsWith("--recipe-dir")).Split("=")[1];
if (string.IsNullOrWhiteSpace(explicitRecipeName))
{
throw new ArgumentException();
}
}
catch (Exception)
{
Log.Error("Could not parse the --recipe parameter. Exiting.");
return;
}
}
// D-18: --fallback-dir <dir> — the ops-owned fallback-graphic directory (mirrors --recipe-dir),
// bundle-relative default. A policy=slate recipe loads its fallbackAsset (or slate.png) from here;
// a missing/invalid asset degrades LOUDLY to a generated default (D-20b). Parsed with the same
// StartsWith care as --recipe-dir (no other arg aliases "--fallback-dir").
var fallbackDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "fallbacks");
if (args.Any(x => x.StartsWith("--fallback-dir")))
{
try
{
fallbackDir = args.FirstOrDefault(x => x.StartsWith("--fallback-dir")).Split("=")[1];
if (string.IsNullOrWhiteSpace(fallbackDir))
{
throw new ArgumentException();
}
}
catch (Exception)
{
Log.Error("Could not parse the --fallback-dir parameter. Exiting.");
return;
}
}
// ── Two-phase recipe resolution, PHASE 1 (D-16 / Pitfall 4): load + match SYNCHRONOUSLY BEFORE
// Cef.Initialize, because the matched recipe's ExpectsCrossOriginIframes gates the site-isolation
// CefCommandLineArgs — flags added AFTER Cef.Initialize are silently ignored. Phase 2 (register
// the doc-start script) happens inside CefWrapper.InitializeWrapperAsync, before the first Load.
recipeStore = new RecipeStore(new RecipeValidator());
recipeStore.Load(recipeDir);
Recipe? startupRecipe = null;
if (explicitRecipeName is not null)
{
// D-20 (mode 2): an EXPLICIT --recipe that fails parse/validate is a HARD startup failure.
var explicitPath = Path.IsPathRooted(explicitRecipeName)
? explicitRecipeName
: Path.Combine(recipeDir, explicitRecipeName.EndsWith(".json") ? explicitRecipeName : explicitRecipeName + ".json");
if (!recipeStore.TryLoadExplicit(explicitPath, out startupRecipe, out var explicitError))
{
Log.Error("Explicit --recipe failed to load: {Error}. Exiting.", explicitError);
return; // fail startup LOUD (D-20).
}
}
else
{
// No explicit recipe → match the start URL against the loaded dir (D-07: a miss is a
// pass-through with the store's "no recipe for <host>" warning — NOT a crash).
startupRecipe = recipeStore.Match(startUrl);
}
// D-21: capture the launch posture (the value that gates the FROZEN site-iso flags) so runtime
// swaps can reject a posture mismatch.
launchExpectsCrossOriginIframes = startupRecipe?.ExpectsCrossOriginIframes ?? false;
AsyncContext.Run(async delegate
{
var settings = new CefSettings();
if (!Directory.Exists(launchCachePath))
{
Directory.CreateDirectory(launchCachePath);
}
settings.RootCachePath = launchCachePath;
//settings.CefCommandLineArgs.Add("--disable-gpu-sandbox");
//settings.CefCommandLineArgs.Add("--no-sandbox");
//settings.CefCommandLineArgs.Add("--in-process-gpu");
//settings.SetOffScreenRenderingBestPerformanceArgs();
settings.CefCommandLineArgs.Add("autoplay-policy", "no-user-gesture-required");
//settings.CefCommandLineArgs.Add("off-screen-frame-rate", "60");
//settings.CefCommandLineArgs.Add("disable-frame-rate-limit");
// D-13: anti-throttle flags — set UNCONDITIONALLY before Cef.Initialize (also on the smoke
// path) so a backgrounded/occluded offscreen surface does not throttle timers / rAF
// (primarily serves Phase-2 freeze detection; cheap to set now).
settings.CefCommandLineArgs.Add("disable-background-timer-throttling", "1");
settings.CefCommandLineArgs.Add("disable-backgrounding-occluded-windows", "1");
settings.CefCommandLineArgs.Add("disable-renderer-backgrounding", "1");
// D-03: site-isolation-disabling flags — RECIPE-GATED, default OFF. Added BEFORE
// Cef.Initialize (Pitfall 4: late adds are silently ignored) only when the startup recipe
// declares expectsCrossOriginIframes, so injected scripts + the .NET<->JS bridge can reach a
// cross-origin iframe. These flags are process-global + FROZEN for the run (D-21).
if (startupRecipe?.ExpectsCrossOriginIframes == true)
{
settings.CefCommandLineArgs.Add("disable-features", "IsolateOrigins,site-per-process");
settings.CefCommandLineArgs.Add("disable-site-isolation-trials", "1");
}
settings.EnableAudio();
Cef.Initialize(settings);
browserWrapper = new CefWrapper(
width,
height,
startUrl)
{
StartupRecipe = startupRecipe, // D-16: registered in InitializeWrapperAsync BEFORE Load.
};
await browserWrapper.InitializeWrapperAsync();
});
// D-13: provenance stamp on normal startup (CEF is initialized so CefSharpVersion is valid).
AppManagement.LogProvenance();
// D-03 posture log: state site-isolation ON/OFF + the cause (the recipe urlMatch that gated the
// flags, or "(no recipe)"). Field shape (isolation + cause) is designed so a Phase-2 /health can
// surface it without rework. site-isolation is ON when the flags were NOT added (default), OFF
// when the recipe gated them off.
Log.Information(
"POSTURE site-isolation={Iso} cause={Cause}",
launchExpectsCrossOriginIframes ? "OFF" : "ON",
startupRecipe?.UrlMatch ?? "(no recipe)");
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSerilog();
builder.WebHost.UseUrls($"http://*:{port}");
// Add services to the container.
builder.Services.AddAuthorization();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
var settings_T = new NDIlib.send_create_t
{
p_ndi_name = UTF.StringToUtf8(ndiName)
};
Program.NdiSenderPtr = NDIlib.send_create(ref settings_T);
// D-01/D-03/D-27/D-30/D-31 — THE COMPOSITION ROOT (this plan, 02-02, is the SINGLE owner of the
// Program.cs composition edits; 02-04's --fallback-dir edit serializes after via depends_on, D-35).
// The OLD push wiring (the NdiFrameSink subscribing to FrameReady and sending in-call) is
// REPLACED by the single-authority pump topology: source → monitor → pump → NDI.
// - FrameMonitor SUBSCRIBES to browserWrapper.FrameReady (the IFrameSource), copies each
// callback-scoped frame into a wait-free pinned double-buffer, and owns the current-output slot.
// - FramePump is the SOLE send_send_video_v2 caller; it PULLS monitor.SnapshotCurrentOutput()
// on a non-reentrant PeriodicTimer so NDI never stops even during a no-paint freeze (MON-03).
// The `monitor` LOCAL declared here is exactly what Plan 06's /health closes over
// (() => monitor.SnapshotHealth()) and what Plan 05 reaches for Reset() — there is deliberately
// NO CefWrapper.Monitor / browserWrapper.Monitor accessor property (D-27).
// 02-05 (D-28/D-13): inject the IN-PROCESS refresh delegate so the monitor's recovery state machine
// can self-heal a wedged page WITHOUT a CEF type leaking into FrameMonitor. The monitor calls THIS
// delegate single-flight on TRIPPED; Phase 5 would inject () => page.ReloadAsync() instead and reuse
// the whole monitor unchanged. RefreshPage() is void → wrap it as a completed Task.
Func<Task> refreshDelegate = () =>
{
browserWrapper.RefreshPage();
return Task.CompletedTask;
};
var monitor = new FrameMonitor(browserWrapper, refreshDelegate);
var pump = new FramePump(monitor, Program.NdiSenderPtr);
pump.Start();
// 02-04 fallback wiring (D-18/D-20/D-21/D-33) — REPLACES 02-02's seeded never-null placeholder
// in the monitor's fallback slot with the REAL validated/generated FallbackFrame. The provider
// loads the policy=slate asset (the startup recipe's fallbackAsset, else slate.png) from
// --fallback-dir at the ACTUAL --w/--h output geometry + the live alpha convention (byte-
// identical to live frames so the receiver never resyncs at the swap); ANY failure degrades to a
// generated slate/black surfaced LOUDLY. fallbackAssetState is what Plan 06's /health reports.
var fallbackProvider = new FallbackProvider(fallbackDir, width, height, AlphaConvention.Expected);
var fallbackResult = fallbackProvider.LoadOrGenerate(
startupRecipe?.FallbackPolicy, startupRecipe?.FallbackAsset);
monitor.SetFallbackFrame(
fallbackResult.Frame.Bgra, fallbackResult.Frame.Width, fallbackResult.Frame.Height);
var fallbackAssetState = fallbackResult.State;
Log.Information(
"FALLBACK ready — policy={Policy} asset={Asset} state={State} geom={W}x{H}",
startupRecipe?.FallbackPolicy ?? "slate",
fallbackResult.Sought,
fallbackAssetState == FallbackAssetState.Configured ? "configured" : "generated-default",
width, height);
// 02-05 (D-26/D-28): apply the startup recipe's timing/expectMotion to the state machine, then wire
// the swap-reset. On EVERY successful SetUrlAsync/SwapRecipeAsync the wrapper raises RecipeSwapped;
// the composition root (NOT CefWrapper — it holds no FrameMonitor reference) resets the monitor's
// stale detection/recovery state AND reloads the fallback asset for the new recipe's policy, so the
// swapped page is classified fresh and false-trips on neither the old dHash window nor the old
// fallback (Pitfall P-5). Then start the non-reentrant 5Hz sampler (D-06/D-31).
monitor.ApplyRecipe(startupRecipe);
browserWrapper.RecipeSwapped += swapped =>
{
monitor.Reset(swapped);
var swappedFallback = fallbackProvider.LoadOrGenerate(swapped?.FallbackPolicy, swapped?.FallbackAsset);
monitor.SetFallbackFrame(
swappedFallback.Frame.Bgra, swappedFallback.Frame.Width, swappedFallback.Frame.Height);
fallbackAssetState = swappedFallback.State;
Log.Information(
"MONITOR reset on swap — recipe expectMotion={Motion} fallback={State}",
swapped?.ExpectMotion ?? false,
swappedFallback.State == FallbackAssetState.Configured ? "configured" : "generated-default");
};
monitor.StartSampling();
// 02-06 (D-23/D-27): wire the CROSS-COMPONENT /health fields the monitor does not own — the pump's
// FramesSent counter, a no-secret recipe urlMatch summary (CurrentRecipe.UrlMatch only — NOT the full
// sensitive URL, T-2-06-1), the Plan-04 configured-vs-generated-default fallback asset state, the P1
// D-03 startup isolation posture string (OFF/ON, same value the POSTURE log emitted above), and the
// process start for uptimeSec. SnapshotHealth() then closes over the `monitor` local with NO args
// (D-27 — no browserWrapper.Monitor accessor). All plain values/delegates: the CEF-agnostic seam holds.
var isolationPostureStr = launchExpectsCrossOriginIframes ? "OFF" : "ON";
var healthProcessStart = System.Diagnostics.Process.GetCurrentProcess().StartTime.ToUniversalTime();
monitor.WireHealth(
() => pump.FramesSent,
() => browserWrapper.CurrentRecipe?.UrlMatch,
() => fallbackAssetState,
isolationPostureStr,
healthProcessStart);
// 03.1 MOUNT-RECOVERY (targetpresent-cold-flake cycle 3): the NET-NEW production recovery — the SAME
// control-layer recovery the --accuweather-validate gate runs (RecoverTargetMountAsync). On the
// ~15-20% cold flake the SPA never mounts the recipe's targetSelector (#cityRadar) on startup, so
// targetPresent never latches and the radar never appears on air (the slate would stick with NO
// recovery — production previously read targetPresent ONLY at /health, never for recovery). This
// fire-and-forget watcher (CEF-agnostic — it lives at the composition root, NOT in FrameMonitor)
// re-navigates via the D-19 SetUrlAsync soft-remount when the target is absent past the threshold,
// bounded + LOUD, while FrameMonitor holds the slate. No-op when the startup recipe has no
// targetSelector (pass-through pages). Cancelled at shutdown so it never outlives the host.
var mountRecoveryCts = new System.Threading.CancellationTokenSource();
_ = Task.Run(() => RecoverTargetMountAsync(browserWrapper, startUrl, startupRecipe, mountRecoveryCts.Token));
var capabilitiesXml = $$"""<ndi_capabilities ntk_kvm="true" />""";
capabilitiesXml += "\0";
var capabilitiesPtr = UTF.StringToUtf8(capabilitiesXml);
var metaframe = new NDIlib.metadata_frame_t()
{
p_data = capabilitiesPtr
};
NDIlib.send_add_connection_metadata(NdiSenderPtr, ref metaframe);
Marshal.FreeHGlobal(capabilitiesPtr);
var running = true;
var thread = new Thread(() =>
{
var metadata = new NDIlib.metadata_frame_t();
var x = 0.0f;
var y = 0.0f;
while (running)
{
var result = NDIlib.send_capture(NdiSenderPtr, ref metadata, 1000);
if (result == NDIlib.frame_type_e.frame_type_none)
{
continue;
}
else if (result == NDIlib.frame_type_e.frame_type_metadata)
{
var metadataConverted = UTF.Utf8ToString(metadata.p_data);
if(metadataConverted.StartsWith("<ndi_kvm u=\""))
{
metadataConverted = metadataConverted.Replace("<ndi_kvm u=\"", "");
metadataConverted = metadataConverted.Replace("\"/>", "");
try
{
var binary = Convert.FromBase64String(metadataConverted);
var opcode = binary[0];
if(opcode == 0x03)
{
x = BitConverter.ToSingle(binary, 1);
y = BitConverter.ToSingle(binary, 5);
}
else if(opcode == 0x04)
{
// Mouse Left Down
var screenX = (int)(x * width);
var screenY = (int)(y * height);
browserWrapper.Click(screenX, screenY);
}
else if(opcode == 0x07)
{
// Mouse Left Up
}
}
catch
{
}
}
Log.Logger.Warning("Got metadata: " + metadataConverted);
NDIlib.send_free_metadata(NdiSenderPtr, ref metadata);
}
}
});
thread.Start();
app.MapPost("/seturl", async (HttpContext httpContext, GoToUrlModel url) =>
{
// D-16: await SetUrlAsync (the synchronous void SetUrl is gone). Resolve the recipe for the
// new URL via the store so a navigation that crosses into a recipe-governed host re-registers
// the doc-start script BEFORE Load. D-21: reject a swap whose isolation posture differs from
// launch (site-iso is frozen at Cef.Initialize) — fail loud, do not silently mis-render.
var nextRecipe = recipeStore.Match(url.Url);
if (nextRecipe is not null
&& !RecipeStore.PostureMatches(launchExpectsCrossOriginIframes, nextRecipe))
{
return Results.BadRequest(new
{
error = "posture-mismatch",
message = "The recipe matching this URL requires a different site-isolation posture than launch. "
+ "Site-isolation flags are frozen at process start; relaunch with this recipe to apply it.",
launchExpectsCrossOriginIframes,
requiredExpectsCrossOriginIframes = nextRecipe.ExpectsCrossOriginIframes,
});
}
await browserWrapper.SetUrlAsync(url.Url, nextRecipe);
return Results.Ok();
})
.WithOpenApi();
// D-06: /recipe GET returns the current recipe driving injection (null = pass-through).
app.MapGet("/recipe", () => browserWrapper.CurrentRecipe)
.WithOpenApi();
// D-06/D-12/D-18/D-21/D-04: /recipe POST reads the RAW body (NOT a direct RecipeDto bind, which
// would silently drop unknown fields — D-18), validates via the shared raw-JSON validator,
// rejects a posture mismatch (D-21), then swaps fail-closed (D-04). Never partial-applies (D-12).
app.MapPost("/recipe", async (HttpContext ctx) =>
{
string rawJson;
using (var reader = new StreamReader(ctx.Request.Body))
{
rawJson = await reader.ReadToEndAsync();
}
// recipeStore is guaranteed non-null here (set in Main before the host runs). Use the shared
// raw-JSON validator (the SAME path the store uses on file-load) so the two surfaces cannot drift.
var (ok, recipe, errors) = new RecipeValidator().TryNormalize(rawJson);
if (!ok || recipe is null)
{
// D-12/D-18: structured errors, never a partial apply.
return Results.BadRequest(new { error = "invalid-recipe", errors });
}
// D-21: a swap whose required posture differs from the FROZEN launch posture is rejected with
// a structured relaunch error — the current recipe is left unchanged.
if (!RecipeStore.PostureMatches(launchExpectsCrossOriginIframes, recipe))
{
return Results.BadRequest(new
{
error = "posture-mismatch",
message = "This recipe requires a different site-isolation posture than launch. "
+ "Site-isolation flags are frozen at process start; relaunch with this recipe to apply it.",
launchExpectsCrossOriginIframes,
requiredExpectsCrossOriginIframes = recipe.ExpectsCrossOriginIframes,
});
}
// D-04/D-17: fail-closed swap (remove-by-id → re-add; RecreateBrowserAsync on a Remove failure).
await browserWrapper.SwapRecipeAsync(recipe);
return Results.Ok();
})
.WithOpenApi();
app.MapGet("/scroll/{increment}", (int increment) =>
{
browserWrapper.ScrollBy(increment);
}).WithOpenApi();
app.MapGet("/click/{x}/{y}", (int x, int y) =>
{
browserWrapper.Click(x, y);
}).WithOpenApi();
app.MapPost("/keystroke", (SendKeystrokeModel model) =>
{
browserWrapper.SendKeystrokes(model);
}).WithOpenApi();
app.MapGet("/type/{toType}", (string toType) =>
{
browserWrapper.SendKeystrokes(new SendKeystrokeModel
{
ToSend = toType
});
}).WithOpenApi();
app.MapGet("/refresh", () =>
{
browserWrapper.RefreshPage();
}).WithOpenApi();
// 02-06 (D-23/D-24/D-25/D-27/D-32 — MON-05): the rich READ-ONLY liveness contract. A GET with NO
// input + NO side effects (D-25 — it cannot mutate monitor or render state, T-2-06-3), mapped here
// BEFORE app.Run() mirroring the /recipe GET above. It closes over the composition-root-local
// `monitor` DIRECTLY (D-27 — there is deliberately NO browserWrapper.Monitor / CefWrapper.Monitor
// accessor); SnapshotHealth() maps the already-instrumented monitor/pump/fallback/posture state.
// D-24 freeze-vs-dead: a running-but-frozen process answers 200 here with status=tripped/recovering
// + a high lastPaintAgeMs; a DEAD process simply fails to connect, so the deferred Phase-4 watchdog
// distinguishes the two without a re-instrumentation pass — and never needlessly restarts a
// recovering process. D-32: status/source serialize as STRING tokens (recovery-exhausted/fallback),
// never integers (the KebabCaseStringEnumConverter on the enums), so the watchdog reads the contract.
// 03-03 (D-24/D-06/D-03): the /health endpoint COMPOSES the three JS proof markers via a SIBLING CDP
// Runtime.evaluate probe — reusing the existing EvaluateScriptAsync machinery (ReadMainBoolAsync,
// the PollMainFlagAsync shape; NO new CDP code) — reading window.__xpnTargetPresent /
// __xpnConsentDismissed / __xpnPlayStarted, then OVERLAYING them onto the monitor.SnapshotHealth()
// result with `with` before returning. This keeps the proof-marker readback OUT of FrameMonitor (D-24:
// FrameMonitor stays IFrameSource-only and never reads page JS); the markers are composed HERE per the
// cross-component WireHealth precedent ("wire the /health fields the monitor does not own"). The probe
// is read-only with a short timeout — a read miss leaves the field null (degraded observability, never
// a 500). These markers are OBSERVABILITY ONLY: none is wired into UseFallback (D-14 / Pitfall 5 —
// targetPresent=false strobes on SPA re-render). T-3-09: operational booleans only, no URL/content.
app.MapGet("/health", async () =>
{
var snapshot = monitor.SnapshotHealth();
// Sibling probe: read the page proof markers off the MAIN frame (each null on a miss).
var targetPresent = await ReadMainBoolAsync(browserWrapper, "__xpnTargetPresent");
var consentDismissed = await ReadMainBoolAsync(browserWrapper, "__xpnConsentDismissed");
var playStarted = await ReadMainBoolAsync(browserWrapper, "__xpnPlayStarted");
// 03.1 (INJ-05/D-06): the chrome-hide proof marker — null-on-miss (D-18). Normal-server observable
// only; the --accuweather-validate gate proves it in-process via PollMainFlagAsync (D-17).
var chromeHidden = await ReadMainBoolAsync(browserWrapper, "__xpnChromeHidden");
return snapshot with
{
TargetPresent = targetPresent,
ConsentDismissed = consentDismissed,
PlayStarted = playStarted,
ChromeHidden = chromeHidden,
};
}).WithOpenApi();
app.Run();
// 03.1 mount-recovery: stop the startup target-mount watcher (no-op if it already returned).
mountRecoveryCts.Cancel();
running = false;
thread.Join();
// 02-05 (D-26): clean shutdown — stop the pump + the monitor's 5Hz sampler (Dispose stops the timer
// first, then unsubscribes FrameReady) BEFORE the wrapper is disposed, so no sampler tick or send
// fires against a torn-down browser. Mirrors the --smoke teardown.
pump.StopAsync().GetAwaiter().GetResult();
monitor.Dispose();
browserWrapper.Dispose();
if (Directory.Exists(launchCachePath))
{
try
{
Directory.Delete(launchCachePath, true);
}
catch
{
}
}
}
/// <summary>
/// D-05/D-15: additive in-process --smoke self-check. Initializes CEF, loads a tiny committed
/// local HTML (or --smoke=<url>), captures one NON-BLANK OnPaint via the conditional
/// CefWrapper one-shot latch, creates the NDI sender, sends EXACTLY ONE frame, logs the
/// provenance stamp, prints "SMOKE OK" and exits 0 — WITHOUT starting the ASP.NET host or the
/// KVM thread. A hard ~20s timeout (D-15c) makes a never-painting init fail fast (non-zero).
/// The normal --url= path never reaches here, so it stays byte-for-byte upstream (D-05).
/// </summary>
private static void RunSmoke(string[] args, string launchCachePath)
{
const int SmokeTimeoutSeconds = 20;
// D-15a: smoke defaults — no interactive prompts, no stdin dependency.
var ndiName = "XPRESSION-SMOKE";
var width = 1920;
var height = 1080;
// --smoke or --smoke=<url>. Default to the committed local smoke HTML beside the exe.
var smokeArg = args.FirstOrDefault(x => x.StartsWith("--smoke")) ?? "--smoke";
string smokeUrl;
var eq = smokeArg.IndexOf('=');
if (eq >= 0 && eq < smokeArg.Length - 1)
{
smokeUrl = smokeArg.Substring(eq + 1);
}
else
{
var localHtml = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "smoke", "smoke.html");
smokeUrl = new Uri(localHtml).AbsoluteUri; // file:///.../smoke/smoke.html
}
Log.Information("SMOKE starting — url={SmokeUrl} timeout={Timeout}s", smokeUrl, SmokeTimeoutSeconds);
var exitCode = 1; // default to failure; only set 0 on the success path.
try
{
AsyncContext.Run(async delegate
{
var settings = new CefSettings();
if (!Directory.Exists(launchCachePath))
{
Directory.CreateDirectory(launchCachePath);
}
settings.RootCachePath = launchCachePath;
settings.CefCommandLineArgs.Add("autoplay-policy", "no-user-gesture-required");
// D-13: anti-throttle flags — kept IN SYNC with the normal path (unconditional).
settings.CefCommandLineArgs.Add("disable-background-timer-throttling", "1");
settings.CefCommandLineArgs.Add("disable-backgrounding-occluded-windows", "1");
settings.CefCommandLineArgs.Add("disable-renderer-backgrounding", "1");
settings.EnableAudio();
Cef.Initialize(settings);
// D-13: provenance stamp on the smoke path too (CEF now initialized).
AppManagement.LogProvenance();
browserWrapper = new CefWrapper(width, height, smokeUrl)
{
SmokeMode = true, // D-15b: arm the one-shot latch.
};
// Behavior 2: create the NDI sender; nint.Zero ⇒ wrong/missing NDI DLL ⇒ fail.
// Created BEFORE InitializeWrapperAsync so the sink can be constructed with a valid
// sender ptr (D-26) and subscribed BEFORE Paint is wired — so the first paint reaches
// the sink (no lost-first-frame race with the smoke latch).
var settings_T = new NDIlib.send_create_t
{
p_ndi_name = UTF.StringToUtf8(ndiName)
};
Program.NdiSenderPtr = NDIlib.send_create(ref settings_T);
if (Program.NdiSenderPtr == nint.Zero)
{
Log.Error("SMOKE FAILED — NDIlib.send_create returned nint.Zero (NDI native DLL missing or wrong).");
return; // exitCode stays 1
}
// D-01/D-27/D-30/D-31 — the SAME single-authority composition root as the interactive
// path, re-proving --smoke under the pump rewrite (D-01 / CONTEXT calibration constraint 5).
// FrameMonitor subscribes to browserWrapper.FrameReady BEFORE InitializeWrapperAsync wires
// Paint, so the first paint is copied; FramePump pulls + sends on cadence. The wrapper's
// one-shot SmokeMode latch still gates "one non-blank frame painted" (it fires inside
// OnBrowserPaint after FrameReady?.Invoke, regardless of the subscriber), so the smoke
// success signal (SmokeFrameSent) is unchanged — but the actual NDI send now flows
// source → monitor → pump, re-proving the keyable BGRA hot path end-to-end.
var monitor = new FrameMonitor(browserWrapper);
var pump = new FramePump(monitor, Program.NdiSenderPtr);
pump.Start();
await browserWrapper.InitializeWrapperAsync();
// Behaviors 1/3: wait for EXACTLY ONE non-blank frame, bounded by the hard timeout.
var sentTask = browserWrapper.SmokeFrameSent;
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(SmokeTimeoutSeconds));
var winner = await Task.WhenAny(sentTask, timeoutTask);
if (winner != sentTask)
{
Log.Error("SMOKE FAILED — no non-blank frame within {Timeout}s (never-painting init).", SmokeTimeoutSeconds);
return; // exitCode stays 1
}
Log.Information("SMOKE OK — one non-blank BGRA frame sent through the vendored NDI DLL.");
Console.WriteLine("SMOKE OK");
exitCode = 0;
// Clean teardown of the single-authority pump before the wrapper is disposed in finally.
await pump.StopAsync();
monitor.Dispose();
});
}
catch (Exception ex)
{
Log.Error("SMOKE FAILED — exception during smoke: {@ex}", ex);
exitCode = 1;
}
finally
{
try
{
browserWrapper?.Dispose();
}
catch
{
}
if (Directory.Exists(launchCachePath))
{
try
{
Directory.Delete(launchCachePath, true);
}
catch
{
}
}
}
Log.CloseAndFlush();
Environment.Exit(exitCode);
}
/// <summary>
/// 03-04 D-07/D-24/D-26 (VAL-04): the idle-gap capture gate — a LOGGING SIDE-CHANNEL on the live
/// pipeline used to data-drive the freeze backstop (<c>freezeTimeoutMs</c>). Unlike the one-shot
/// gates above, this is a LONG-RUNNING live gate: it stands up the SAME single-authority composition
/// root the interactive <c>--url=</c>/<c>--recipe</c> path uses — NDI sender → <see cref="CefWrapper"/>
/// → <see cref="FrameMonitor"/> → <see cref="FramePump"/> — against the AccuWeather recipe
/// (<c>expectMotion=true</c>), then logs ONE CSV line per sample tick for a bounded duration.
/// <para>
/// The capture is purely a tap on values <see cref="FrameMonitor.Classify"/> already produces, exposed
/// via the plan-03 read-only <see cref="FrameMonitor.SampleObserved"/> telemetry seam (D-24): NO new
/// timer (it rides the existing 5 Hz <c>OnSampleTick</c>, <c>SampleIntervalMs=200</c>), NO recomputed
/// detector, NO reach into private FrameMonitor fields. The six columns are
/// <c>tMs</c> (monotonic from the first sample), <c>dHash</c>, <c>hammingFromPrev</c> (all from the
/// snapshot), <c>lastPaintAgeMs</c> (read read-only off <see cref="FrameMonitor.SnapshotHealth"/>),
/// <c>beaconState</c> (the snapshot's 3-state liveness — ground truth; analysis gates the idle-gap on
/// <c>beaconState=true</c>), and <c>targetPresent</c> (read via the SAME sibling-probe
/// <see cref="ReadMainBoolAsync"/> of <c>window.__xpnTargetPresent</c> the <c>/health</c> path uses —
/// NOT a FrameMonitor field; FrameMonitor stays IFrameSource-only, D-24).
/// </para>
/// <para>
/// CRITICAL: NDI is wired exactly like the interactive path. <see cref="CefWrapper"/>'s
/// <c>OnBrowserPaint</c> early-returns at <c>if (Program.NdiSenderPtr == nint.Zero) return;</c> BEFORE
/// raising <c>FrameReady</c>, which is the monitor's ONLY frame feed — a capture that skips NDI would
/// starve the monitor and produce an empty log. Runs for <c>--duration</c> seconds then flushes the log
/// + <see cref="Environment.Exit"/>(0). Bounded + self-exiting (the operator runs it under a timeout).
/// </para>
/// </summary>
private static void RunAccuWeatherCapture(string[] args, string launchCachePath)
{
var width = 1920;
var height = 1080;
const int DefaultDurationSeconds = 300;
// ── arg parsing ───────────────────────────────────────────────────────────────────────────
// --recipe=<path-or-name> (mirrors the normal path's --recipe; NOT aliasing --recipe-dir).
string? recipeName = null;
var recipeArg = args.FirstOrDefault(x => x.StartsWith("--recipe") && !x.StartsWith("--recipe-dir"));
if (recipeArg is not null)
{
var eqR = recipeArg.IndexOf('=');
if (eqR >= 0 && eqR < recipeArg.Length - 1)
{
recipeName = recipeArg.Substring(eqR + 1);
}
}
if (string.IsNullOrWhiteSpace(recipeName))
{
Console.WriteLine("ACCUWEATHER-CAPTURE FAIL: --recipe=<path-to-accuweather.json> is required.");
Log.Error("ACCUWEATHER-CAPTURE FAIL — missing --recipe.");
Log.CloseAndFlush();
Environment.Exit(1);
}
// --duration=<seconds> (default 300 = 5 min); bounded + self-exiting.
var durationSeconds = DefaultDurationSeconds;
var durationArg = args.FirstOrDefault(x => x.StartsWith("--duration"));
if (durationArg is not null)
{
var eqD = durationArg.IndexOf('=');
if (eqD < 0 || !int.TryParse(durationArg.Substring(eqD + 1), out durationSeconds) || durationSeconds <= 0)
{
Console.WriteLine("ACCUWEATHER-CAPTURE FAIL: --duration must be a positive integer (seconds).");
Log.Error("ACCUWEATHER-CAPTURE FAIL — bad --duration.");
Log.CloseAndFlush();
Environment.Exit(1);
}
}
// --out=<path> override; default to a Windows-local path the operator (and WSL via /mnt/c) can
// retrieve. The dir is created if absent.
string outPath;
var outArg = args.FirstOrDefault(x => x.StartsWith("--out"));
if (outArg is not null && outArg.IndexOf('=') is var eqO && eqO >= 0 && eqO < outArg.Length - 1)
{
outPath = outArg.Substring(eqO + 1);
}
else
{
var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss");
outPath = Path.Combine(@"C:\temp", $"accuweather-capture-{stamp}.csv");
}
// Resolve the recipe SYNCHRONOUSLY before Cef.Initialize — its ExpectsCrossOriginIframes gates the
// site-isolation CefCommandLineArgs (Pitfall 4: late adds are silently ignored), exactly as the
// normal path does. An explicit recipe that fails validation is a HARD failure (D-20).
var recipeDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "recipes");
var store = new RecipeStore(new RecipeValidator());
var recipePath = Path.IsPathRooted(recipeName)
? recipeName
: Path.Combine(recipeDir, recipeName!.EndsWith(".json") ? recipeName! : recipeName! + ".json");
if (!store.TryLoadExplicit(recipePath, out var recipe, out var recipeError) || recipe is null)
{
Console.WriteLine($"ACCUWEATHER-CAPTURE FAIL: recipe failed to load: {recipeError}");
Log.Error("ACCUWEATHER-CAPTURE FAIL — recipe load: {Error}", recipeError);
Log.CloseAndFlush();
Environment.Exit(1);
}