@trillboards/edge-sdk 0.2.135 → 0.2.137

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
@@ -45,11 +45,37 @@ static BOOL acquireSetupInstanceLock(void) {
45
45
  @property(nonatomic, strong) NSTextField *metric;
46
46
  @property(nonatomic, strong) NSProgressIndicator *progress;
47
47
  @property(nonatomic, strong) NSButton *actionButton;
48
+ @property(nonatomic, strong) NSButton *googleReopenButton;
48
49
  @property(nonatomic, strong) NSButton *closeButton;
49
50
  @property(nonatomic, strong) NSButton *updateButton;
50
51
  @property(nonatomic, strong) NSTextField *updateStatus;
52
+ @property(nonatomic, strong) NSTextField *privacyLabel;
51
53
  @property(nonatomic, strong) NSArray<NSTextField *> *stepLabels;
54
+ @property(nonatomic, strong) NSArray<NSView *> *onboardingPanels;
55
+ @property(nonatomic, strong) NSView *accountPanel;
56
+ @property(nonatomic, strong) NSView *termsPanel;
57
+ @property(nonatomic, strong) NSView *chooseAccountPanel;
58
+ @property(nonatomic, strong) NSPopUpButton *accountPicker;
59
+ @property(nonatomic, strong) NSButton *chooseAccountSubmit;
60
+ @property(nonatomic, strong) NSView *planPanel;
61
+ @property(nonatomic, strong) NSTextField *nameInput;
62
+ @property(nonatomic, strong) NSTextField *emailInput;
63
+ @property(nonatomic, strong) NSSecureTextField *passwordInput;
64
+ @property(nonatomic, strong) NSTextField *nameLabel;
65
+ @property(nonatomic, strong) NSTextField *passwordLabel;
66
+ @property(nonatomic, strong) NSButton *accountSubmit;
67
+ @property(nonatomic, strong) NSButton *authModeButton;
68
+ @property(nonatomic, strong) NSButton *termsCheck;
69
+ @property(nonatomic, strong) NSTextField *termsVersion;
70
+ @property(nonatomic, strong) NSButton *termsSubmit;
71
+ @property(nonatomic, strong) NSButton *monthlyRadio;
72
+ @property(nonatomic, strong) NSButton *annualRadio;
73
+ @property(nonatomic, strong) NSButton *standardRadio;
74
+ @property(nonatomic, strong) NSButton *multiRadio;
75
+ @property(nonatomic, strong) NSTextField *standardPlan;
76
+ @property(nonatomic, strong) NSTextField *multiPlan;
52
77
  @property(nonatomic, strong) NSTimer *timer;
78
+ @property(nonatomic, strong) NSFileHandle *setupInput;
53
79
  @property(nonatomic, assign) pid_t pairProcessIdentifier;
54
80
  @property(nonatomic, strong) dispatch_source_t pairProcessSource;
55
81
  @property(nonatomic, copy) NSString *lastStage;
@@ -57,6 +83,7 @@ static BOOL acquireSetupInstanceLock(void) {
57
83
  @property(nonatomic, copy) NSString *requiredUpdateURL;
58
84
  @property(nonatomic, assign) BOOL setupStarted;
59
85
  @property(nonatomic, assign) BOOL terminating;
86
+ @property(nonatomic, assign) BOOL signingIn;
60
87
  @end
61
88
 
62
89
  @implementation TrillboardsSetupDelegate
@@ -74,6 +101,24 @@ static BOOL acquireSetupInstanceLock(void) {
74
101
  return field;
75
102
  }
76
103
 
104
+ - (NSTextField *)inputWithFrame:(NSRect)frame placeholder:(NSString *)placeholder secure:(BOOL)secure {
105
+ NSTextField *field = secure
106
+ ? [[NSSecureTextField alloc] initWithFrame:frame]
107
+ : [[NSTextField alloc] initWithFrame:frame];
108
+ field.placeholderString = placeholder;
109
+ field.font = [NSFont systemFontOfSize:14 weight:NSFontWeightRegular];
110
+ return field;
111
+ }
112
+
113
+ - (NSButton *)panelButton:(NSString *)title frame:(NSRect)frame action:(SEL)action {
114
+ NSButton *button = [[NSButton alloc] initWithFrame:frame];
115
+ button.title = title;
116
+ button.bezelStyle = NSBezelStyleRounded;
117
+ button.target = self;
118
+ button.action = action;
119
+ return button;
120
+ }
121
+
77
122
  - (void)applicationDidFinishLaunching:(NSNotification *)notification {
78
123
  (void)notification;
79
124
  self.lastStage = @"welcome";
@@ -118,6 +163,10 @@ static BOOL acquireSetupInstanceLock(void) {
118
163
  - (void)stopPairProcess {
119
164
  pid_t pid = self.pairProcessIdentifier;
120
165
  self.pairProcessIdentifier = 0;
166
+ if (self.setupInput) {
167
+ [self.setupInput closeFile];
168
+ self.setupInput = nil;
169
+ }
121
170
  if (self.pairProcessSource) {
122
171
  dispatch_source_cancel(self.pairProcessSource);
123
172
  self.pairProcessSource = nil;
@@ -150,12 +199,22 @@ static BOOL acquireSetupInstanceLock(void) {
150
199
  return NO;
151
200
  }
152
201
 
202
+ int inputPipe[2];
203
+ if (pipe(inputPipe) != 0) {
204
+ close(logFd);
205
+ if (error) *error = [NSError errorWithDomain:NSPOSIXErrorDomain code:errno userInfo:nil];
206
+ return NO;
207
+ }
208
+
153
209
  posix_spawn_file_actions_t fileActions;
154
210
  posix_spawnattr_t attributes;
155
211
  posix_spawn_file_actions_init(&fileActions);
156
212
  posix_spawn_file_actions_adddup2(&fileActions, logFd, STDOUT_FILENO);
157
213
  posix_spawn_file_actions_adddup2(&fileActions, logFd, STDERR_FILENO);
214
+ posix_spawn_file_actions_adddup2(&fileActions, inputPipe[0], STDIN_FILENO);
158
215
  posix_spawn_file_actions_addclose(&fileActions, logFd);
216
+ posix_spawn_file_actions_addclose(&fileActions, inputPipe[0]);
217
+ posix_spawn_file_actions_addclose(&fileActions, inputPipe[1]);
159
218
  posix_spawnattr_init(&attributes);
160
219
  posix_spawnattr_setflags(&attributes, POSIX_SPAWN_SETPGROUP);
161
220
  posix_spawnattr_setpgroup(&attributes, 0);
@@ -179,11 +238,14 @@ static BOOL acquireSetupInstanceLock(void) {
179
238
  posix_spawnattr_destroy(&attributes);
180
239
  posix_spawn_file_actions_destroy(&fileActions);
181
240
  close(logFd);
241
+ close(inputPipe[0]);
182
242
  if (spawnError != 0) {
243
+ close(inputPipe[1]);
183
244
  if (error) *error = [NSError errorWithDomain:NSPOSIXErrorDomain code:spawnError userInfo:nil];
184
245
  return NO;
185
246
  }
186
247
 
248
+ self.setupInput = [[NSFileHandle alloc] initWithFileDescriptor:inputPipe[1] closeOnDealloc:YES];
187
249
  self.pairProcessIdentifier = pid;
188
250
  dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_PROC,
189
251
  (uintptr_t)pid,
@@ -203,6 +265,10 @@ static BOOL acquireSetupInstanceLock(void) {
203
265
  int status = 0;
204
266
  pid_t waited = waitpid(pid, &status, 0);
205
267
  self.pairProcessIdentifier = 0;
268
+ if (self.setupInput) {
269
+ [self.setupInput closeFile];
270
+ self.setupInput = nil;
271
+ }
206
272
  if (self.pairProcessSource) {
207
273
  dispatch_source_cancel(self.pairProcessSource);
208
274
  self.pairProcessSource = nil;
@@ -241,7 +307,7 @@ static BOOL acquireSetupInstanceLock(void) {
241
307
  NSTextField *brand = [self label:@"Sense Anything" size:16 weight:NSFontWeightSemibold frame:NSMakeRect(24, 480, 145, 28)];
242
308
  [sidebar addSubview:brand];
243
309
 
244
- NSArray<NSString *> *names = @[@"Welcome", @"Account", @"Recorder", @"Cameras", @"Local AI", @"Ready"];
310
+ NSArray<NSString *> *names = @[@"Welcome", @"Account", @"Account setup", @"Plan & Payment", @"Recorder", @"Cameras", @"Local AI", @"Ready"];
245
311
  NSMutableArray<NSTextField *> *labels = [NSMutableArray array];
246
312
  for (NSUInteger index = 0; index < names.count; index++) {
247
313
  NSTextField *step = [self label:[NSString stringWithFormat:@"%lu %@", index + 1, names[index]]
@@ -274,6 +340,14 @@ static BOOL acquireSetupInstanceLock(void) {
274
340
  self.statusValue.layer.cornerRadius = 6;
275
341
  [root addSubview:self.statusValue];
276
342
 
343
+ self.googleReopenButton = [[NSButton alloc] initWithFrame:NSMakeRect(360, 219, 218, 28)];
344
+ self.googleReopenButton.title = @"Open sign-in again";
345
+ self.googleReopenButton.bezelStyle = NSBezelStyleRounded;
346
+ self.googleReopenButton.target = self;
347
+ self.googleReopenButton.action = @selector(googleReopenPressed);
348
+ self.googleReopenButton.hidden = YES;
349
+ [root addSubview:self.googleReopenButton];
350
+
277
351
  self.metric = [self label:@"" size:13 weight:NSFontWeightRegular frame:NSMakeRect(236, 174, 465, 28)];
278
352
  self.metric.textColor = NSColor.secondaryLabelColor;
279
353
  [root addSubview:self.metric];
@@ -300,12 +374,12 @@ static BOOL acquireSetupInstanceLock(void) {
300
374
  self.updateStatus.textColor = NSColor.secondaryLabelColor;
301
375
  [root addSubview:self.updateStatus];
302
376
 
303
- NSTextField *privacy = [self label:@"Camera credentials stay on this computer. Cloud inference is off by default."
377
+ self.privacyLabel = [self label:@"Camera credentials stay on this computer. Cloud inference is off by default."
304
378
  size:12
305
379
  weight:NSFontWeightRegular
306
380
  frame:NSMakeRect(236, 34, 300, 42)];
307
- privacy.textColor = NSColor.secondaryLabelColor;
308
- [root addSubview:privacy];
381
+ self.privacyLabel.textColor = NSColor.secondaryLabelColor;
382
+ [root addSubview:self.privacyLabel];
309
383
 
310
384
  self.actionButton = [[NSButton alloc] initWithFrame:NSMakeRect(562, 36, 139, 40)];
311
385
  self.actionButton.title = @"Start setup";
@@ -321,9 +395,234 @@ static BOOL acquireSetupInstanceLock(void) {
321
395
  self.closeButton.action = @selector(closePressed);
322
396
  self.closeButton.hidden = YES;
323
397
  [root addSubview:self.closeButton];
398
+
399
+ // Native onboarding panels. Provider-owned Google and Stripe UI opens in
400
+ // the system browser; every product-owned choice remains in this window.
401
+ self.accountPanel = [[NSView alloc] initWithFrame:NSMakeRect(236, 78, 465, 220)];
402
+ NSButton *google = [self panelButton:@"Continue with Google"
403
+ frame:NSMakeRect(0, 178, 465, 34)
404
+ action:@selector(googlePressed)];
405
+ [self.accountPanel addSubview:google];
406
+ NSTextField *orEmail = [self label:@"or use email" size:11 weight:NSFontWeightRegular frame:NSMakeRect(0, 153, 465, 18)];
407
+ orEmail.alignment = NSTextAlignmentCenter;
408
+ orEmail.textColor = NSColor.secondaryLabelColor;
409
+ [self.accountPanel addSubview:orEmail];
410
+ self.nameLabel = [self label:@"Full name" size:11 weight:NSFontWeightRegular frame:NSMakeRect(0, 132, 220, 17)];
411
+ [self.accountPanel addSubview:self.nameLabel];
412
+ self.nameInput = [self inputWithFrame:NSMakeRect(0, 103, 220, 28) placeholder:@"Maya Chen" secure:NO];
413
+ [self.accountPanel addSubview:self.nameInput];
414
+ NSTextField *emailLabel = [self label:@"Work email" size:11 weight:NSFontWeightRegular frame:NSMakeRect(235, 132, 230, 17)];
415
+ [self.accountPanel addSubview:emailLabel];
416
+ self.emailInput = [self inputWithFrame:NSMakeRect(235, 103, 230, 28) placeholder:@"maya@yourshop.com" secure:NO];
417
+ [self.accountPanel addSubview:self.emailInput];
418
+ self.passwordLabel = [self label:@"Create password" size:11 weight:NSFontWeightRegular frame:NSMakeRect(0, 78, 220, 17)];
419
+ [self.accountPanel addSubview:self.passwordLabel];
420
+ self.passwordInput = (NSSecureTextField *)[self inputWithFrame:NSMakeRect(0, 49, 220, 28) placeholder:@"At least 8 characters" secure:YES];
421
+ [self.accountPanel addSubview:self.passwordInput];
422
+ self.authModeButton = [self panelButton:@"Already have an account? Sign in"
423
+ frame:NSMakeRect(230, 49, 235, 28)
424
+ action:@selector(authModePressed)];
425
+ [self.accountPanel addSubview:self.authModeButton];
426
+ self.accountSubmit = [self panelButton:@"Create account"
427
+ frame:NSMakeRect(285, 5, 180, 34)
428
+ action:@selector(accountSubmitPressed)];
429
+ [self.accountPanel addSubview:self.accountSubmit];
430
+ [root addSubview:self.accountPanel];
431
+
432
+ self.termsPanel = [[NSView alloc] initWithFrame:NSMakeRect(236, 78, 465, 220)];
433
+ NSTextField *termsSummary = [self label:@"Review and accept the current Sense Anything terms, privacy notice, earner agreement, and data processing addendum for this account."
434
+ size:13 weight:NSFontWeightRegular frame:NSMakeRect(0, 150, 465, 58)];
435
+ termsSummary.textColor = NSColor.secondaryLabelColor;
436
+ [self.termsPanel addSubview:termsSummary];
437
+ self.termsCheck = [NSButton checkboxWithTitle:@"I reviewed and accept the current agreements shown for this account."
438
+ target:self
439
+ action:@selector(termsCheckPressed)];
440
+ self.termsCheck.frame = NSMakeRect(0, 103, 465, 38);
441
+ [self.termsPanel addSubview:self.termsCheck];
442
+ self.termsVersion = [self label:@"" size:11 weight:NSFontWeightRegular frame:NSMakeRect(24, 66, 430, 34)];
443
+ self.termsVersion.textColor = NSColor.secondaryLabelColor;
444
+ [self.termsPanel addSubview:self.termsVersion];
445
+ NSButton *reviewTerms = [self panelButton:@"Review terms"
446
+ frame:NSMakeRect(0, 39, 145, 24)
447
+ action:@selector(reviewTermsPressed)];
448
+ [self.termsPanel addSubview:reviewTerms];
449
+ NSButton *reviewPrivacy = [self panelButton:@"Review privacy"
450
+ frame:NSMakeRect(150, 39, 145, 24)
451
+ action:@selector(reviewPrivacyPressed)];
452
+ [self.termsPanel addSubview:reviewPrivacy];
453
+ NSButton *reviewDpa = [self panelButton:@"Review DPA"
454
+ frame:NSMakeRect(300, 39, 145, 24)
455
+ action:@selector(reviewDpaPressed)];
456
+ [self.termsPanel addSubview:reviewDpa];
457
+ self.termsSubmit = [self panelButton:@"Accept & continue"
458
+ frame:NSMakeRect(285, 5, 180, 34)
459
+ action:@selector(termsSubmitPressed)];
460
+ self.termsSubmit.enabled = NO;
461
+ [self.termsPanel addSubview:self.termsSubmit];
462
+ [root addSubview:self.termsPanel];
463
+
464
+ self.planPanel = [[NSView alloc] initWithFrame:NSMakeRect(236, 78, 465, 220)];
465
+ self.monthlyRadio = [NSButton radioButtonWithTitle:@"Monthly" target:self action:@selector(intervalPressed:)];
466
+ self.monthlyRadio.frame = NSMakeRect(0, 181, 100, 24);
467
+ self.monthlyRadio.state = NSControlStateValueOn;
468
+ [self.planPanel addSubview:self.monthlyRadio];
469
+ self.annualRadio = [NSButton radioButtonWithTitle:@"Annual" target:self action:@selector(intervalPressed:)];
470
+ self.annualRadio.frame = NSMakeRect(105, 181, 100, 24);
471
+ [self.planPanel addSubview:self.annualRadio];
472
+ self.standardRadio = [NSButton radioButtonWithTitle:@"" target:self action:@selector(planPressed:)];
473
+ self.standardRadio.frame = NSMakeRect(0, 120, 22, 24);
474
+ self.standardRadio.state = NSControlStateValueOn;
475
+ [self.planPanel addSubview:self.standardRadio];
476
+ self.standardPlan = [self label:@"Sense Anything" size:14 weight:NSFontWeightSemibold frame:NSMakeRect(28, 105, 195, 58)];
477
+ [self.planPanel addSubview:self.standardPlan];
478
+ self.multiRadio = [NSButton radioButtonWithTitle:@"" target:self action:@selector(planPressed:)];
479
+ self.multiRadio.frame = NSMakeRect(238, 120, 22, 24);
480
+ [self.planPanel addSubview:self.multiRadio];
481
+ self.multiPlan = [self label:@"Multi-site" size:14 weight:NSFontWeightSemibold frame:NSMakeRect(266, 105, 199, 58)];
482
+ [self.planPanel addSubview:self.multiPlan];
483
+ NSTextField *planNote = [self label:@"Stripe handles checkout securely in your browser. This installer resumes automatically when your plan is active."
484
+ size:11 weight:NSFontWeightRegular frame:NSMakeRect(0, 52, 465, 42)];
485
+ planNote.textColor = NSColor.secondaryLabelColor;
486
+ [self.planPanel addSubview:planNote];
487
+ NSButton *planSubmit = [self panelButton:@"Continue to checkout"
488
+ frame:NSMakeRect(265, 5, 200, 34)
489
+ action:@selector(planSubmitPressed)];
490
+ [self.planPanel addSubview:planSubmit];
491
+ [root addSubview:self.planPanel];
492
+
493
+ self.chooseAccountPanel = [[NSView alloc] initWithFrame:NSMakeRect(236, 78, 465, 220)];
494
+ self.accountPicker = [[NSPopUpButton alloc] initWithFrame:NSMakeRect(0, 135, 465, 32) pullsDown:NO];
495
+ [self.chooseAccountPanel addSubview:self.accountPicker];
496
+ self.chooseAccountSubmit = [self panelButton:@"Continue with this account" frame:NSMakeRect(215, 0, 250, 34) action:@selector(chooseAccountPressed)];
497
+ [self.chooseAccountPanel addSubview:self.chooseAccountSubmit];
498
+ [root addSubview:self.chooseAccountPanel];
499
+
500
+ self.onboardingPanels = @[self.accountPanel, self.termsPanel, self.planPanel, self.chooseAccountPanel];
501
+ [self showOnboardingPanel:nil];
502
+ }
503
+
504
+ - (void)showOnboardingPanel:(NSView *)panel {
505
+ for (NSView *candidate in self.onboardingPanels ?: @[]) candidate.hidden = candidate != panel;
506
+ BOOL showStatus = panel == nil;
507
+ self.statusValue.hidden = !showStatus;
508
+ self.metric.hidden = !showStatus;
509
+ self.progress.hidden = !showStatus;
510
+ self.updateButton.hidden = !showStatus;
511
+ self.updateStatus.hidden = !showStatus;
512
+ self.privacyLabel.hidden = !showStatus;
513
+ self.actionButton.hidden = !showStatus;
514
+ self.closeButton.hidden = YES;
515
+ self.googleReopenButton.hidden = YES;
516
+ }
517
+
518
+ - (void)chooseAccountPressed {
519
+ NSString *owner = self.accountPicker.selectedItem.representedObject;
520
+ if (!owner.length) return;
521
+ self.chooseAccountSubmit.enabled = NO;
522
+ [self sendOnboardingCommand:@{ @"action": @"select_account", @"ownerUserId": owner }];
523
+ }
524
+
525
+ - (void)sendOnboardingCommand:(NSDictionary *)command {
526
+ if (!self.setupInput || ![NSJSONSerialization isValidJSONObject:command]) return;
527
+ NSData *json = [NSJSONSerialization dataWithJSONObject:command options:0 error:nil];
528
+ if (!json) return;
529
+ NSMutableData *line = [json mutableCopy];
530
+ [line appendData:[@"\n" dataUsingEncoding:NSUTF8StringEncoding]];
531
+ @try {
532
+ [self.setupInput writeData:line];
533
+ } @catch (__unused NSException *exception) {
534
+ [self showLocalError:@"Could not continue setup"
535
+ message:@"Close and reopen Sense Anything Connector, then try again."];
536
+ }
537
+ }
538
+
539
+ - (void)googleReopenPressed {
540
+ [self sendOnboardingCommand:@{ @"action": @"google_reopen" }];
541
+ }
542
+
543
+ - (void)googlePressed {
544
+ [self sendOnboardingCommand:@{ @"action": @"google_start" }];
545
+ }
546
+
547
+ - (void)authModePressed {
548
+ self.signingIn = !self.signingIn;
549
+ self.nameLabel.hidden = self.signingIn;
550
+ self.nameInput.hidden = self.signingIn;
551
+ self.passwordLabel.stringValue = self.signingIn ? @"Password" : @"Create password";
552
+ self.passwordInput.placeholderString = self.signingIn ? @"Your password" : @"At least 8 characters";
553
+ self.accountSubmit.title = self.signingIn ? @"Sign in & continue" : @"Create account";
554
+ self.authModeButton.title = self.signingIn ? @"New here? Create account" : @"Already have an account? Sign in";
555
+ }
556
+
557
+ - (void)showInputMessage:(NSString *)message {
558
+ NSAlert *alert = [[NSAlert alloc] init];
559
+ alert.messageText = @"Sense Anything";
560
+ alert.informativeText = message;
561
+ [alert addButtonWithTitle:@"OK"];
562
+ [alert runModal];
563
+ }
564
+
565
+ - (void)accountSubmitPressed {
566
+ NSString *email = [self.emailInput.stringValue stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
567
+ NSString *password = self.passwordInput.stringValue;
568
+ NSString *name = [self.nameInput.stringValue stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
569
+ if (email.length == 0 || password.length == 0) {
570
+ [self showInputMessage:@"Enter your email and password to continue."];
571
+ return;
572
+ }
573
+ if (!self.signingIn && (name.length == 0 || password.length < 8)) {
574
+ [self showInputMessage:@"Enter your name and a password with at least 8 characters."];
575
+ return;
576
+ }
577
+ NSDictionary *command = self.signingIn
578
+ ? @{ @"action": @"email_sign_in", @"email": email, @"password": password }
579
+ : @{ @"action": @"email_sign_up", @"name": name, @"email": email, @"password": password };
580
+ [self sendOnboardingCommand:command];
581
+ self.passwordInput.stringValue = @"";
582
+ }
583
+
584
+ - (void)termsCheckPressed {
585
+ self.termsSubmit.enabled = self.termsCheck.state == NSControlStateValueOn;
586
+ }
587
+
588
+ - (void)reviewTermsPressed {
589
+ [self openURL:@"https://trillboards.com/legal/terms"];
590
+ }
591
+
592
+ - (void)reviewPrivacyPressed {
593
+ [self openURL:@"https://trillboards.com/legal/privacy-policy"];
594
+ }
595
+
596
+ - (void)reviewDpaPressed {
597
+ [self openURL:@"https://trillboards.com/legal/data-processing"];
598
+ }
599
+
600
+ - (void)termsSubmitPressed {
601
+ if (self.termsCheck.state != NSControlStateValueOn) return;
602
+ self.termsSubmit.enabled = NO;
603
+ [self sendOnboardingCommand:@{ @"action": @"accept_terms" }];
604
+ }
605
+
606
+ - (void)planSubmitPressed {
607
+ NSButton *selected = self.multiRadio.state == NSControlStateValueOn ? self.multiRadio : self.standardRadio;
608
+ NSString *tier = selected.identifier;
609
+ if (tier.length == 0) return;
610
+ NSString *interval = self.annualRadio.state == NSControlStateValueOn ? @"annual" : @"monthly";
611
+ [self sendOnboardingCommand:@{ @"action": @"select_plan", @"tier": tier, @"interval": interval }];
612
+ }
613
+
614
+ - (void)intervalPressed:(NSButton *)sender {
615
+ self.monthlyRadio.state = sender == self.monthlyRadio ? NSControlStateValueOn : NSControlStateValueOff;
616
+ self.annualRadio.state = sender == self.annualRadio ? NSControlStateValueOn : NSControlStateValueOff;
617
+ }
618
+
619
+ - (void)planPressed:(NSButton *)sender {
620
+ self.standardRadio.state = sender == self.standardRadio ? NSControlStateValueOn : NSControlStateValueOff;
621
+ self.multiRadio.state = sender == self.multiRadio ? NSControlStateValueOn : NSControlStateValueOff;
324
622
  }
325
623
 
326
624
  - (void)showWelcome {
625
+ [self showOnboardingPanel:nil];
327
626
  self.lastStage = @"welcome";
328
627
  self.headline.stringValue = @"Set up this computer for CCTV";
329
628
  self.detail.stringValue = @"Connect your Sense Anything account, find your recorder, and verify every camera before screens are created.";
@@ -338,6 +637,7 @@ static BOOL acquireSetupInstanceLock(void) {
338
637
  }
339
638
 
340
639
  - (void)showLocalError:(NSString *)title message:(NSString *)message {
640
+ [self showOnboardingPanel:nil];
341
641
  self.lastStage = @"error";
342
642
  self.headline.stringValue = title;
343
643
  self.detail.stringValue = message;
@@ -348,6 +648,7 @@ static BOOL acquireSetupInstanceLock(void) {
348
648
  self.actionButton.title = @"Try again";
349
649
  self.actionButton.enabled = YES;
350
650
  self.closeButton.hidden = YES;
651
+ self.googleReopenButton.hidden = YES;
351
652
  }
352
653
 
353
654
  - (void)closePressed {
@@ -450,7 +751,10 @@ static BOOL acquireSetupInstanceLock(void) {
450
751
  [self openURL:self.requiredUpdateURL];
451
752
  return;
452
753
  }
453
- if ([self.lastStage isEqualToString:@"waiting_for_account"] ||
754
+ if ([self.lastStage isEqualToString:@"onboarding_google"] ||
755
+ [self.lastStage isEqualToString:@"onboarding_checkout"]) {
756
+ [self sendOnboardingCommand:@{ @"action": @"refresh" }];
757
+ } else if ([self.lastStage isEqualToString:@"waiting_for_account"] ||
454
758
  [self.lastStage isEqualToString:@"account_attention"]) {
455
759
  [self openURL:self.portalURL];
456
760
  } else if ([self.lastStage isEqualToString:@"ready"]) {
@@ -510,6 +814,7 @@ static BOOL acquireSetupInstanceLock(void) {
510
814
  - (void)renderState:(NSDictionary *)state {
511
815
  NSString *stage = [state[@"stage"] isKindOfClass:NSString.class] ? state[@"stage"] : nil;
512
816
  if (!stage) return;
817
+ NSString *previousStage = self.lastStage;
513
818
  self.lastStage = stage;
514
819
  if ([state[@"portalUrl"] isKindOfClass:NSString.class]) self.portalURL = state[@"portalUrl"];
515
820
  if ([state[@"title"] isKindOfClass:NSString.class]) self.headline.stringValue = state[@"title"];
@@ -519,11 +824,86 @@ static BOOL acquireSetupInstanceLock(void) {
519
824
  NSNumber *validated = state[@"validatedFeeds"];
520
825
  NSNumber *devices = state[@"devicesFound"];
521
826
  NSNumber *rejected = state[@"rejectedFeeds"];
827
+ if ([stage isEqualToString:@"onboarding_account"]) {
828
+ [self showOnboardingPanel:self.accountPanel];
829
+ [self updateSteps:1];
830
+ self.accountSubmit.enabled = YES;
831
+ return;
832
+ }
833
+ if ([stage isEqualToString:@"onboarding_choose_account"]) {
834
+ [self showOnboardingPanel:self.chooseAccountPanel];
835
+ [self updateSteps:2];
836
+ [self.accountPicker removeAllItems];
837
+ NSArray *accounts = [state[@"accounts"] isKindOfClass:NSArray.class] ? state[@"accounts"] : @[];
838
+ for (NSDictionary *account in accounts) {
839
+ NSString *label = [NSString stringWithFormat:@"%@ (%@)", account[@"label"] ?: @"Account", account[@"role"] ?: @""];
840
+ NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:label action:nil keyEquivalent:@""];
841
+ item.representedObject = account[@"ownerUserId"];
842
+ [self.accountPicker.menu addItem:item];
843
+ }
844
+ if (accounts.count) [self.accountPicker selectItemAtIndex:0];
845
+ self.chooseAccountSubmit.enabled = accounts.count > 0;
846
+ return;
847
+ }
848
+ if ([stage isEqualToString:@"onboarding_terms"]) {
849
+ [self showOnboardingPanel:self.termsPanel];
850
+ [self updateSteps:2];
851
+ if (![previousStage isEqualToString:@"onboarding_terms"]) {
852
+ self.termsCheck.state = NSControlStateValueOff;
853
+ self.termsSubmit.enabled = NO;
854
+ }
855
+ self.termsSubmit.enabled = self.termsCheck.state == NSControlStateValueOn;
856
+ NSArray *agreements = [state[@"requiredAgreements"] isKindOfClass:NSArray.class] ? state[@"requiredAgreements"] : @[];
857
+ NSMutableArray<NSString *> *versions = [NSMutableArray array];
858
+ for (NSDictionary *agreement in agreements) {
859
+ if (![agreement isKindOfClass:NSDictionary.class]) continue;
860
+ NSString *type = [agreement[@"type"] isKindOfClass:NSString.class] ? agreement[@"type"] : @"";
861
+ NSString *label = [agreement[@"label"] isKindOfClass:NSString.class] ? agreement[@"label"] : [type stringByReplacingOccurrencesOfString:@"_" withString:@" "];
862
+ NSString *version = [agreement[@"version"] isKindOfClass:NSString.class] ? agreement[@"version"] : @"";
863
+ if (type.length && version.length) [versions addObject:[NSString stringWithFormat:@"%@ v%@", label, version]];
864
+ }
865
+ self.termsVersion.stringValue = [versions componentsJoinedByString:@" • "];
866
+ return;
867
+ }
868
+ if ([stage isEqualToString:@"onboarding_plan"]) {
869
+ [self showOnboardingPanel:self.planPanel];
870
+ [self updateSteps:3];
871
+ NSArray *plans = [state[@"plans"] isKindOfClass:NSArray.class] ? state[@"plans"] : @[];
872
+ if (plans.count > 0) {
873
+ NSDictionary *first = plans[0];
874
+ self.standardRadio.identifier = [first[@"tier"] isKindOfClass:NSString.class] ? first[@"tier"] : @"";
875
+ self.standardPlan.stringValue = [NSString stringWithFormat:@"%@\n%@/month • %@/year",
876
+ first[@"label"] ?: @"Sense Anything", first[@"monthly"] ?: @"—", first[@"annual"] ?: @"—"];
877
+ self.standardRadio.hidden = NO;
878
+ self.standardPlan.hidden = NO;
879
+ }
880
+ if (plans.count > 1) {
881
+ NSDictionary *second = plans[1];
882
+ self.multiRadio.identifier = [second[@"tier"] isKindOfClass:NSString.class] ? second[@"tier"] : @"";
883
+ self.multiPlan.stringValue = [NSString stringWithFormat:@"%@\n%@/month • %@/year",
884
+ second[@"label"] ?: @"Multi-site", second[@"monthly"] ?: @"—", second[@"annual"] ?: @"—"];
885
+ self.multiRadio.hidden = NO;
886
+ self.multiPlan.hidden = NO;
887
+ } else {
888
+ self.multiRadio.hidden = YES;
889
+ self.multiPlan.hidden = YES;
890
+ }
891
+ return;
892
+ }
893
+
894
+ [self showOnboardingPanel:nil];
522
895
  if (([stage isEqualToString:@"waiting_for_account"] ||
523
896
  [stage isEqualToString:@"account_attention"]) &&
524
897
  [state[@"displayCode"] isKindOfClass:NSString.class]) {
525
898
  self.statusValue.font = [NSFont monospacedSystemFontOfSize:25 weight:NSFontWeightSemibold];
526
899
  self.statusValue.stringValue = state[@"displayCode"];
900
+ } else if ([stage isEqualToString:@"onboarding_google"]) {
901
+ self.statusValue.stringValue = @"Waiting for Google";
902
+ self.googleReopenButton.hidden = NO;
903
+ } else if ([stage isEqualToString:@"onboarding_checkout"]) {
904
+ self.statusValue.stringValue = @"Waiting for Stripe";
905
+ } else if ([stage isEqualToString:@"onboarding_install"]) {
906
+ self.statusValue.stringValue = @"Onboarding complete";
527
907
  } else if (cameraCount.integerValue > 0) {
528
908
  self.statusValue.font = [NSFont systemFontOfSize:25 weight:NSFontWeightSemibold];
529
909
  self.statusValue.stringValue = [NSString stringWithFormat:@"%ld camera%@", cameraCount.integerValue, cameraCount.integerValue == 1 ? @"" : @"s"];
@@ -566,7 +946,16 @@ static BOOL acquireSetupInstanceLock(void) {
566
946
  self.progress.doubleValue = [self progressForStage:stage];
567
947
  [self updateSteps:[self stepForStage:stage]];
568
948
  self.closeButton.hidden = ![stage isEqualToString:@"ready"];
569
- if ([stage isEqualToString:@"waiting_for_account"]) {
949
+ if ([stage isEqualToString:@"onboarding_google"]) {
950
+ self.actionButton.title = @"Use another sign-in";
951
+ self.actionButton.enabled = YES;
952
+ } else if ([stage isEqualToString:@"onboarding_checkout"]) {
953
+ self.actionButton.title = @"Back to plans";
954
+ self.actionButton.enabled = YES;
955
+ } else if ([stage isEqualToString:@"onboarding_install"]) {
956
+ self.actionButton.title = @"Preparing cameras…";
957
+ self.actionButton.enabled = NO;
958
+ } else if ([stage isEqualToString:@"waiting_for_account"]) {
570
959
  self.actionButton.title = @"Open Sense Anything";
571
960
  self.actionButton.enabled = YES;
572
961
  } else if ([stage isEqualToString:@"account_attention"]) {
@@ -592,25 +981,29 @@ static BOOL acquireSetupInstanceLock(void) {
592
981
  }
593
982
 
594
983
  - (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;
984
+ if ([@[@"onboarding_account", @"onboarding_google"] containsObject:stage]) return 1;
985
+ if ([@[@"onboarding_terms", @"onboarding_choose_account"] containsObject:stage]) return 2;
986
+ if ([@[@"onboarding_plan", @"onboarding_checkout"] containsObject:stage]) return 3;
987
+ if ([@[@"onboarding_install", @"starting", @"waiting_for_account", @"account_attention", @"account_connected", @"discovering", @"devices_found"] containsObject:stage]) return 4;
988
+ if ([@[@"validating", @"cameras_created"] containsObject:stage]) return 5;
989
+ if ([@[@"local_ai", @"local_ai_error", @"starting_service"] containsObject:stage]) return 6;
990
+ if ([stage isEqualToString:@"ready"]) return 7;
600
991
  return 0;
601
992
  }
602
993
 
603
994
  - (double)progressForStage:(NSString *)stage {
604
995
  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,
996
+ @"onboarding_account": @10, @"onboarding_google": @12, @"onboarding_terms": @25,
997
+ @"onboarding_plan": @42, @"onboarding_checkout": @50, @"onboarding_install": @65,
998
+ @"waiting_for_account": @65, @"account_attention": @65, @"account_connected": @65, @"discovering": @68,
999
+ @"devices_found": @70, @"validating": @75, @"cameras_created": @80,
607
1000
  @"local_ai": @88, @"starting_service": @94, @"ready": @100
608
1001
  };
609
1002
  return values[stage] ? values[stage].doubleValue : 8;
610
1003
  }
611
1004
 
612
1005
  - (void)updateSteps:(NSInteger)active {
613
- NSArray<NSString *> *names = @[@"Welcome", @"Account", @"Recorder", @"Cameras", @"Local AI", @"Ready"];
1006
+ NSArray<NSString *> *names = @[@"Welcome", @"Account", @"Account setup", @"Plan & Payment", @"Recorder", @"Cameras", @"Local AI", @"Ready"];
614
1007
  for (NSInteger index = 0; index < (NSInteger)self.stepLabels.count; index++) {
615
1008
  NSTextField *field = self.stepLabels[(NSUInteger)index];
616
1009
  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
  )