feat: continuous text selection, Cmd+Enter to send, deploy.sh OTA/iPad targets
| .. | .. |
|---|
| 1 | 1 | import 'package:flutter/material.dart'; |
|---|
| 2 | +import 'package:flutter/services.dart'; |
|---|
| 2 | 3 | |
|---|
| 3 | 4 | import '../providers/providers.dart'; |
|---|
| 4 | 5 | import '../theme/app_theme.dart'; |
|---|
| .. | .. |
|---|
| 97 | 98 | const SizedBox(width: 8), |
|---|
| 98 | 99 | // Text field |
|---|
| 99 | 100 | Expanded( |
|---|
| 100 | | - child: TextField( |
|---|
| 101 | | - controller: textController, |
|---|
| 102 | | - decoration: InputDecoration( |
|---|
| 103 | | - hintText: 'Type a message...', |
|---|
| 104 | | - filled: true, |
|---|
| 105 | | - fillColor: isDark ? AppColors.darkInputBg : AppColors.lightInputBg, |
|---|
| 106 | | - border: OutlineInputBorder( |
|---|
| 107 | | - borderRadius: BorderRadius.circular(24), |
|---|
| 108 | | - borderSide: BorderSide.none, |
|---|
| 101 | + child: CallbackShortcuts( |
|---|
| 102 | + // Cmd+Enter (or Ctrl+Enter on other keyboards) sends the message. |
|---|
| 103 | + // Plain Enter still inserts a newline (textInputAction: newline). |
|---|
| 104 | + bindings: <ShortcutActivator, VoidCallback>{ |
|---|
| 105 | + const SingleActivator(LogicalKeyboardKey.enter, meta: true): |
|---|
| 106 | + onSendText, |
|---|
| 107 | + const SingleActivator(LogicalKeyboardKey.enter, control: true): |
|---|
| 108 | + onSendText, |
|---|
| 109 | + }, |
|---|
| 110 | + child: TextField( |
|---|
| 111 | + controller: textController, |
|---|
| 112 | + decoration: InputDecoration( |
|---|
| 113 | + hintText: 'Type a message...', |
|---|
| 114 | + filled: true, |
|---|
| 115 | + fillColor: isDark |
|---|
| 116 | + ? AppColors.darkInputBg |
|---|
| 117 | + : AppColors.lightInputBg, |
|---|
| 118 | + border: OutlineInputBorder( |
|---|
| 119 | + borderRadius: BorderRadius.circular(24), |
|---|
| 120 | + borderSide: BorderSide.none, |
|---|
| 121 | + ), |
|---|
| 122 | + contentPadding: const EdgeInsets.symmetric( |
|---|
| 123 | + horizontal: 16, |
|---|
| 124 | + vertical: 10, |
|---|
| 125 | + ), |
|---|
| 126 | + isDense: true, |
|---|
| 109 | 127 | ), |
|---|
| 110 | | - contentPadding: |
|---|
| 111 | | - const EdgeInsets.symmetric(horizontal: 16, vertical: 10), |
|---|
| 112 | | - isDense: true, |
|---|
| 128 | + textInputAction: TextInputAction.newline, |
|---|
| 129 | + maxLines: 4, |
|---|
| 130 | + minLines: 1, |
|---|
| 113 | 131 | ), |
|---|
| 114 | | - textInputAction: TextInputAction.newline, |
|---|
| 115 | | - maxLines: 4, |
|---|
| 116 | | - minLines: 1, |
|---|
| 117 | 132 | ), |
|---|
| 118 | 133 | ), |
|---|
| 119 | 134 | const SizedBox(width: 8), |
|---|
| .. | .. |
|---|
| 63 | 63 | color: _isUser |
|---|
| 64 | 64 | ? (isDark ? AppColors.userBubble : AppColors.lightUserBubble) |
|---|
| 65 | 65 | : (isDark |
|---|
| 66 | | - ? AppColors.assistantBubble |
|---|
| 67 | | - : AppColors.lightAssistantBubble), |
|---|
| 66 | + ? AppColors.assistantBubble |
|---|
| 67 | + : AppColors.lightAssistantBubble), |
|---|
| 68 | 68 | borderRadius: BorderRadius.only( |
|---|
| 69 | 69 | topLeft: const Radius.circular(16), |
|---|
| 70 | 70 | topRight: const Radius.circular(16), |
|---|
| .. | .. |
|---|
| 138 | 138 | ? Colors.white.withAlpha(20) |
|---|
| 139 | 139 | : Colors.black.withAlpha(15); |
|---|
| 140 | 140 | |
|---|
| 141 | | - return MarkdownBody( |
|---|
| 142 | | - data: message.content, |
|---|
| 143 | | - selectable: true, |
|---|
| 144 | | - softLineBreak: true, |
|---|
| 145 | | - styleSheet: MarkdownStyleSheet( |
|---|
| 146 | | - p: TextStyle(fontSize: 15, height: 1.4, color: textColor), |
|---|
| 147 | | - h1: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: textColor), |
|---|
| 148 | | - h2: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor), |
|---|
| 149 | | - h3: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: textColor), |
|---|
| 150 | | - strong: TextStyle(fontWeight: FontWeight.bold, color: textColor), |
|---|
| 151 | | - em: TextStyle(fontStyle: FontStyle.italic, color: textColor), |
|---|
| 152 | | - code: TextStyle( |
|---|
| 153 | | - fontSize: 13, |
|---|
| 154 | | - fontFamily: 'monospace', |
|---|
| 155 | | - color: textColor, |
|---|
| 156 | | - backgroundColor: codeBackground, |
|---|
| 157 | | - ), |
|---|
| 158 | | - codeblockDecoration: BoxDecoration( |
|---|
| 159 | | - color: codeBackground, |
|---|
| 160 | | - borderRadius: BorderRadius.circular(8), |
|---|
| 161 | | - ), |
|---|
| 162 | | - codeblockPadding: const EdgeInsets.all(10), |
|---|
| 163 | | - listBullet: TextStyle(fontSize: 15, color: textColor), |
|---|
| 164 | | - blockquoteDecoration: BoxDecoration( |
|---|
| 165 | | - border: Border( |
|---|
| 166 | | - left: BorderSide(color: AppColors.accent, width: 3), |
|---|
| 141 | + // SelectionArea gives one continuous selection across all markdown blocks |
|---|
| 142 | + // (paragraphs, lists, code). MarkdownBody's own `selectable: true` makes each |
|---|
| 143 | + // block a separate SelectableText, which is why dragging snapped paragraph |
|---|
| 144 | + // by paragraph — so it's disabled here in favour of the SelectionArea. |
|---|
| 145 | + return SelectionArea( |
|---|
| 146 | + child: MarkdownBody( |
|---|
| 147 | + data: message.content, |
|---|
| 148 | + selectable: false, |
|---|
| 149 | + softLineBreak: true, |
|---|
| 150 | + styleSheet: MarkdownStyleSheet( |
|---|
| 151 | + p: TextStyle(fontSize: 15, height: 1.4, color: textColor), |
|---|
| 152 | + h1: TextStyle( |
|---|
| 153 | + fontSize: 20, |
|---|
| 154 | + fontWeight: FontWeight.bold, |
|---|
| 155 | + color: textColor, |
|---|
| 167 | 156 | ), |
|---|
| 157 | + h2: TextStyle( |
|---|
| 158 | + fontSize: 18, |
|---|
| 159 | + fontWeight: FontWeight.bold, |
|---|
| 160 | + color: textColor, |
|---|
| 161 | + ), |
|---|
| 162 | + h3: TextStyle( |
|---|
| 163 | + fontSize: 16, |
|---|
| 164 | + fontWeight: FontWeight.bold, |
|---|
| 165 | + color: textColor, |
|---|
| 166 | + ), |
|---|
| 167 | + strong: TextStyle(fontWeight: FontWeight.bold, color: textColor), |
|---|
| 168 | + em: TextStyle(fontStyle: FontStyle.italic, color: textColor), |
|---|
| 169 | + code: TextStyle( |
|---|
| 170 | + fontSize: 13, |
|---|
| 171 | + fontFamily: 'monospace', |
|---|
| 172 | + color: textColor, |
|---|
| 173 | + backgroundColor: codeBackground, |
|---|
| 174 | + ), |
|---|
| 175 | + codeblockDecoration: BoxDecoration( |
|---|
| 176 | + color: codeBackground, |
|---|
| 177 | + borderRadius: BorderRadius.circular(8), |
|---|
| 178 | + ), |
|---|
| 179 | + codeblockPadding: const EdgeInsets.all(10), |
|---|
| 180 | + listBullet: TextStyle(fontSize: 15, color: textColor), |
|---|
| 181 | + blockquoteDecoration: BoxDecoration( |
|---|
| 182 | + border: Border(left: BorderSide(color: AppColors.accent, width: 3)), |
|---|
| 183 | + ), |
|---|
| 184 | + blockquotePadding: const EdgeInsets.only(left: 12, top: 4, bottom: 4), |
|---|
| 168 | 185 | ), |
|---|
| 169 | | - blockquotePadding: const EdgeInsets.only(left: 12, top: 4, bottom: 4), |
|---|
| 170 | | - ), |
|---|
| 171 | | - onTapLink: (text, href, title) { |
|---|
| 172 | | - if (href != null) { |
|---|
| 173 | | - final uri = Uri.tryParse(href); |
|---|
| 174 | | - if (uri != null) { |
|---|
| 175 | | - launchUrl(uri, mode: LaunchMode.externalApplication); |
|---|
| 186 | + onTapLink: (text, href, title) { |
|---|
| 187 | + if (href != null) { |
|---|
| 188 | + final uri = Uri.tryParse(href); |
|---|
| 189 | + if (uri != null) { |
|---|
| 190 | + launchUrl(uri, mode: LaunchMode.externalApplication); |
|---|
| 191 | + } |
|---|
| 176 | 192 | } |
|---|
| 177 | | - } |
|---|
| 178 | | - }, |
|---|
| 193 | + }, |
|---|
| 194 | + ), |
|---|
| 179 | 195 | ); |
|---|
| 180 | 196 | } |
|---|
| 181 | 197 | |
|---|
| .. | .. |
|---|
| 224 | 240 | color: _isUser |
|---|
| 225 | 241 | ? Colors.white.withAlpha(180) |
|---|
| 226 | 242 | : (isDark |
|---|
| 227 | | - ? AppColors.darkTextSecondary |
|---|
| 228 | | - : AppColors.lightTextSecondary), |
|---|
| 243 | + ? AppColors.darkTextSecondary |
|---|
| 244 | + : AppColors.lightTextSecondary), |
|---|
| 229 | 245 | borderRadius: BorderRadius.circular(2), |
|---|
| 230 | 246 | ), |
|---|
| 231 | 247 | ); |
|---|
| .. | .. |
|---|
| 242 | 258 | color: _isUser |
|---|
| 243 | 259 | ? Colors.white70 |
|---|
| 244 | 260 | : (isDark |
|---|
| 245 | | - ? AppColors.darkTextTertiary |
|---|
| 246 | | - : AppColors.lightTextSecondary), |
|---|
| 261 | + ? AppColors.darkTextTertiary |
|---|
| 262 | + : AppColors.lightTextSecondary), |
|---|
| 247 | 263 | ), |
|---|
| 248 | 264 | ), |
|---|
| 249 | 265 | ], |
|---|
| .. | .. |
|---|
| 289 | 305 | _imageCache.remove(_imageCache.keys.first); |
|---|
| 290 | 306 | } |
|---|
| 291 | 307 | final raw = message.imageBase64!; |
|---|
| 292 | | - _imageCache[message.id] = Uint8List.fromList(base64Decode( |
|---|
| 293 | | - raw.contains(',') ? raw.split(',').last : raw, |
|---|
| 294 | | - )); |
|---|
| 308 | + _imageCache[message.id] = Uint8List.fromList( |
|---|
| 309 | + base64Decode(raw.contains(',') ? raw.split(',').last : raw), |
|---|
| 310 | + ); |
|---|
| 295 | 311 | } |
|---|
| 296 | 312 | final bytes = _imageCache[message.id]!; |
|---|
| 297 | 313 | |
|---|
| .. | .. |
|---|
| 301 | 317 | GestureDetector( |
|---|
| 302 | 318 | onTap: () { |
|---|
| 303 | 319 | Navigator.of(context).push( |
|---|
| 304 | | - MaterialPageRoute( |
|---|
| 305 | | - builder: (_) => ImageViewer(imageBytes: bytes), |
|---|
| 306 | | - ), |
|---|
| 320 | + MaterialPageRoute(builder: (_) => ImageViewer(imageBytes: bytes)), |
|---|
| 307 | 321 | ); |
|---|
| 308 | 322 | }, |
|---|
| 309 | 323 | child: ClipRRect( |
|---|
| .. | .. |
|---|
| 348 | 362 | if (isPdf) { |
|---|
| 349 | 363 | icon = Icons.picture_as_pdf; |
|---|
| 350 | 364 | iconColor = Colors.red; |
|---|
| 351 | | - } else if (mime.contains('spreadsheet') || mime.contains('excel') || mime == 'text/csv') { |
|---|
| 365 | + } else if (mime.contains('spreadsheet') || |
|---|
| 366 | + mime.contains('excel') || |
|---|
| 367 | + mime == 'text/csv') { |
|---|
| 352 | 368 | icon = Icons.table_chart; |
|---|
| 353 | 369 | iconColor = Colors.green; |
|---|
| 354 | 370 | } else if (mime.contains('word') || mime.contains('document')) { |
|---|
| .. | .. |
|---|
| 370 | 386 | width: 260, |
|---|
| 371 | 387 | padding: const EdgeInsets.all(12), |
|---|
| 372 | 388 | decoration: BoxDecoration( |
|---|
| 373 | | - color: (_isUser ? Colors.white : Theme.of(context).colorScheme.primary).withAlpha(25), |
|---|
| 389 | + color: |
|---|
| 390 | + (_isUser ? Colors.white : Theme.of(context).colorScheme.primary) |
|---|
| 391 | + .withAlpha(25), |
|---|
| 374 | 392 | borderRadius: BorderRadius.circular(8), |
|---|
| 375 | 393 | border: Border.all( |
|---|
| 376 | 394 | color: (_isUser ? Colors.white : Colors.grey).withAlpha(50), |
|---|
| .. | .. |
|---|
| 402 | 420 | '${mime.split('/').last.toUpperCase()} - ${sizeKB} KB', |
|---|
| 403 | 421 | style: TextStyle( |
|---|
| 404 | 422 | fontSize: 11, |
|---|
| 405 | | - color: (_isUser ? Colors.white : Colors.grey).withAlpha(180), |
|---|
| 423 | + color: (_isUser ? Colors.white : Colors.grey) |
|---|
| 424 | + .withAlpha(180), |
|---|
| 406 | 425 | ), |
|---|
| 407 | 426 | ), |
|---|
| 408 | 427 | ], |
|---|
| .. | .. |
|---|
| 449 | 468 | // Other files: save to temp and share |
|---|
| 450 | 469 | final ext = _mimeToExt(mime); |
|---|
| 451 | 470 | final dir = await getTemporaryDirectory(); |
|---|
| 452 | | - final fileName = '${message.content.isNotEmpty ? message.content.replaceAll(RegExp(r'[^\w\s.-]'), '').trim() : 'file'}.$ext'; |
|---|
| 471 | + final fileName = |
|---|
| 472 | + '${message.content.isNotEmpty ? message.content.replaceAll(RegExp(r'[^\w\s.-]'), '').trim() : 'file'}.$ext'; |
|---|
| 453 | 473 | final file = File('${dir.path}/$fileName'); |
|---|
| 454 | 474 | await file.writeAsBytes(bytes); |
|---|
| 455 | 475 | |
|---|
| .. | .. |
|---|
| 458 | 478 | ); |
|---|
| 459 | 479 | } catch (e) { |
|---|
| 460 | 480 | if (context.mounted) { |
|---|
| 461 | | - ScaffoldMessenger.of(context).showSnackBar( |
|---|
| 462 | | - SnackBar(content: Text('Could not open file: $e')), |
|---|
| 463 | | - ); |
|---|
| 481 | + ScaffoldMessenger.of( |
|---|
| 482 | + context, |
|---|
| 483 | + ).showSnackBar(SnackBar(content: Text('Could not open file: $e'))); |
|---|
| 464 | 484 | } |
|---|
| 465 | 485 | } |
|---|
| 466 | 486 | } |
|---|
| .. | .. |
|---|
| 469 | 489 | const map = { |
|---|
| 470 | 490 | 'application/pdf': 'pdf', |
|---|
| 471 | 491 | 'application/msword': 'doc', |
|---|
| 472 | | - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', |
|---|
| 492 | + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': |
|---|
| 493 | + 'docx', |
|---|
| 473 | 494 | 'application/vnd.ms-excel': 'xls', |
|---|
| 474 | | - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx', |
|---|
| 495 | + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': |
|---|
| 496 | + 'xlsx', |
|---|
| 475 | 497 | 'text/plain': 'txt', |
|---|
| 476 | 498 | 'text/csv': 'csv', |
|---|
| 477 | 499 | 'text/markdown': 'md', |
|---|
| .. | .. |
|---|
| 499 | 521 | fontSize: 11, |
|---|
| 500 | 522 | color: _isUser |
|---|
| 501 | 523 | ? Colors.white60 |
|---|
| 502 | | - : (isDark |
|---|
| 503 | | - ? AppColors.darkTextTertiary |
|---|
| 504 | | - : Colors.grey.shade500), |
|---|
| 524 | + : (isDark ? AppColors.darkTextTertiary : Colors.grey.shade500), |
|---|
| 505 | 525 | ), |
|---|
| 506 | 526 | ), |
|---|
| 507 | 527 | if (message.status == MessageStatus.sending) ...[ |
|---|
| .. | .. |
|---|
| 546 | 566 | ), |
|---|
| 547 | 567 | if (onDelete != null) |
|---|
| 548 | 568 | ListTile( |
|---|
| 549 | | - leading: const Icon(Icons.delete_outline, color: AppColors.error), |
|---|
| 550 | | - title: const Text('Delete', style: TextStyle(color: AppColors.error)), |
|---|
| 569 | + leading: const Icon( |
|---|
| 570 | + Icons.delete_outline, |
|---|
| 571 | + color: AppColors.error, |
|---|
| 572 | + ), |
|---|
| 573 | + title: const Text( |
|---|
| 574 | + 'Delete', |
|---|
| 575 | + style: TextStyle(color: AppColors.error), |
|---|
| 576 | + ), |
|---|
| 551 | 577 | onTap: () { |
|---|
| 552 | 578 | Navigator.pop(ctx); |
|---|
| 553 | 579 | onDelete?.call(); |
|---|
| .. | .. |
|---|
| 3 | 3 | # |
|---|
| 4 | 4 | # Installs a personal (Pro-unlocked) PAILot build onto one or more iOS devices. |
|---|
| 5 | 5 | # |
|---|
| 6 | | -# ── Remote / "fully remote" targets ──────────────────────────────────────── |
|---|
| 7 | | -# Installation uses `xcrun devicectl device install`, which reaches the device |
|---|
| 8 | | -# over Apple's CoreDevice tunnel — USB, local network, OR any routable network |
|---|
| 9 | | -# path where the device is paired (e.g. across Tailscale). There is NO special |
|---|
| 10 | | -# remote logic to add: once a device has been paired, `--device <id>` works |
|---|
| 11 | | -# whether it sits on your desk or across the world, as long as it is awake and |
|---|
| 12 | | -# reachable. That is exactly how the iPad here installs — it is a `localNetwork` |
|---|
| 13 | | -# device, never plugged in. (First-time discovery of a NEW device uses .local / |
|---|
| 14 | | -# mDNS, which does not cross networks, so a device must be paired at least once |
|---|
| 15 | | -# on the local network before it can be driven fully remotely.) |
|---|
| 16 | | -# Check reachability any time with: bash tools/deploy.sh --check |
|---|
| 6 | +# ── Two transports ───────────────────────────────────────────────────────── |
|---|
| 7 | +# 1. devicectl (default): `xcrun devicectl device install`. Reaches the device |
|---|
| 8 | +# over Apple's CoreDevice tunnel, but DISCOVERY is mDNS/Bonjour — so it only |
|---|
| 9 | +# works when the Mac and device share the local network (USB or same wifi). |
|---|
| 10 | +# It does NOT traverse Tailscale: a device on a remote network shows |
|---|
| 11 | +# "unavailable" and both its .ts.net name and tailnet IP are rejected. |
|---|
| 12 | +# |
|---|
| 13 | +# 2. --ota (fully remote): build + publish the IPA to the aibroker-ota hub, which |
|---|
| 14 | +# serves it over Tailscale (HTTPS via Tailscale Serve). Open the returned URL |
|---|
| 15 | +# in Safari on ANY device on the tailnet and tap Install. This is the path to |
|---|
| 16 | +# use when you are NOT on the local wifi. Requires `aibroker ota up` once. |
|---|
| 17 | +# (The device UDID must still be in the provisioning profile.) |
|---|
| 17 | 18 | # |
|---|
| 18 | 19 | # ── Usage ────────────────────────────────────────────────────────────────── |
|---|
| 19 | | -# bash tools/deploy.sh # Matthias' iPhone (default) |
|---|
| 20 | +# bash tools/deploy.sh # Matthias' iPhone (default, local network) |
|---|
| 20 | 21 | # bash tools/deploy.sh -a # Amelie's iPhone |
|---|
| 21 | 22 | # bash tools/deploy.sh -i # Matthias' iPad |
|---|
| 22 | 23 | # bash tools/deploy.sh --all # every known device (continues on failure) |
|---|
| 23 | | -# bash tools/deploy.sh --device ID # explicit CoreDevice identifier (any remote device) |
|---|
| 24 | +# bash tools/deploy.sh --device ID # explicit CoreDevice id (local network) |
|---|
| 25 | +# bash tools/deploy.sh --ota # remote install over Tailscale (prints URL) |
|---|
| 24 | 26 | # bash tools/deploy.sh --build # force a fresh Pro-unlocked build first |
|---|
| 25 | 27 | # bash tools/deploy.sh --check # list devices devicectl can currently reach |
|---|
| 26 | 28 | set -euo pipefail |
|---|
| .. | .. |
|---|
| 29 | 31 | APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" |
|---|
| 30 | 32 | cd "$APP_DIR" |
|---|
| 31 | 33 | |
|---|
| 32 | | -IPA="build/ios/ipa/pailot.ipa" |
|---|
| 34 | +IPA_DIR="build/ios/ipa" |
|---|
| 35 | +IPA="" # resolved after build (flutter names it PAILot.ipa) |
|---|
| 36 | +resolve_ipa() { IPA="$(ls -t "$IPA_DIR"/*.ipa 2>/dev/null | head -1 || true)"; } |
|---|
| 33 | 37 | |
|---|
| 34 | 38 | # Known devices — CoreDevice identifiers from `xcrun devicectl list devices`. |
|---|
| 35 | 39 | IPHONE_MATTHIAS="8708CADC-3330-50B6-AA0A-1655526A573A" |
|---|
| .. | .. |
|---|
| 38 | 42 | |
|---|
| 39 | 43 | TARGETS=() # entries: "identifier|label" |
|---|
| 40 | 44 | FORCE_BUILD=false |
|---|
| 45 | +OTA=false |
|---|
| 41 | 46 | |
|---|
| 42 | 47 | while [[ $# -gt 0 ]]; do |
|---|
| 43 | 48 | case "$1" in |
|---|
| .. | .. |
|---|
| 50 | 55 | "$IPHONE_AMELIE|Amelie's iPhone") |
|---|
| 51 | 56 | shift ;; |
|---|
| 52 | 57 | --device) TARGETS+=("$2|device $2"); shift 2 ;; |
|---|
| 58 | + --ota) OTA=true; shift ;; |
|---|
| 53 | 59 | --build) FORCE_BUILD=true; shift ;; |
|---|
| 54 | 60 | --check) echo "=== Devices devicectl can reach ==="; xcrun devicectl list devices; exit 0 ;; |
|---|
| 55 | | - -h|--help) sed -n '2,30p' "$0"; exit 0 ;; |
|---|
| 61 | + -h|--help) sed -n '2,33p' "$0"; exit 0 ;; |
|---|
| 56 | 62 | *) echo "Unknown option: $1" >&2; exit 2 ;; |
|---|
| 57 | 63 | esac |
|---|
| 58 | 64 | done |
|---|
| 59 | | - |
|---|
| 60 | | -# Default target: Matthias' iPhone. |
|---|
| 61 | | -if [[ ${#TARGETS[@]} -eq 0 ]]; then |
|---|
| 62 | | - TARGETS+=("$IPHONE_MATTHIAS|Matthias' iPhone") |
|---|
| 63 | | -fi |
|---|
| 64 | 65 | |
|---|
| 65 | 66 | # ── Build a Pro-unlocked personal IPA when needed ────────────────────────── |
|---|
| 66 | 67 | # PAILOT_PRO unlocks Pro for personal / sideloaded installs ONLY. App Store |
|---|
| 67 | 68 | # builds go through tools/build-appstore.sh / tools/release.sh, which do NOT |
|---|
| 68 | 69 | # pass this define — paying users still see the paywall. |
|---|
| 69 | | -if [[ "$FORCE_BUILD" == true || ! -f "$IPA" ]]; then |
|---|
| 70 | +resolve_ipa |
|---|
| 71 | +if [[ "$FORCE_BUILD" == true || -z "$IPA" ]]; then |
|---|
| 70 | 72 | echo "=== Building Pro-unlocked IPA (PAILOT_PRO=true) ===" |
|---|
| 71 | 73 | flutter build ipa --release --no-pub --export-method development \ |
|---|
| 72 | 74 | --no-tree-shake-icons --dart-define=PAILOT_PRO=true |
|---|
| .. | .. |
|---|
| 75 | 77 | plutil -replace Name -string "PAILot" "$ARCHIVE" 2>/dev/null || true |
|---|
| 76 | 78 | plutil -replace SchemeName -string "PAILot" "$ARCHIVE" 2>/dev/null || true |
|---|
| 77 | 79 | fi |
|---|
| 80 | + resolve_ipa |
|---|
| 78 | 81 | else |
|---|
| 79 | 82 | echo "=== Using existing IPA: $IPA ===" |
|---|
| 80 | 83 | echo " (Pro status depends on how it was built — use --build to force a fresh Pro build)" |
|---|
| 84 | +fi |
|---|
| 85 | +[[ -n "$IPA" && -f "$IPA" ]] || { echo "ERROR: no IPA found in $IPA_DIR" >&2; exit 1; } |
|---|
| 86 | + |
|---|
| 87 | +# ── OTA (remote over Tailscale) ──────────────────────────────────────────── |
|---|
| 88 | +if [[ "$OTA" == true ]]; then |
|---|
| 89 | + VERSION="$(grep -E '^version:' pubspec.yaml | sed -E 's/version:[[:space:]]*//; s/\+.*//' | head -1)" |
|---|
| 90 | + echo "" |
|---|
| 91 | + echo "=== Publishing to aibroker-ota hub (Tailscale) ===" |
|---|
| 92 | + resp="$(curl -sf -X POST http://127.0.0.1:8765/api/apps \ |
|---|
| 93 | + -F slug=pailot -F name=PAILot -F bundleId=com.tekmidian.pailot \ |
|---|
| 94 | + -F "version=${VERSION:-1.0.0}" -F platform=ios -F "file=@${IPA}" 2>&1)" || { |
|---|
| 95 | + echo "ERROR: publish failed — is the hub up? Run: aibroker ota up" >&2; exit 1; } |
|---|
| 96 | + # Derive the tailnet host for the HTTPS install URL. |
|---|
| 97 | + ts="tailscale"; command -v tailscale >/dev/null 2>&1 || ts="/Applications/Tailscale.app/Contents/MacOS/Tailscale" |
|---|
| 98 | + host="$("$ts" status --json 2>/dev/null | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).Self.DNSName.replace(/\.$/,""))}catch{}})' 2>/dev/null || true)" |
|---|
| 99 | + echo "" |
|---|
| 100 | + if [[ -n "$host" ]]; then |
|---|
| 101 | + echo " Install on any tailnet device — open in Safari:" |
|---|
| 102 | + echo " https://${host}/install/pailot/" |
|---|
| 103 | + else |
|---|
| 104 | + echo " Published. Open the install page in Safari on a tailnet device:" |
|---|
| 105 | + echo " https://<your-mac-tailnet-host>/install/pailot/" |
|---|
| 106 | + fi |
|---|
| 107 | + echo "" |
|---|
| 108 | + echo "=== Done (OTA). Tap Install in Safari, then force-quit & reopen PAILot. ===" |
|---|
| 109 | + exit 0 |
|---|
| 110 | +fi |
|---|
| 111 | + |
|---|
| 112 | +# ── devicectl install (local network) ────────────────────────────────────── |
|---|
| 113 | +# Default target: Matthias' iPhone. |
|---|
| 114 | +if [[ ${#TARGETS[@]} -eq 0 ]]; then |
|---|
| 115 | + TARGETS+=("$IPHONE_MATTHIAS|Matthias' iPhone") |
|---|
| 81 | 116 | fi |
|---|
| 82 | 117 | |
|---|
| 83 | 118 | install_one() { |
|---|
| .. | .. |
|---|
| 87 | 122 | if xcrun devicectl device install app --device "$id" "$IPA"; then |
|---|
| 88 | 123 | echo " OK: $label" |
|---|
| 89 | 124 | else |
|---|
| 90 | | - echo " WARNING: install on $label failed — is it awake & reachable?" |
|---|
| 91 | | - echo " Try: bash tools/deploy.sh --check" |
|---|
| 125 | + echo " WARNING: install on $label failed — awake & on the same network?" |
|---|
| 126 | + echo " Off the local wifi? Use: bash tools/deploy.sh --ota" |
|---|
| 92 | 127 | return 1 |
|---|
| 93 | 128 | fi |
|---|
| 94 | 129 | } |
|---|