@trillboards/edge-sdk 0.2.135 → 0.2.136

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.
@@ -84,17 +84,6 @@ docker pull ghcr.io/trillboards/edge-sdk:openvino
84
84
  docker pull ghcr.io/trillboards/edge-sdk:auto-sense # BYO-CCTV connector
85
85
  ```
86
86
 
87
- **AWS ECR (private, for production fleet):**
88
-
89
- ```bash
90
- aws ecr get-login-password --region us-east-1 | \
91
- docker login --username AWS --password-stdin 975050306809.dkr.ecr.us-east-1.amazonaws.com
92
-
93
- docker pull 975050306809.dkr.ecr.us-east-1.amazonaws.com/trillboards-edge-sdk:latest
94
- docker pull 975050306809.dkr.ecr.us-east-1.amazonaws.com/trillboards-edge-sdk:cuda
95
- docker pull 975050306809.dkr.ecr.us-east-1.amazonaws.com/trillboards-edge-sdk:openvino
96
- ```
97
-
98
87
  ## Quick Start
99
88
 
100
89
  ```bash
@@ -46,7 +46,13 @@ delivered **only by pairing** — it is never baked into the `.pkg`.
46
46
  CurrentUserHomeDirectory`.)
47
47
  3. The installer installs and verifies the connector and included local AI
48
48
  model. Only then does **Sense Anything Connector** open automatically. Select
49
- **Start setup**. The app opens the signed-in Sense Anything camera wizard,
49
+ **Start setup**. Follow **Welcome → Account → Account setup → Plan & Payment**.
50
+ Sign in with Google or email, choose the account if you manage more than one,
51
+ accept the current agreements, then choose a plan
52
+ and finish secure Stripe checkout. Cancelling checkout preserves account setup;
53
+ use **Back to plans** in the Connector to retry. Existing subscribed accounts
54
+ resume at recorder setup. The Connector checks subscription access automatically
55
+ before opening the signed-in Sense Anything camera wizard,
50
56
  finds recorders on the local network, and validates each channel before any
51
57
  camera screens are created. Black, disconnected, and unreadable inputs are
52
58
  ignored. After the working cameras are saved, the already-installed local AI
@@ -48,8 +48,33 @@ static BOOL acquireSetupInstanceLock(void) {
48
48
  @property(nonatomic, strong) NSButton *closeButton;
49
49
  @property(nonatomic, strong) NSButton *updateButton;
50
50
  @property(nonatomic, strong) NSTextField *updateStatus;
51
+ @property(nonatomic, strong) NSTextField *privacyLabel;
51
52
  @property(nonatomic, strong) NSArray<NSTextField *> *stepLabels;
53
+ @property(nonatomic, strong) NSArray<NSView *> *onboardingPanels;
54
+ @property(nonatomic, strong) NSView *accountPanel;
55
+ @property(nonatomic, strong) NSView *termsPanel;
56
+ @property(nonatomic, strong) NSView *chooseAccountPanel;
57
+ @property(nonatomic, strong) NSPopUpButton *accountPicker;
58
+ @property(nonatomic, strong) NSButton *chooseAccountSubmit;
59
+ @property(nonatomic, strong) NSView *planPanel;
60
+ @property(nonatomic, strong) NSTextField *nameInput;
61
+ @property(nonatomic, strong) NSTextField *emailInput;
62
+ @property(nonatomic, strong) NSSecureTextField *passwordInput;
63
+ @property(nonatomic, strong) NSTextField *nameLabel;
64
+ @property(nonatomic, strong) NSTextField *passwordLabel;
65
+ @property(nonatomic, strong) NSButton *accountSubmit;
66
+ @property(nonatomic, strong) NSButton *authModeButton;
67
+ @property(nonatomic, strong) NSButton *termsCheck;
68
+ @property(nonatomic, strong) NSTextField *termsVersion;
69
+ @property(nonatomic, strong) NSButton *termsSubmit;
70
+ @property(nonatomic, strong) NSButton *monthlyRadio;
71
+ @property(nonatomic, strong) NSButton *annualRadio;
72
+ @property(nonatomic, strong) NSButton *standardRadio;
73
+ @property(nonatomic, strong) NSButton *multiRadio;
74
+ @property(nonatomic, strong) NSTextField *standardPlan;
75
+ @property(nonatomic, strong) NSTextField *multiPlan;
52
76
  @property(nonatomic, strong) NSTimer *timer;
77
+ @property(nonatomic, strong) NSFileHandle *setupInput;
53
78
  @property(nonatomic, assign) pid_t pairProcessIdentifier;
54
79
  @property(nonatomic, strong) dispatch_source_t pairProcessSource;
55
80
  @property(nonatomic, copy) NSString *lastStage;
@@ -57,6 +82,7 @@ static BOOL acquireSetupInstanceLock(void) {
57
82
  @property(nonatomic, copy) NSString *requiredUpdateURL;
58
83
  @property(nonatomic, assign) BOOL setupStarted;
59
84
  @property(nonatomic, assign) BOOL terminating;
85
+ @property(nonatomic, assign) BOOL signingIn;
60
86
  @end
61
87
 
62
88
  @implementation TrillboardsSetupDelegate
@@ -74,6 +100,24 @@ static BOOL acquireSetupInstanceLock(void) {
74
100
  return field;
75
101
  }
76
102
 
103
+ - (NSTextField *)inputWithFrame:(NSRect)frame placeholder:(NSString *)placeholder secure:(BOOL)secure {
104
+ NSTextField *field = secure
105
+ ? [[NSSecureTextField alloc] initWithFrame:frame]
106
+ : [[NSTextField alloc] initWithFrame:frame];
107
+ field.placeholderString = placeholder;
108
+ field.font = [NSFont systemFontOfSize:14 weight:NSFontWeightRegular];
109
+ return field;
110
+ }
111
+
112
+ - (NSButton *)panelButton:(NSString *)title frame:(NSRect)frame action:(SEL)action {
113
+ NSButton *button = [[NSButton alloc] initWithFrame:frame];
114
+ button.title = title;
115
+ button.bezelStyle = NSBezelStyleRounded;
116
+ button.target = self;
117
+ button.action = action;
118
+ return button;
119
+ }
120
+
77
121
  - (void)applicationDidFinishLaunching:(NSNotification *)notification {
78
122
  (void)notification;
79
123
  self.lastStage = @"welcome";
@@ -118,6 +162,10 @@ static BOOL acquireSetupInstanceLock(void) {
118
162
  - (void)stopPairProcess {
119
163
  pid_t pid = self.pairProcessIdentifier;
120
164
  self.pairProcessIdentifier = 0;
165
+ if (self.setupInput) {
166
+ [self.setupInput closeFile];
167
+ self.setupInput = nil;
168
+ }
121
169
  if (self.pairProcessSource) {
122
170
  dispatch_source_cancel(self.pairProcessSource);
123
171
  self.pairProcessSource = nil;
@@ -150,12 +198,22 @@ static BOOL acquireSetupInstanceLock(void) {
150
198
  return NO;
151
199
  }
152
200
 
201
+ int inputPipe[2];
202
+ if (pipe(inputPipe) != 0) {
203
+ close(logFd);
204
+ if (error) *error = [NSError errorWithDomain:NSPOSIXErrorDomain code:errno userInfo:nil];
205
+ return NO;
206
+ }
207
+
153
208
  posix_spawn_file_actions_t fileActions;
154
209
  posix_spawnattr_t attributes;
155
210
  posix_spawn_file_actions_init(&fileActions);
156
211
  posix_spawn_file_actions_adddup2(&fileActions, logFd, STDOUT_FILENO);
157
212
  posix_spawn_file_actions_adddup2(&fileActions, logFd, STDERR_FILENO);
213
+ posix_spawn_file_actions_adddup2(&fileActions, inputPipe[0], STDIN_FILENO);
158
214
  posix_spawn_file_actions_addclose(&fileActions, logFd);
215
+ posix_spawn_file_actions_addclose(&fileActions, inputPipe[0]);
216
+ posix_spawn_file_actions_addclose(&fileActions, inputPipe[1]);
159
217
  posix_spawnattr_init(&attributes);
160
218
  posix_spawnattr_setflags(&attributes, POSIX_SPAWN_SETPGROUP);
161
219
  posix_spawnattr_setpgroup(&attributes, 0);
@@ -179,11 +237,14 @@ static BOOL acquireSetupInstanceLock(void) {
179
237
  posix_spawnattr_destroy(&attributes);
180
238
  posix_spawn_file_actions_destroy(&fileActions);
181
239
  close(logFd);
240
+ close(inputPipe[0]);
182
241
  if (spawnError != 0) {
242
+ close(inputPipe[1]);
183
243
  if (error) *error = [NSError errorWithDomain:NSPOSIXErrorDomain code:spawnError userInfo:nil];
184
244
  return NO;
185
245
  }
186
246
 
247
+ self.setupInput = [[NSFileHandle alloc] initWithFileDescriptor:inputPipe[1] closeOnDealloc:YES];
187
248
  self.pairProcessIdentifier = pid;
188
249
  dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_PROC,
189
250
  (uintptr_t)pid,
@@ -203,6 +264,10 @@ static BOOL acquireSetupInstanceLock(void) {
203
264
  int status = 0;
204
265
  pid_t waited = waitpid(pid, &status, 0);
205
266
  self.pairProcessIdentifier = 0;
267
+ if (self.setupInput) {
268
+ [self.setupInput closeFile];
269
+ self.setupInput = nil;
270
+ }
206
271
  if (self.pairProcessSource) {
207
272
  dispatch_source_cancel(self.pairProcessSource);
208
273
  self.pairProcessSource = nil;
@@ -241,7 +306,7 @@ static BOOL acquireSetupInstanceLock(void) {
241
306
  NSTextField *brand = [self label:@"Sense Anything" size:16 weight:NSFontWeightSemibold frame:NSMakeRect(24, 480, 145, 28)];
242
307
  [sidebar addSubview:brand];
243
308
 
244
- NSArray<NSString *> *names = @[@"Welcome", @"Account", @"Recorder", @"Cameras", @"Local AI", @"Ready"];
309
+ NSArray<NSString *> *names = @[@"Welcome", @"Account", @"Account setup", @"Plan & Payment", @"Recorder", @"Cameras", @"Local AI", @"Ready"];
245
310
  NSMutableArray<NSTextField *> *labels = [NSMutableArray array];
246
311
  for (NSUInteger index = 0; index < names.count; index++) {
247
312
  NSTextField *step = [self label:[NSString stringWithFormat:@"%lu %@", index + 1, names[index]]
@@ -300,12 +365,12 @@ static BOOL acquireSetupInstanceLock(void) {
300
365
  self.updateStatus.textColor = NSColor.secondaryLabelColor;
301
366
  [root addSubview:self.updateStatus];
302
367
 
303
- NSTextField *privacy = [self label:@"Camera credentials stay on this computer. Cloud inference is off by default."
368
+ self.privacyLabel = [self label:@"Camera credentials stay on this computer. Cloud inference is off by default."
304
369
  size:12
305
370
  weight:NSFontWeightRegular
306
371
  frame:NSMakeRect(236, 34, 300, 42)];
307
- privacy.textColor = NSColor.secondaryLabelColor;
308
- [root addSubview:privacy];
372
+ self.privacyLabel.textColor = NSColor.secondaryLabelColor;
373
+ [root addSubview:self.privacyLabel];
309
374
 
310
375
  self.actionButton = [[NSButton alloc] initWithFrame:NSMakeRect(562, 36, 139, 40)];
311
376
  self.actionButton.title = @"Start setup";
@@ -321,9 +386,229 @@ static BOOL acquireSetupInstanceLock(void) {
321
386
  self.closeButton.action = @selector(closePressed);
322
387
  self.closeButton.hidden = YES;
323
388
  [root addSubview:self.closeButton];
389
+
390
+ // Native onboarding panels. Provider-owned Google and Stripe UI opens in
391
+ // the system browser; every product-owned choice remains in this window.
392
+ self.accountPanel = [[NSView alloc] initWithFrame:NSMakeRect(236, 78, 465, 220)];
393
+ NSButton *google = [self panelButton:@"Continue with Google"
394
+ frame:NSMakeRect(0, 178, 465, 34)
395
+ action:@selector(googlePressed)];
396
+ [self.accountPanel addSubview:google];
397
+ NSTextField *orEmail = [self label:@"or use email" size:11 weight:NSFontWeightRegular frame:NSMakeRect(0, 153, 465, 18)];
398
+ orEmail.alignment = NSTextAlignmentCenter;
399
+ orEmail.textColor = NSColor.secondaryLabelColor;
400
+ [self.accountPanel addSubview:orEmail];
401
+ self.nameLabel = [self label:@"Full name" size:11 weight:NSFontWeightRegular frame:NSMakeRect(0, 132, 220, 17)];
402
+ [self.accountPanel addSubview:self.nameLabel];
403
+ self.nameInput = [self inputWithFrame:NSMakeRect(0, 103, 220, 28) placeholder:@"Maya Chen" secure:NO];
404
+ [self.accountPanel addSubview:self.nameInput];
405
+ NSTextField *emailLabel = [self label:@"Work email" size:11 weight:NSFontWeightRegular frame:NSMakeRect(235, 132, 230, 17)];
406
+ [self.accountPanel addSubview:emailLabel];
407
+ self.emailInput = [self inputWithFrame:NSMakeRect(235, 103, 230, 28) placeholder:@"maya@yourshop.com" secure:NO];
408
+ [self.accountPanel addSubview:self.emailInput];
409
+ self.passwordLabel = [self label:@"Create password" size:11 weight:NSFontWeightRegular frame:NSMakeRect(0, 78, 220, 17)];
410
+ [self.accountPanel addSubview:self.passwordLabel];
411
+ self.passwordInput = (NSSecureTextField *)[self inputWithFrame:NSMakeRect(0, 49, 220, 28) placeholder:@"At least 8 characters" secure:YES];
412
+ [self.accountPanel addSubview:self.passwordInput];
413
+ self.authModeButton = [self panelButton:@"Already have an account? Sign in"
414
+ frame:NSMakeRect(230, 49, 235, 28)
415
+ action:@selector(authModePressed)];
416
+ [self.accountPanel addSubview:self.authModeButton];
417
+ self.accountSubmit = [self panelButton:@"Create account"
418
+ frame:NSMakeRect(285, 5, 180, 34)
419
+ action:@selector(accountSubmitPressed)];
420
+ [self.accountPanel addSubview:self.accountSubmit];
421
+ [root addSubview:self.accountPanel];
422
+
423
+ self.termsPanel = [[NSView alloc] initWithFrame:NSMakeRect(236, 78, 465, 220)];
424
+ NSTextField *termsSummary = [self label:@"Review and accept the current Sense Anything terms, privacy notice, earner agreement, and data processing addendum for this account."
425
+ size:13 weight:NSFontWeightRegular frame:NSMakeRect(0, 150, 465, 58)];
426
+ termsSummary.textColor = NSColor.secondaryLabelColor;
427
+ [self.termsPanel addSubview:termsSummary];
428
+ self.termsCheck = [NSButton checkboxWithTitle:@"I reviewed and accept the current agreements shown for this account."
429
+ target:self
430
+ action:@selector(termsCheckPressed)];
431
+ self.termsCheck.frame = NSMakeRect(0, 103, 465, 38);
432
+ [self.termsPanel addSubview:self.termsCheck];
433
+ self.termsVersion = [self label:@"" size:11 weight:NSFontWeightRegular frame:NSMakeRect(24, 66, 430, 34)];
434
+ self.termsVersion.textColor = NSColor.secondaryLabelColor;
435
+ [self.termsPanel addSubview:self.termsVersion];
436
+ NSButton *reviewTerms = [self panelButton:@"Review terms"
437
+ frame:NSMakeRect(0, 39, 145, 24)
438
+ action:@selector(reviewTermsPressed)];
439
+ [self.termsPanel addSubview:reviewTerms];
440
+ NSButton *reviewPrivacy = [self panelButton:@"Review privacy"
441
+ frame:NSMakeRect(150, 39, 145, 24)
442
+ action:@selector(reviewPrivacyPressed)];
443
+ [self.termsPanel addSubview:reviewPrivacy];
444
+ NSButton *reviewDpa = [self panelButton:@"Review DPA"
445
+ frame:NSMakeRect(300, 39, 145, 24)
446
+ action:@selector(reviewDpaPressed)];
447
+ [self.termsPanel addSubview:reviewDpa];
448
+ self.termsSubmit = [self panelButton:@"Accept & continue"
449
+ frame:NSMakeRect(285, 5, 180, 34)
450
+ action:@selector(termsSubmitPressed)];
451
+ self.termsSubmit.enabled = NO;
452
+ [self.termsPanel addSubview:self.termsSubmit];
453
+ [root addSubview:self.termsPanel];
454
+
455
+ self.planPanel = [[NSView alloc] initWithFrame:NSMakeRect(236, 78, 465, 220)];
456
+ self.monthlyRadio = [NSButton radioButtonWithTitle:@"Monthly" target:self action:@selector(intervalPressed:)];
457
+ self.monthlyRadio.frame = NSMakeRect(0, 181, 100, 24);
458
+ self.monthlyRadio.state = NSControlStateValueOn;
459
+ [self.planPanel addSubview:self.monthlyRadio];
460
+ self.annualRadio = [NSButton radioButtonWithTitle:@"Annual" target:self action:@selector(intervalPressed:)];
461
+ self.annualRadio.frame = NSMakeRect(105, 181, 100, 24);
462
+ [self.planPanel addSubview:self.annualRadio];
463
+ self.standardRadio = [NSButton radioButtonWithTitle:@"" target:self action:@selector(planPressed:)];
464
+ self.standardRadio.frame = NSMakeRect(0, 120, 22, 24);
465
+ self.standardRadio.state = NSControlStateValueOn;
466
+ [self.planPanel addSubview:self.standardRadio];
467
+ self.standardPlan = [self label:@"Sense Anything" size:14 weight:NSFontWeightSemibold frame:NSMakeRect(28, 105, 195, 58)];
468
+ [self.planPanel addSubview:self.standardPlan];
469
+ self.multiRadio = [NSButton radioButtonWithTitle:@"" target:self action:@selector(planPressed:)];
470
+ self.multiRadio.frame = NSMakeRect(238, 120, 22, 24);
471
+ [self.planPanel addSubview:self.multiRadio];
472
+ self.multiPlan = [self label:@"Multi-site" size:14 weight:NSFontWeightSemibold frame:NSMakeRect(266, 105, 199, 58)];
473
+ [self.planPanel addSubview:self.multiPlan];
474
+ NSTextField *planNote = [self label:@"Stripe handles checkout securely in your browser. This installer resumes automatically when your plan is active."
475
+ size:11 weight:NSFontWeightRegular frame:NSMakeRect(0, 52, 465, 42)];
476
+ planNote.textColor = NSColor.secondaryLabelColor;
477
+ [self.planPanel addSubview:planNote];
478
+ NSButton *planSubmit = [self panelButton:@"Continue to checkout"
479
+ frame:NSMakeRect(265, 5, 200, 34)
480
+ action:@selector(planSubmitPressed)];
481
+ [self.planPanel addSubview:planSubmit];
482
+ [root addSubview:self.planPanel];
483
+
484
+ self.chooseAccountPanel = [[NSView alloc] initWithFrame:NSMakeRect(236, 78, 465, 220)];
485
+ self.accountPicker = [[NSPopUpButton alloc] initWithFrame:NSMakeRect(0, 135, 465, 32) pullsDown:NO];
486
+ [self.chooseAccountPanel addSubview:self.accountPicker];
487
+ self.chooseAccountSubmit = [self panelButton:@"Continue with this account" frame:NSMakeRect(215, 0, 250, 34) action:@selector(chooseAccountPressed)];
488
+ [self.chooseAccountPanel addSubview:self.chooseAccountSubmit];
489
+ [root addSubview:self.chooseAccountPanel];
490
+
491
+ self.onboardingPanels = @[self.accountPanel, self.termsPanel, self.planPanel, self.chooseAccountPanel];
492
+ [self showOnboardingPanel:nil];
493
+ }
494
+
495
+ - (void)showOnboardingPanel:(NSView *)panel {
496
+ for (NSView *candidate in self.onboardingPanels ?: @[]) candidate.hidden = candidate != panel;
497
+ BOOL showStatus = panel == nil;
498
+ self.statusValue.hidden = !showStatus;
499
+ self.metric.hidden = !showStatus;
500
+ self.progress.hidden = !showStatus;
501
+ self.updateButton.hidden = !showStatus;
502
+ self.updateStatus.hidden = !showStatus;
503
+ self.privacyLabel.hidden = !showStatus;
504
+ self.actionButton.hidden = !showStatus;
505
+ self.closeButton.hidden = YES;
506
+ }
507
+
508
+ - (void)chooseAccountPressed {
509
+ NSString *owner = self.accountPicker.selectedItem.representedObject;
510
+ if (!owner.length) return;
511
+ self.chooseAccountSubmit.enabled = NO;
512
+ [self sendOnboardingCommand:@{ @"action": @"select_account", @"ownerUserId": owner }];
513
+ }
514
+
515
+ - (void)sendOnboardingCommand:(NSDictionary *)command {
516
+ if (!self.setupInput || ![NSJSONSerialization isValidJSONObject:command]) return;
517
+ NSData *json = [NSJSONSerialization dataWithJSONObject:command options:0 error:nil];
518
+ if (!json) return;
519
+ NSMutableData *line = [json mutableCopy];
520
+ [line appendData:[@"\n" dataUsingEncoding:NSUTF8StringEncoding]];
521
+ @try {
522
+ [self.setupInput writeData:line];
523
+ } @catch (__unused NSException *exception) {
524
+ [self showLocalError:@"Could not continue setup"
525
+ message:@"Close and reopen Sense Anything Connector, then try again."];
526
+ }
527
+ }
528
+
529
+ - (void)googlePressed {
530
+ [self sendOnboardingCommand:@{ @"action": @"google_start" }];
531
+ }
532
+
533
+ - (void)authModePressed {
534
+ self.signingIn = !self.signingIn;
535
+ self.nameLabel.hidden = self.signingIn;
536
+ self.nameInput.hidden = self.signingIn;
537
+ self.passwordLabel.stringValue = self.signingIn ? @"Password" : @"Create password";
538
+ self.passwordInput.placeholderString = self.signingIn ? @"Your password" : @"At least 8 characters";
539
+ self.accountSubmit.title = self.signingIn ? @"Sign in & continue" : @"Create account";
540
+ self.authModeButton.title = self.signingIn ? @"New here? Create account" : @"Already have an account? Sign in";
541
+ }
542
+
543
+ - (void)showInputMessage:(NSString *)message {
544
+ NSAlert *alert = [[NSAlert alloc] init];
545
+ alert.messageText = @"Sense Anything";
546
+ alert.informativeText = message;
547
+ [alert addButtonWithTitle:@"OK"];
548
+ [alert runModal];
549
+ }
550
+
551
+ - (void)accountSubmitPressed {
552
+ NSString *email = [self.emailInput.stringValue stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
553
+ NSString *password = self.passwordInput.stringValue;
554
+ NSString *name = [self.nameInput.stringValue stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
555
+ if (email.length == 0 || password.length == 0) {
556
+ [self showInputMessage:@"Enter your email and password to continue."];
557
+ return;
558
+ }
559
+ if (!self.signingIn && (name.length == 0 || password.length < 8)) {
560
+ [self showInputMessage:@"Enter your name and a password with at least 8 characters."];
561
+ return;
562
+ }
563
+ NSDictionary *command = self.signingIn
564
+ ? @{ @"action": @"email_sign_in", @"email": email, @"password": password }
565
+ : @{ @"action": @"email_sign_up", @"name": name, @"email": email, @"password": password };
566
+ [self sendOnboardingCommand:command];
567
+ self.passwordInput.stringValue = @"";
568
+ }
569
+
570
+ - (void)termsCheckPressed {
571
+ self.termsSubmit.enabled = self.termsCheck.state == NSControlStateValueOn;
572
+ }
573
+
574
+ - (void)reviewTermsPressed {
575
+ [self openURL:@"https://trillboards.com/legal/terms"];
576
+ }
577
+
578
+ - (void)reviewPrivacyPressed {
579
+ [self openURL:@"https://trillboards.com/legal/privacy-policy"];
580
+ }
581
+
582
+ - (void)reviewDpaPressed {
583
+ [self openURL:@"https://trillboards.com/legal/data-processing"];
584
+ }
585
+
586
+ - (void)termsSubmitPressed {
587
+ if (self.termsCheck.state != NSControlStateValueOn) return;
588
+ self.termsSubmit.enabled = NO;
589
+ [self sendOnboardingCommand:@{ @"action": @"accept_terms" }];
590
+ }
591
+
592
+ - (void)planSubmitPressed {
593
+ NSButton *selected = self.multiRadio.state == NSControlStateValueOn ? self.multiRadio : self.standardRadio;
594
+ NSString *tier = selected.identifier;
595
+ if (tier.length == 0) return;
596
+ NSString *interval = self.annualRadio.state == NSControlStateValueOn ? @"annual" : @"monthly";
597
+ [self sendOnboardingCommand:@{ @"action": @"select_plan", @"tier": tier, @"interval": interval }];
598
+ }
599
+
600
+ - (void)intervalPressed:(NSButton *)sender {
601
+ self.monthlyRadio.state = sender == self.monthlyRadio ? NSControlStateValueOn : NSControlStateValueOff;
602
+ self.annualRadio.state = sender == self.annualRadio ? NSControlStateValueOn : NSControlStateValueOff;
603
+ }
604
+
605
+ - (void)planPressed:(NSButton *)sender {
606
+ self.standardRadio.state = sender == self.standardRadio ? NSControlStateValueOn : NSControlStateValueOff;
607
+ self.multiRadio.state = sender == self.multiRadio ? NSControlStateValueOn : NSControlStateValueOff;
324
608
  }
325
609
 
326
610
  - (void)showWelcome {
611
+ [self showOnboardingPanel:nil];
327
612
  self.lastStage = @"welcome";
328
613
  self.headline.stringValue = @"Set up this computer for CCTV";
329
614
  self.detail.stringValue = @"Connect your Sense Anything account, find your recorder, and verify every camera before screens are created.";
@@ -338,6 +623,7 @@ static BOOL acquireSetupInstanceLock(void) {
338
623
  }
339
624
 
340
625
  - (void)showLocalError:(NSString *)title message:(NSString *)message {
626
+ [self showOnboardingPanel:nil];
341
627
  self.lastStage = @"error";
342
628
  self.headline.stringValue = title;
343
629
  self.detail.stringValue = message;
@@ -450,7 +736,10 @@ static BOOL acquireSetupInstanceLock(void) {
450
736
  [self openURL:self.requiredUpdateURL];
451
737
  return;
452
738
  }
453
- if ([self.lastStage isEqualToString:@"waiting_for_account"] ||
739
+ if ([self.lastStage isEqualToString:@"onboarding_google"] ||
740
+ [self.lastStage isEqualToString:@"onboarding_checkout"]) {
741
+ [self sendOnboardingCommand:@{ @"action": @"refresh" }];
742
+ } else if ([self.lastStage isEqualToString:@"waiting_for_account"] ||
454
743
  [self.lastStage isEqualToString:@"account_attention"]) {
455
744
  [self openURL:self.portalURL];
456
745
  } else if ([self.lastStage isEqualToString:@"ready"]) {
@@ -510,6 +799,7 @@ static BOOL acquireSetupInstanceLock(void) {
510
799
  - (void)renderState:(NSDictionary *)state {
511
800
  NSString *stage = [state[@"stage"] isKindOfClass:NSString.class] ? state[@"stage"] : nil;
512
801
  if (!stage) return;
802
+ NSString *previousStage = self.lastStage;
513
803
  self.lastStage = stage;
514
804
  if ([state[@"portalUrl"] isKindOfClass:NSString.class]) self.portalURL = state[@"portalUrl"];
515
805
  if ([state[@"title"] isKindOfClass:NSString.class]) self.headline.stringValue = state[@"title"];
@@ -519,11 +809,85 @@ static BOOL acquireSetupInstanceLock(void) {
519
809
  NSNumber *validated = state[@"validatedFeeds"];
520
810
  NSNumber *devices = state[@"devicesFound"];
521
811
  NSNumber *rejected = state[@"rejectedFeeds"];
812
+ if ([stage isEqualToString:@"onboarding_account"]) {
813
+ [self showOnboardingPanel:self.accountPanel];
814
+ [self updateSteps:1];
815
+ self.accountSubmit.enabled = YES;
816
+ return;
817
+ }
818
+ if ([stage isEqualToString:@"onboarding_choose_account"]) {
819
+ [self showOnboardingPanel:self.chooseAccountPanel];
820
+ [self updateSteps:2];
821
+ [self.accountPicker removeAllItems];
822
+ NSArray *accounts = [state[@"accounts"] isKindOfClass:NSArray.class] ? state[@"accounts"] : @[];
823
+ for (NSDictionary *account in accounts) {
824
+ NSString *label = [NSString stringWithFormat:@"%@ (%@)", account[@"label"] ?: @"Account", account[@"role"] ?: @""];
825
+ NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:label action:nil keyEquivalent:@""];
826
+ item.representedObject = account[@"ownerUserId"];
827
+ [self.accountPicker.menu addItem:item];
828
+ }
829
+ if (accounts.count) [self.accountPicker selectItemAtIndex:0];
830
+ self.chooseAccountSubmit.enabled = accounts.count > 0;
831
+ return;
832
+ }
833
+ if ([stage isEqualToString:@"onboarding_terms"]) {
834
+ [self showOnboardingPanel:self.termsPanel];
835
+ [self updateSteps:2];
836
+ if (![previousStage isEqualToString:@"onboarding_terms"]) {
837
+ self.termsCheck.state = NSControlStateValueOff;
838
+ self.termsSubmit.enabled = NO;
839
+ }
840
+ self.termsSubmit.enabled = self.termsCheck.state == NSControlStateValueOn;
841
+ NSArray *agreements = [state[@"requiredAgreements"] isKindOfClass:NSArray.class] ? state[@"requiredAgreements"] : @[];
842
+ NSMutableArray<NSString *> *versions = [NSMutableArray array];
843
+ for (NSDictionary *agreement in agreements) {
844
+ if (![agreement isKindOfClass:NSDictionary.class]) continue;
845
+ NSString *type = [agreement[@"type"] isKindOfClass:NSString.class] ? agreement[@"type"] : @"";
846
+ NSString *label = [agreement[@"label"] isKindOfClass:NSString.class] ? agreement[@"label"] : [type stringByReplacingOccurrencesOfString:@"_" withString:@" "];
847
+ NSString *version = [agreement[@"version"] isKindOfClass:NSString.class] ? agreement[@"version"] : @"";
848
+ if (type.length && version.length) [versions addObject:[NSString stringWithFormat:@"%@ v%@", label, version]];
849
+ }
850
+ self.termsVersion.stringValue = [versions componentsJoinedByString:@" • "];
851
+ return;
852
+ }
853
+ if ([stage isEqualToString:@"onboarding_plan"]) {
854
+ [self showOnboardingPanel:self.planPanel];
855
+ [self updateSteps:3];
856
+ NSArray *plans = [state[@"plans"] isKindOfClass:NSArray.class] ? state[@"plans"] : @[];
857
+ if (plans.count > 0) {
858
+ NSDictionary *first = plans[0];
859
+ self.standardRadio.identifier = [first[@"tier"] isKindOfClass:NSString.class] ? first[@"tier"] : @"";
860
+ self.standardPlan.stringValue = [NSString stringWithFormat:@"%@\n%@/month • %@/year",
861
+ first[@"label"] ?: @"Sense Anything", first[@"monthly"] ?: @"—", first[@"annual"] ?: @"—"];
862
+ self.standardRadio.hidden = NO;
863
+ self.standardPlan.hidden = NO;
864
+ }
865
+ if (plans.count > 1) {
866
+ NSDictionary *second = plans[1];
867
+ self.multiRadio.identifier = [second[@"tier"] isKindOfClass:NSString.class] ? second[@"tier"] : @"";
868
+ self.multiPlan.stringValue = [NSString stringWithFormat:@"%@\n%@/month • %@/year",
869
+ second[@"label"] ?: @"Multi-site", second[@"monthly"] ?: @"—", second[@"annual"] ?: @"—"];
870
+ self.multiRadio.hidden = NO;
871
+ self.multiPlan.hidden = NO;
872
+ } else {
873
+ self.multiRadio.hidden = YES;
874
+ self.multiPlan.hidden = YES;
875
+ }
876
+ return;
877
+ }
878
+
879
+ [self showOnboardingPanel:nil];
522
880
  if (([stage isEqualToString:@"waiting_for_account"] ||
523
881
  [stage isEqualToString:@"account_attention"]) &&
524
882
  [state[@"displayCode"] isKindOfClass:NSString.class]) {
525
883
  self.statusValue.font = [NSFont monospacedSystemFontOfSize:25 weight:NSFontWeightSemibold];
526
884
  self.statusValue.stringValue = state[@"displayCode"];
885
+ } else if ([stage isEqualToString:@"onboarding_google"]) {
886
+ self.statusValue.stringValue = @"Waiting for Google";
887
+ } else if ([stage isEqualToString:@"onboarding_checkout"]) {
888
+ self.statusValue.stringValue = @"Waiting for Stripe";
889
+ } else if ([stage isEqualToString:@"onboarding_install"]) {
890
+ self.statusValue.stringValue = @"Onboarding complete";
527
891
  } else if (cameraCount.integerValue > 0) {
528
892
  self.statusValue.font = [NSFont systemFontOfSize:25 weight:NSFontWeightSemibold];
529
893
  self.statusValue.stringValue = [NSString stringWithFormat:@"%ld camera%@", cameraCount.integerValue, cameraCount.integerValue == 1 ? @"" : @"s"];
@@ -566,7 +930,16 @@ static BOOL acquireSetupInstanceLock(void) {
566
930
  self.progress.doubleValue = [self progressForStage:stage];
567
931
  [self updateSteps:[self stepForStage:stage]];
568
932
  self.closeButton.hidden = ![stage isEqualToString:@"ready"];
569
- if ([stage isEqualToString:@"waiting_for_account"]) {
933
+ if ([stage isEqualToString:@"onboarding_google"]) {
934
+ self.actionButton.title = @"Use another sign-in";
935
+ self.actionButton.enabled = YES;
936
+ } else if ([stage isEqualToString:@"onboarding_checkout"]) {
937
+ self.actionButton.title = @"Back to plans";
938
+ self.actionButton.enabled = YES;
939
+ } else if ([stage isEqualToString:@"onboarding_install"]) {
940
+ self.actionButton.title = @"Preparing cameras…";
941
+ self.actionButton.enabled = NO;
942
+ } else if ([stage isEqualToString:@"waiting_for_account"]) {
570
943
  self.actionButton.title = @"Open Sense Anything";
571
944
  self.actionButton.enabled = YES;
572
945
  } else if ([stage isEqualToString:@"account_attention"]) {
@@ -592,25 +965,29 @@ static BOOL acquireSetupInstanceLock(void) {
592
965
  }
593
966
 
594
967
  - (NSInteger)stepForStage:(NSString *)stage {
595
- if ([@[@"waiting_for_account", @"account_attention", @"account_connected"] containsObject:stage]) return 1;
596
- if ([@[@"discovering", @"devices_found"] containsObject:stage]) return 2;
597
- if ([@[@"validating", @"cameras_created"] containsObject:stage]) return 3;
598
- if ([@[@"local_ai", @"starting_service"] containsObject:stage]) return 4;
599
- if ([stage isEqualToString:@"ready"]) return 5;
968
+ if ([@[@"onboarding_account", @"onboarding_google"] containsObject:stage]) return 1;
969
+ if ([@[@"onboarding_terms", @"onboarding_choose_account"] containsObject:stage]) return 2;
970
+ if ([@[@"onboarding_plan", @"onboarding_checkout"] containsObject:stage]) return 3;
971
+ if ([@[@"onboarding_install", @"starting", @"waiting_for_account", @"account_attention", @"account_connected", @"discovering", @"devices_found"] containsObject:stage]) return 4;
972
+ if ([@[@"validating", @"cameras_created"] containsObject:stage]) return 5;
973
+ if ([@[@"local_ai", @"local_ai_error", @"starting_service"] containsObject:stage]) return 6;
974
+ if ([stage isEqualToString:@"ready"]) return 7;
600
975
  return 0;
601
976
  }
602
977
 
603
978
  - (double)progressForStage:(NSString *)stage {
604
979
  NSDictionary<NSString *, NSNumber *> *values = @{
605
- @"waiting_for_account": @18, @"account_attention": @18, @"account_connected": @30, @"discovering": @42,
606
- @"devices_found": @50, @"validating": @68, @"cameras_created": @80,
980
+ @"onboarding_account": @10, @"onboarding_google": @12, @"onboarding_terms": @25,
981
+ @"onboarding_plan": @42, @"onboarding_checkout": @50, @"onboarding_install": @65,
982
+ @"waiting_for_account": @65, @"account_attention": @65, @"account_connected": @65, @"discovering": @68,
983
+ @"devices_found": @70, @"validating": @75, @"cameras_created": @80,
607
984
  @"local_ai": @88, @"starting_service": @94, @"ready": @100
608
985
  };
609
986
  return values[stage] ? values[stage].doubleValue : 8;
610
987
  }
611
988
 
612
989
  - (void)updateSteps:(NSInteger)active {
613
- NSArray<NSString *> *names = @[@"Welcome", @"Account", @"Recorder", @"Cameras", @"Local AI", @"Ready"];
990
+ NSArray<NSString *> *names = @[@"Welcome", @"Account", @"Account setup", @"Plan & Payment", @"Recorder", @"Cameras", @"Local AI", @"Ready"];
614
991
  for (NSInteger index = 0; index < (NSInteger)self.stepLabels.count; index++) {
615
992
  NSTextField *field = self.stepLabels[(NSUInteger)index];
616
993
  NSString *prefix = index < active ? @"OK" : [NSString stringWithFormat:@"%ld", index + 1];
@@ -363,7 +363,12 @@ if [ "${SETUP_MODE:-0}" = "1" ]; then
363
363
  plog "setup: starting validate-first setup mode"
364
364
  set +e
365
365
  if is_ui; then
366
- "$LAUNCHER" setup --config "$CONFIG" --status-file "$UI_STATUS_FILE"
366
+ # The native app owns account, legal and plan selection. This long-lived
367
+ # process receives commands on inherited stdin and exits only after the API
368
+ # says legal + entitlement are current, so cameras cannot open early.
369
+ # The same process keeps the authenticated session in memory through camera
370
+ # setup and mints a one-use signed-in browser handoff at first launch.
371
+ "$LAUNCHER" installer-onboarding --config "$CONFIG" --status-file "$UI_STATUS_FILE"
367
372
  else
368
373
  "$LAUNCHER" setup --config "$CONFIG"
369
374
  fi
@@ -148,7 +148,13 @@ REM ── Validate-first setup: the CLI displays the code, keeps this window's
148
148
  REM staged progress, and exits 0 once the portal finalizes cameras and the
149
149
  REM config is written — then the SAME honest service stages below run.
150
150
  if defined UI_STATUS_FILE (
151
- call "%~dp0trillboards-edge.cmd" setup --config "%CFG%" --status-file "%UI_STATUS_FILE%"
151
+ REM Account, current legal versions and paid entitlement are completed in
152
+ REM the native installer BEFORE camera setup is allowed to open the portal.
153
+ REM Commands arrive on inherited stdin; passwords never touch argv or disk.
154
+ REM The onboarding process keeps its authenticated session in memory, starts
155
+ REM camera setup itself, and wraps the first portal launch in a one-use login
156
+ REM handoff. Email users therefore do not sign in a second time in the browser.
157
+ call "%~dp0trillboards-edge.cmd" installer-onboarding --config "%CFG%" --status-file "%UI_STATUS_FILE%"
152
158
  ) else (
153
159
  call "%~dp0trillboards-edge.cmd" setup --config "%CFG%"
154
160
  )