@hasna/recordings 0.3.13 → 0.5.0

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.
Files changed (148) hide show
  1. package/README.md +279 -43
  2. package/bun.lock +37 -38
  3. package/contracts/v1/fixtures.json +1206 -0
  4. package/dist/cli/index.js +1909 -619
  5. package/dist/cli/macos-shortcut.d.ts +2 -2
  6. package/dist/contracts/hosted-v1.d.ts +105 -0
  7. package/dist/contracts/hosted-v1.d.ts.map +1 -0
  8. package/dist/contracts/hosted-v1.js +41 -0
  9. package/dist/contracts/stream-v1.d.ts +117 -0
  10. package/dist/contracts/stream-v1.d.ts.map +1 -0
  11. package/dist/contracts/stream-v1.js +35 -0
  12. package/dist/db/database.d.ts.map +1 -1
  13. package/dist/db/pg-migrations.d.ts.map +1 -1
  14. package/dist/db/recordings.d.ts +6 -0
  15. package/dist/db/recordings.d.ts.map +1 -1
  16. package/dist/hosted/index.d.ts +58 -0
  17. package/dist/hosted/index.d.ts.map +1 -0
  18. package/dist/hosted/index.js +266 -0
  19. package/dist/hosted/transport.d.ts +36 -0
  20. package/dist/hosted/transport.d.ts.map +1 -0
  21. package/dist/hosted-v1-aavn7ktb.js +4114 -0
  22. package/dist/hosted-v1-gdr9extc.js +84 -0
  23. package/dist/http/client.d.ts +183 -22
  24. package/dist/http/client.d.ts.map +1 -1
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2879 -2288
  28. package/dist/lib/audio-artifact-storage.d.ts +98 -0
  29. package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
  30. package/dist/lib/capture-probe.d.ts +3 -3
  31. package/dist/lib/capture-probe.d.ts.map +1 -1
  32. package/dist/lib/config.d.ts.map +1 -1
  33. package/dist/lib/local-opt-in.d.ts +132 -0
  34. package/dist/lib/local-opt-in.d.ts.map +1 -0
  35. package/dist/lib/macos-bundle.d.ts +1 -1
  36. package/dist/lib/persistence-probe.d.ts +56 -17
  37. package/dist/lib/persistence-probe.d.ts.map +1 -1
  38. package/dist/lib/transcriber.d.ts.map +1 -1
  39. package/dist/mcp/index.d.ts.map +1 -1
  40. package/dist/mcp/index.js +1713 -459
  41. package/dist/sdk/index.d.ts +13 -5
  42. package/dist/sdk/index.d.ts.map +1 -1
  43. package/dist/sdk/index.js +904 -1
  44. package/dist/sdk/resolve.d.ts +53 -0
  45. package/dist/sdk/resolve.d.ts.map +1 -0
  46. package/dist/sdk/v1.generated.d.ts +6 -0
  47. package/dist/sdk/v1.generated.d.ts.map +1 -1
  48. package/dist/server/client-ip.d.ts +39 -0
  49. package/dist/server/client-ip.d.ts.map +1 -0
  50. package/dist/server/cloud-config.d.ts +12 -0
  51. package/dist/server/cloud-config.d.ts.map +1 -1
  52. package/dist/server/cloud-readiness.d.ts.map +1 -1
  53. package/dist/server/cloud.d.ts +20 -2
  54. package/dist/server/cloud.d.ts.map +1 -1
  55. package/dist/server/index.js +590 -287
  56. package/dist/server/migrate-command.d.ts +1 -1
  57. package/dist/server/migrate-command.d.ts.map +1 -1
  58. package/dist/server/openapi.d.ts +21 -0
  59. package/dist/server/openapi.d.ts.map +1 -1
  60. package/dist/server/repo.d.ts.map +1 -1
  61. package/dist/server/serve.d.ts.map +1 -1
  62. package/dist/storage.d.ts +2 -2
  63. package/dist/storage.d.ts.map +1 -1
  64. package/dist/storage.js +1358 -874
  65. package/dist/store.d.ts +12 -3
  66. package/dist/store.d.ts.map +1 -1
  67. package/dist/types/index.d.ts +15 -0
  68. package/dist/types/index.d.ts.map +1 -1
  69. package/docs/hosted-sdk.md +71 -0
  70. package/docs/wire-contracts.md +24 -0
  71. package/package.json +33 -11
  72. package/packaging/macos/build_release_pkg.sh +3 -3
  73. package/packaging/macos/managed_bootstrap.sh +4 -4
  74. package/packaging/macos/scripts/postinstall +2 -2
  75. package/packaging/macos/scripts/preinstall +2 -2
  76. package/scripts/build_companion_cli.sh +7 -1
  77. package/scripts/ci-linux-suite.ts +23 -13
  78. package/scripts/install_macos_app.sh +27 -24
  79. package/scripts/macos_artifact.ts +60 -49
  80. package/scripts/migrate.ts +4 -20
  81. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
  82. package/scripts/native/recordings_fs_guard.c +36 -4
  83. package/scripts/native-core-receipt.py +171 -0
  84. package/scripts/native_fs_guard.ts +2 -0
  85. package/scripts/release-suite-gate.ts +227 -150
  86. package/scripts/resolve_tailscale_cli.sh +24 -3
  87. package/scripts/smoke_macos_app.sh +21 -21
  88. package/src/native/Recordings/App/ContentView.swift +53 -68
  89. package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
  90. package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
  91. package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
  92. package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
  93. package/src/native/Recordings/App/RecordingsApp.swift +185 -9
  94. package/src/native/Recordings/App/RecordingsListView.swift +60 -112
  95. package/src/native/Recordings/App/RecordingsStore.swift +124 -109
  96. package/src/native/Recordings/App/Theme.swift +73 -61
  97. package/src/native/Recordings/Package.swift +1 -0
  98. package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
  99. package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
  100. package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
  101. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
  102. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
  103. package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
  104. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +539 -89
  105. package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
  106. package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
  107. package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +112 -0
  108. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
  109. package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
  110. package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
  111. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
  112. package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
  113. package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
  114. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +454 -76
  115. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
  116. package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
  117. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
  118. package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
  119. package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
  120. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +1 -1
  121. package/src/native/Recordings/RecordingsTests/RecordingFrozenPasteTargetTests.swift +50 -0
  122. package/src/native/Recordings/RecordingsTests/RecordingPasteTargetTrackerTests.swift +48 -0
  123. package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +308 -0
  124. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
  125. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +123 -21
  126. package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
  127. package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
  128. package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
  129. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
  130. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
  131. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
  132. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
  133. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
  134. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
  135. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
  136. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
  137. package/src/native/Recordings/build.sh +8 -7
  138. package/dist/__tests__/helpers/installer-guard-execution.d.ts +0 -22
  139. package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +0 -1
  140. package/dist/__tests__/helpers/installer-preflight.d.ts +0 -22
  141. package/dist/__tests__/helpers/installer-preflight.d.ts.map +0 -1
  142. package/dist/__tests__/helpers/native-fs-guard.d.ts +0 -2
  143. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +0 -1
  144. package/dist/__tests__/helpers/source-assertions.d.ts +0 -171
  145. package/dist/__tests__/helpers/source-assertions.d.ts.map +0 -1
  146. package/dist/__tests__/preload.d.ts +0 -2
  147. package/dist/__tests__/preload.d.ts.map +0 -1
  148. package/src/native/Recordings/App/SidebarView.swift +0 -212
@@ -77,6 +77,27 @@ recordings_tailscale_stat_owner_and_mode() {
77
77
  esac
78
78
  }
79
79
 
80
+ # macOS does not ship /usr/bin/realpath on every supported host. Resolve with
81
+ # Bash's physical-directory builtins, without a PATH tool or interpreter. The
82
+ # callers still reject leaf symlinks and compare this physical path exactly.
83
+ recordings_tailscale_physical_path() {
84
+ local candidate="$1"
85
+ local parent
86
+ local leaf
87
+
88
+ recordings_validate_tailscale_cli_shape "$candidate" || return 1
89
+ if [ -d "$candidate" ]; then
90
+ (unset CDPATH; builtin cd -P -- "$candidate" && builtin pwd -P)
91
+ return
92
+ fi
93
+ [ -e "$candidate" ] && [ ! -L "$candidate" ] || return 1
94
+ parent="${candidate%/*}"
95
+ leaf="${candidate##*/}"
96
+ [ -n "$parent" ] || parent=/
97
+ parent="$(unset CDPATH; builtin cd -P -- "$parent" && builtin pwd -P)" || return 1
98
+ printf '%s/%s\n' "${parent%/}" "$leaf"
99
+ }
100
+
80
101
  recordings_validate_trusted_tailscale_app_cli() {
81
102
  local candidate="$1"
82
103
  local real_host_kernel="$2"
@@ -96,7 +117,7 @@ recordings_validate_trusted_tailscale_app_cli() {
96
117
  echo "Trusted Tailscale CLI must be a canonical non-symlink trusted executable." >&2
97
118
  return 1
98
119
  fi
99
- canonical="$(/usr/bin/realpath "$candidate" 2>/dev/null)" || {
120
+ canonical="$(recordings_tailscale_physical_path "$candidate" 2>/dev/null)" || {
100
121
  echo "Trusted Tailscale CLI could not be resolved canonically." >&2
101
122
  return 1
102
123
  }
@@ -145,7 +166,7 @@ recordings_validate_private_tailscale_snapshot_parent() {
145
166
  echo "Tailscale snapshot parent must be an existing private directory." >&2
146
167
  return 1
147
168
  fi
148
- canonical="$(/usr/bin/realpath "$snapshot_parent" 2>/dev/null)" || {
169
+ canonical="$(recordings_tailscale_physical_path "$snapshot_parent" 2>/dev/null)" || {
149
170
  echo "Tailscale snapshot parent could not be resolved canonically." >&2
150
171
  return 1
151
172
  }
@@ -270,7 +291,7 @@ recordings_verify_official_tailscale_app_and_cli() {
270
291
  echo "Trusted Tailscale app must be a canonical non-symlink directory." >&2
271
292
  return 1
272
293
  fi
273
- canonical_app="$(/usr/bin/realpath "$app" 2>/dev/null)" || {
294
+ canonical_app="$(recordings_tailscale_physical_path "$app" 2>/dev/null)" || {
274
295
  echo "Trusted Tailscale app could not be resolved canonically." >&2
275
296
  return 1
276
297
  }
@@ -9,7 +9,7 @@ if [ "$#" -ge 3 ]; then
9
9
  SMOKE_VARIANT="${4:-full}"
10
10
  ;;
11
11
  *)
12
- echo "Usage: $0 <HasnaRecordings.app> <explicit absolute Bun executable path> [--variant full|bar]" >&2
12
+ echo "Usage: $0 <Hasna Recordings.app> <explicit absolute Bun executable path> [--variant full|bar]" >&2
13
13
  exit 2
14
14
  ;;
15
15
  esac
@@ -51,7 +51,7 @@ HOST_PLATFORM="$("$HOST_UNAME_EXECUTABLE" -s)"
51
51
  readonly HOST_PLATFORM
52
52
 
53
53
  if [ "$HOST_PLATFORM" != "Darwin" ] && [ "${RECORDINGS_TEST_SMOKE_ALLOW_NON_DARWIN:-0}" != "1" ]; then
54
- echo "HasnaRecordings.app runtime smoke is only supported on macOS." >&2
54
+ echo "Hasna Recordings.app runtime smoke is only supported on macOS." >&2
55
55
  exit 1
56
56
  fi
57
57
 
@@ -123,12 +123,12 @@ HOME_DIRECTORY="${HOME:-}"
123
123
  case "$HOME_DIRECTORY" in
124
124
  /*) ;;
125
125
  *)
126
- echo "HasnaRecordings.app runtime smoke requires an absolute HOME path." >&2
126
+ echo "Hasna Recordings.app runtime smoke requires an absolute HOME path." >&2
127
127
  exit 1
128
128
  ;;
129
129
  esac
130
130
  if [ ! -d "$HOME_DIRECTORY" ]; then
131
- echo "HasnaRecordings.app runtime smoke HOME is not a directory: $HOME_DIRECTORY" >&2
131
+ echo "Hasna Recordings.app runtime smoke HOME is not a directory: $HOME_DIRECTORY" >&2
132
132
  exit 1
133
133
  fi
134
134
 
@@ -136,7 +136,7 @@ APP_PARENT="$(cd -P "$("$DIRNAME_EXECUTABLE" "$1")" && "$PWD_EXECUTABLE" -P)"
136
136
  APP_PATH="$APP_PARENT/$("$BASENAME_EXECUTABLE" "$1")"
137
137
  EXECUTABLE="$APP_PATH/Contents/MacOS/Recordings"
138
138
  if [ ! -x "$EXECUTABLE" ]; then
139
- echo "HasnaRecordings.app runtime smoke executable is missing: $EXECUTABLE" >&2
139
+ echo "Hasna Recordings.app runtime smoke executable is missing: $EXECUTABLE" >&2
140
140
  exit 1
141
141
  fi
142
142
 
@@ -317,7 +317,7 @@ cleanup() {
317
317
  fi
318
318
  if [ -n "$SMOKE_APP_PID" ] && [ -n "$SMOKE_APP_PID_START_IDENTITY" ]; then
319
319
  terminate_verified_process \
320
- "HasnaRecordings.app" \
320
+ "Hasna Recordings.app" \
321
321
  "$SMOKE_APP_PID" \
322
322
  "$EXECUTABLE" \
323
323
  "$SMOKE_APP_PID_START_IDENTITY" || true
@@ -377,7 +377,7 @@ run_smoke() {
377
377
  if ! SMOKE_APP_IDENTITIES_BEFORE_LAUNCH="$(
378
378
  process_records_for_exact_executable "$EXECUTABLE"
379
379
  )"; then
380
- echo "HasnaRecordings.app runtime smoke ${mode} could not inventory exact app processes before launch." >&2
380
+ echo "Hasna Recordings.app runtime smoke ${mode} could not inventory exact app processes before launch." >&2
381
381
  return 1
382
382
  fi
383
383
 
@@ -399,24 +399,24 @@ run_smoke() {
399
399
  result_pid="$("$ENV_EXECUTABLE" -i HOME="$HOME_DIRECTORY" PATH="$SANITIZED_PATH" TMPDIR="$WORK_DIR" \
400
400
  "$BUN_EXECUTABLE" -e 'const value = await Bun.file(process.argv[1]).json(); console.log(value.processIdentifier)' "$output")"
401
401
  if ! [[ "$result_pid" =~ ^[1-9][0-9]*$ ]] || ! kill -0 "$result_pid" 2>/dev/null; then
402
- echo "HasnaRecordings.app runtime smoke ${mode} reported a process that is not running." >&2
402
+ echo "Hasna Recordings.app runtime smoke ${mode} reported a process that is not running." >&2
403
403
  return 1
404
404
  fi
405
405
  if ! result_pid_start_identity="$(
406
406
  capture_process_start_identity "$result_pid" "$EXECUTABLE"
407
407
  )"; then
408
- echo "HasnaRecordings.app runtime smoke ${mode} reported a process outside the exact app path." >&2
408
+ echo "Hasna Recordings.app runtime smoke ${mode} reported a process outside the exact app path." >&2
409
409
  return 1
410
410
  fi
411
411
  if process_identity_existed_before_launch "$result_pid" "$result_pid_start_identity"; then
412
- echo "HasnaRecordings.app runtime smoke ${mode} reported an app process that predated this launch." >&2
412
+ echo "Hasna Recordings.app runtime smoke ${mode} reported an app process that predated this launch." >&2
413
413
  return 1
414
414
  fi
415
415
  if [ -n "$SMOKE_APP_PID" ] && {
416
416
  [ "$result_pid" != "$SMOKE_APP_PID" ] ||
417
417
  [ "$result_pid_start_identity" != "$SMOKE_APP_PID_START_IDENTITY" ];
418
418
  }; then
419
- echo "HasnaRecordings.app runtime smoke ${mode} evidence did not match the exact launched app process." >&2
419
+ echo "Hasna Recordings.app runtime smoke ${mode} evidence did not match the exact launched app process." >&2
420
420
  return 1
421
421
  fi
422
422
  SMOKE_APP_PID="$result_pid"
@@ -429,19 +429,19 @@ run_smoke() {
429
429
  break
430
430
  fi
431
431
  if ! kill -0 "$SMOKE_PID" 2>/dev/null; then
432
- echo "HasnaRecordings.app runtime smoke ${mode} exited without evidence." >&2
432
+ echo "Hasna Recordings.app runtime smoke ${mode} exited without evidence." >&2
433
433
  "$SED_EXECUTABLE" -n '1,120p' "$log" >&2
434
434
  return 1
435
435
  fi
436
436
  "$SLEEP_EXECUTABLE" 0.1
437
437
  done
438
438
  if [ ! -f "$output" ]; then
439
- echo "HasnaRecordings.app runtime smoke ${mode} timed out." >&2
439
+ echo "Hasna Recordings.app runtime smoke ${mode} timed out." >&2
440
440
  "$SED_EXECUTABLE" -n '1,120p' "$log" >&2
441
441
  return 1
442
442
  fi
443
443
  if [ -z "$SMOKE_APP_PID" ]; then
444
- echo "HasnaRecordings.app runtime smoke ${mode} did not bind evidence to the exact app process." >&2
444
+ echo "Hasna Recordings.app runtime smoke ${mode} did not bind evidence to the exact app process." >&2
445
445
  return 1
446
446
  fi
447
447
 
@@ -546,7 +546,7 @@ run_smoke() {
546
546
  ' "$output"
547
547
 
548
548
  if [ -e "$completion" ]; then
549
- echo "HasnaRecordings.app runtime smoke ${mode} produced completion before the challenge." >&2
549
+ echo "Hasna Recordings.app runtime smoke ${mode} produced completion before the challenge." >&2
550
550
  return 1
551
551
  fi
552
552
  challenge="$("$ENV_EXECUTABLE" -i HOME="$HOME_DIRECTORY" PATH="$SANITIZED_PATH" TMPDIR="$WORK_DIR" \
@@ -554,7 +554,7 @@ run_smoke() {
554
554
  if ! rechecked_start_identity="$(
555
555
  capture_process_start_identity "$result_pid" "$EXECUTABLE"
556
556
  )" || [ "$rechecked_start_identity" != "$result_pid_start_identity" ]; then
557
- echo "HasnaRecordings.app runtime smoke ${mode} process identity changed before completion challenge." >&2
557
+ echo "Hasna Recordings.app runtime smoke ${mode} process identity changed before completion challenge." >&2
558
558
  return 1
559
559
  fi
560
560
  write_atomic_text_file "$acknowledgement" "$challenge"
@@ -564,7 +564,7 @@ run_smoke() {
564
564
  "$SLEEP_EXECUTABLE" 0.1
565
565
  done
566
566
  if kill -0 "$SMOKE_PID" 2>/dev/null; then
567
- echo "HasnaRecordings.app runtime smoke ${mode} completion handshake timed out." >&2
567
+ echo "Hasna Recordings.app runtime smoke ${mode} completion handshake timed out." >&2
568
568
  return 1
569
569
  fi
570
570
  if wait "$SMOKE_PID" 2>/dev/null; then
@@ -577,7 +577,7 @@ run_smoke() {
577
577
  SMOKE_APP_PID=""
578
578
  SMOKE_APP_PID_START_IDENTITY=""
579
579
  if [ "$wrapper_status" -ne 0 ]; then
580
- echo "HasnaRecordings.app runtime smoke ${mode} open -W wrapper exited unsuccessfully (${wrapper_status})." >&2
580
+ echo "Hasna Recordings.app runtime smoke ${mode} open -W wrapper exited unsuccessfully (${wrapper_status})." >&2
581
581
  "$SED_EXECUTABLE" -n '1,120p' "$log" >&2
582
582
  return 1
583
583
  fi
@@ -602,7 +602,7 @@ run_smoke() {
602
602
  process.exit(1);
603
603
  }
604
604
  ' "$completion"; then
605
- echo "HasnaRecordings.app runtime smoke ${mode} did not provide a valid completion response." >&2
605
+ echo "Hasna Recordings.app runtime smoke ${mode} did not provide a valid completion response." >&2
606
606
  return 1
607
607
  fi
608
608
 
@@ -613,7 +613,7 @@ run_smoke normal
613
613
  run_smoke permission-helper
614
614
  run_smoke resolver
615
615
  if [ "$SMOKE_VARIANT" = "bar" ]; then
616
- echo "HasnaRecordings.app runtime smoke passed (bar variant): menu bar, windowless launch, helper isolation, and packaged resolver."
616
+ echo "Hasna Recordings.app runtime smoke passed (bar variant): menu bar, windowless launch, helper isolation, and packaged resolver."
617
617
  else
618
- echo "HasnaRecordings.app runtime smoke passed: menu bar, retained window, helper isolation, and packaged resolver."
618
+ echo "Hasna Recordings.app runtime smoke passed: menu bar, retained window, helper isolation, and packaged resolver."
619
619
  fi
@@ -1,83 +1,68 @@
1
1
  import SwiftUI
2
2
  import RecordingsLib
3
3
 
4
- /// Root layout: a narrow violet Liquid-Glass sidebar on the left, and ONE continuous canvas
5
- /// on the right — a compact header line, then either the Record workspace (default) or the
6
- /// recordings library (list | detail), separated only by hairline dividers. No boxed panels.
4
+ enum RecorderPage {
5
+ case recorder, history, settings, advanced
6
+ var size: NSSize {
7
+ switch self {
8
+ case .recorder: NSSize(width: 224, height: 244)
9
+ case .history: NSSize(width: 500, height: 390)
10
+ case .settings: NSSize(width: 420, height: 350)
11
+ case .advanced: NSSize(width: 520, height: 540)
12
+ }
13
+ }
14
+ }
15
+
16
+ /// One retained app window and one shared glass/title bar across every destination.
7
17
  struct ContentView: View {
8
18
  @ObservedObject var store: RecordingsStore
9
- @Environment(\.colorScheme) private var colorScheme
19
+ @ObservedObject var state: RecordingsAppState
20
+ let windowAction: (NSWindow.ButtonType) -> Void
10
21
 
11
22
  var body: some View {
12
- HStack(spacing: 0) {
13
- SidebarView(store: store)
14
- .frame(width: Theme.sidebarWidth)
15
- .background(Theme.sidebarGradient(colorScheme).ignoresSafeArea())
16
-
17
- VStack(spacing: 0) {
18
- HeaderBar(store: store)
19
- Divider().opacity(0.5)
20
-
21
- switch store.pane {
22
- case .record:
23
- RecordWorkspaceView(store: store)
24
- case .library:
25
- HSplitView {
26
- RecordingsListView(store: store)
27
- .frame(minWidth: 280, idealWidth: 340)
28
- RecordingDetailView(store: store)
29
- .frame(minWidth: 420)
30
- }
31
- }
23
+ VStack(spacing: 0) {
24
+ HStack(spacing: 7) {
25
+ windowDot(.red, label: "Close", kind: .closeButton)
26
+ windowDot(.yellow, label: "Minimize", kind: .miniaturizeButton)
27
+ windowDot(.green, label: "Zoom", kind: .zoomButton)
28
+ Spacer(minLength: 6)
29
+ Text("Hasna Recordings").font(.system(size: 11, weight: .medium)).foregroundStyle(.secondary)
30
+ Spacer(minLength: 0)
31
+ }.padding(.horizontal, 12).frame(height: 36)
32
+ switch state.page {
33
+ case .recorder:
34
+ RecordWorkspaceView(store: store)
35
+ HStack {
36
+ GlassIconButton(symbol: "clock", label: "Recordings", action: state.openHistory)
37
+ Spacer()
38
+ GlassIconButton(symbol: "gearshape", label: "Settings", action: state.openSettings)
39
+ }.padding(.horizontal, 18).padding(.bottom, 14)
40
+ case .history:
41
+ RecordingsListView(store: store, close: state.openRecordings)
42
+ case .settings:
43
+ RecorderSettingsView(store: store, close: state.openRecordings, advanced: state.openAdvancedSettings)
44
+ case .advanced:
45
+ HStack {
46
+ Button(action: state.openSettings) { Label("Settings", systemImage: "chevron.left") }.buttonStyle(GlassButtonStyle())
47
+ Spacer()
48
+ }.padding(.horizontal, 16)
49
+ SettingsView(engine: store.engine, shortcuts: store.voiceShortcuts, preferences: store.preferences)
32
50
  }
33
- .background(Theme.canvas(colorScheme))
34
51
  }
35
- .frame(minWidth: 940, minHeight: 620)
36
- .ignoresSafeArea(.container, edges: .top)
52
+ .font(.system(size: 13))
53
+ .frame(width: state.page.size.width, height: state.page.size.height)
54
+ .background(FrostedBackground())
55
+ .ignoresSafeArea()
37
56
  .onAppear { store.loadLibrary() }
38
- .alert("Recordings Error", isPresented: Binding(
39
- get: { store.operationError != nil },
40
- set: { if !$0 { store.operationError = nil } }
41
- )) {
57
+ .alert("Recordings Error", isPresented: Binding(get: { store.operationError != nil }, set: { if !$0 { store.operationError = nil } })) {
42
58
  Button("OK", role: .cancel) { store.operationError = nil }
43
- } message: {
44
- Text(store.operationError ?? "The operation failed.")
45
- }
59
+ } message: { Text(store.operationError ?? "The operation failed.") }
46
60
  }
47
- }
48
61
 
49
- /// Compact header line: "12 recordings · Updated 3m ago" + actions.
50
- private struct HeaderBar: View {
51
- @ObservedObject var store: RecordingsStore
52
-
53
- var body: some View {
54
- HStack(spacing: 6) {
55
- Text("\(store.library.count) recording\(store.library.count == 1 ? "" : "s")")
56
- if let updated = store.lastUpdated {
57
- Text("·")
58
- Text("Updated \(updated.relativeDescription)")
59
- }
60
- Spacer()
61
-
62
- Button { store.loadLibrary() } label: {
63
- Image(systemName: "arrow.clockwise")
64
- }
65
- .buttonStyle(.plain)
66
- .help("Refresh library")
67
-
68
- Button {
69
- store.pane = .record
70
- } label: {
71
- Image(systemName: "mic.circle.fill")
72
- }
73
- .buttonStyle(.plain)
74
- .foregroundStyle(Theme.accent)
75
- .help("New recording")
76
- }
77
- .font(.system(.subheadline, design: .rounded))
78
- .foregroundStyle(.secondary)
79
- .padding(.horizontal, 18)
80
- .padding(.top, 20)
81
- .padding(.bottom, 10)
62
+ private func windowDot(_ color: Color, label: String, kind: NSWindow.ButtonType) -> some View {
63
+ Button { windowAction(kind) } label: {
64
+ Circle().fill(color.gradient).frame(width: 12, height: 12)
65
+ .overlay(Circle().strokeBorder(.white.opacity(0.6), lineWidth: 0.5))
66
+ }.buttonStyle(.plain).accessibilityLabel(label)
82
67
  }
83
68
  }
@@ -4,12 +4,17 @@ import RecordingsLib
4
4
 
5
5
  struct MenuBarStatusLabel: View {
6
6
  @ObservedObject var store: RecordingsStore
7
-
8
7
  var body: some View {
9
- Image(systemName: presentation.iconName)
10
- .accessibilityLabel(presentation.accessibilityLabel)
8
+ HStack(spacing: 4) {
9
+ Image(systemName: presentation.iconName)
10
+ .symbolRenderingMode(.palette)
11
+ .foregroundStyle(store.engine.captureIsActive && !store.engine.isPaused ? .red : .primary)
12
+ if store.engine.captureIsActive || store.isPlaying {
13
+ Text(Theme.clock(store.isPlaying ? store.playbackTime : store.engine.recordingDuration))
14
+ .monospacedDigit()
15
+ }
16
+ }.accessibilityLabel(presentation.accessibilityLabel)
11
17
  }
12
-
13
18
  private var presentation: MenuBarPresentation {
14
19
  MenuBarPresentation(
15
20
  isRecording: store.engine.isRecording,
@@ -24,68 +29,88 @@ struct MenuBarStatusLabel: View {
24
29
  struct MenuBarStatusView: View {
25
30
  @ObservedObject var store: RecordingsStore
26
31
  let openRecordings: () -> Void
27
- /// Bar-only builds have no workspace window to open; the entry point is hidden so
28
- /// the affordance does not exist where the surface cannot exist.
32
+ let openSettings: () -> Void
33
+ let openHistory: () -> Void
34
+ let openRecent: () -> Void
35
+ var openBar: (() -> Void)? = nil
36
+ var closeBar: (() -> Void)? = nil
29
37
  let barOnly: Bool
38
+ @State private var showsActions = false
39
+ private var width: CGFloat { min(820, (NSScreen.main?.visibleFrame.width ?? 1200) - 48) }
30
40
 
31
41
  var body: some View {
32
- VStack(alignment: .leading, spacing: 12) {
33
- HStack(spacing: 8) {
34
- Image(systemName: presentation.iconName)
35
- .foregroundStyle(statusColor)
36
- VStack(alignment: .leading, spacing: 2) {
37
- Text("Recordings")
38
- .font(.headline)
39
- Text(presentation.statusText)
40
- .font(.caption)
41
- .foregroundStyle(.secondary)
42
+ VStack(spacing: 16) {
43
+ HStack(spacing: 12) {
44
+ Button(action: toggleRecording) {
45
+ GlassCircle(symbol: recordButtonIcon, size: 34, red: store.engine.captureIsActive)
42
46
  }
43
- Spacer()
44
- }
45
-
46
- Button(action: toggleRecording) {
47
- Label(recordButtonTitle, systemImage: recordButtonIcon)
48
- .frame(maxWidth: .infinity)
49
- }
50
- .buttonStyle(.borderedProminent)
51
- .tint(store.engine.captureIsActive ? .red : .accentColor)
52
- .disabled(!presentation.primaryActionEnabled)
53
-
54
- Divider()
55
-
56
- if !barOnly {
57
- Button(action: openRecordings) {
58
- Label("Open Recordings", systemImage: "macwindow")
47
+ .buttonStyle(.plain).disabled(!presentation.primaryActionEnabled)
48
+ .accessibilityLabel(recordButtonTitle)
49
+ AudioWaveform(level: store.engine.audioLevel, recording: store.engine.captureIsActive && !store.engine.isPaused)
50
+ .frame(width: 84, height: 30).accessibilityLabel("Microphone level")
51
+ Divider().frame(height: 30)
52
+ VStack(alignment: .leading, spacing: 4) {
53
+ Text(presentation.statusText).font(.system(size: 11)).foregroundStyle(statusColor)
54
+ Text(transcript).font(.system(size: 13)).lineLimit(1)
59
55
  .frame(maxWidth: .infinity, alignment: .leading)
60
56
  }
61
- .buttonStyle(.plain)
62
- }
63
-
64
- SettingsLink {
65
- Label("Settings", systemImage: "gearshape")
66
- .frame(maxWidth: .infinity, alignment: .leading)
67
- }
68
- .buttonStyle(.plain)
69
-
70
- Button {
71
- NSApplication.shared.terminate(nil)
72
- } label: {
73
- Label("Quit Recordings", systemImage: "power")
74
- .frame(maxWidth: .infinity, alignment: .leading)
57
+ .frame(maxWidth: .infinity, alignment: .leading)
58
+ Divider().frame(height: 28)
59
+ GlassIconButton(symbol: store.engine.isPaused ? "play.fill" : "pause.fill", label: store.engine.isPaused ? "Resume recording" : "Pause recording") { store.engine.togglePause() }
60
+ .disabled(!store.engine.isRecording)
61
+ GlassIconButton(symbol: store.isPlaying ? "pause.fill" : "play.fill", label: "Playback and recent pastes") {
62
+ if store.hasPlayback && !store.engine.captureIsActive { store.toggleLatestPlayback() }
63
+ openRecent()
64
+ }
65
+ Divider().frame(height: 28)
66
+ Toggle("Auto-paste", isOn: Binding(get: { store.engine.autoPasteEnabled }, set: { store.engine.autoPasteEnabled = $0 })).toggleStyle(.switch).controlSize(.small).fixedSize().font(.system(size: 12))
67
+ GlassIconButton(symbol: "ellipsis", label: "More", size: 26) { showsActions.toggle() }
68
+ .popover(isPresented: $showsActions, arrowEdge: .bottom) {
69
+ VStack(alignment: .leading, spacing: 14) {
70
+ if let openBar { Button("Keep bar visible", action: openBar) }
71
+ if let closeBar { Button("Hide bar", action: closeBar) }
72
+ Button("Recent pastes", action: openRecent)
73
+ Button("Recordings", action: openHistory)
74
+ if !barOnly { Button(action: openRecordings) { Text("Open Hasna Recordings") } }
75
+ Button(action: openSettings) { Text("Settings") }
76
+ if store.engine.canCancelIntentDelivery { Button("Cancel processing") { store.engine.cancelIntentProcessing() } }
77
+ if store.engine.captureIsActive { Button("Discard recording") { store.engine.cancelRecording() } }
78
+ Divider()
79
+ Button("Quit Hasna Recordings") { NSApplication.shared.terminate(nil) }
80
+ }.buttonStyle(GlassButtonStyle()).font(.system(size: 13)).padding(12).frame(width: 200, alignment: .leading).background(FrostedBackground())
81
+ }
75
82
  }
76
- .buttonStyle(.plain)
83
+ .padding(.horizontal, 12).padding(.vertical, 10)
84
+ .frame(width: width, height: 60)
85
+ .background(FrostedBackground(radius: 16, cool: true))
77
86
  }
78
- .padding(14)
79
- .frame(width: 260)
87
+ .padding(8)
80
88
  }
81
89
 
90
+ private var transcript: String {
91
+ if !store.engine.liveTranscriptionText.isEmpty { return store.engine.liveTranscriptionText }
92
+ if store.engine.captureIsActive { return store.engine.isPaused ? "Paused" : "Listening…" }
93
+ if let blocked = store.engine.blockedReason { return blocked }
94
+ if store.engine.isTranscribing { return store.engine.statusMessage }
95
+ return store.engine.recentTranscriptions.first?.displayText ?? "Your words appear here as you speak…"
96
+ }
82
97
  private var statusColor: Color {
83
98
  if store.engine.captureIsActive { return .red }
84
- // The popover repeats the menu-bar signal rather than relying on the caption text, which
85
- // is rendered `.secondary` and reads as an aside.
86
99
  return presentation.isBlocked ? .orange : .accentColor
87
100
  }
88
-
101
+ private var recordButtonTitle: String {
102
+ store.engine.captureIsActive ? "Stop and Transcribe" : "Start Recording"
103
+ }
104
+ private var recordButtonIcon: String {
105
+ store.engine.captureIsActive ? "stop.fill" : "mic.fill"
106
+ }
107
+ private func toggleRecording() {
108
+ if store.engine.captureIsActive {
109
+ store.engine.stopAndTranscribe()
110
+ } else {
111
+ store.beginRecording()
112
+ }
113
+ }
89
114
  private var presentation: MenuBarPresentation {
90
115
  MenuBarPresentation(
91
116
  isRecording: store.engine.isRecording,
@@ -95,20 +120,105 @@ struct MenuBarStatusView: View {
95
120
  blockedReason: store.engine.blockedReason
96
121
  )
97
122
  }
123
+ }
98
124
 
99
- private var recordButtonTitle: String {
100
- store.engine.captureIsActive ? "Stop and Transcribe" : "Start Recording"
125
+ struct AudioWaveform: View {
126
+ let level: Double
127
+ let recording: Bool
128
+ var body: some View {
129
+ Canvas { context, size in
130
+ let count = 29
131
+ let step = size.width / CGFloat(count)
132
+ for i in 0..<count {
133
+ let envelope = 0.2 + 0.8 * abs(sin(Double(i) * 1.73))
134
+ let height = recording ? max(4, CGFloat(level * envelope) * size.height) : 4
135
+ let rect = CGRect(x: CGFloat(i) * step, y: (size.height - height) / 2, width: 2.5, height: height)
136
+ context.fill(Path(roundedRect: rect, cornerRadius: 1.25), with: .color(recording && i < 10 ? Theme.recordRed : .gray.opacity(0.55)))
137
+ }
138
+ }
101
139
  }
140
+ }
102
141
 
103
- private var recordButtonIcon: String {
104
- store.engine.captureIsActive ? "stop.fill" : "mic.fill"
142
+ struct RecentPastesView: View {
143
+ @ObservedObject var store: RecordingsStore
144
+ let close: () -> Void
145
+ var body: some View {
146
+ VStack(spacing: 12) {
147
+ HStack {
148
+ Text("Recent pastes").font(.system(size: 13, weight: .semibold))
149
+ Spacer()
150
+ Button("Clear all") { store.engine.clearRecentPastes() }
151
+ .buttonStyle(.plain).font(.system(size: 11)).padding(.horizontal, 14).padding(.vertical, 8)
152
+ .background(GlassInset())
153
+ Button(action: close) { Image(systemName: "xmark").font(.system(size: 12)) }
154
+ .buttonStyle(.plain).padding(.leading, 10).accessibilityLabel("Close recent pastes")
155
+ }.padding(.horizontal, 10)
156
+ VStack(spacing: 0) {
157
+ HStack(spacing: 0) {
158
+ Text("App").frame(width: 100, alignment: .leading)
159
+ Text("Location").frame(width: 100, alignment: .leading)
160
+ Text("Pasted content").frame(maxWidth: .infinity, alignment: .leading)
161
+ Text("Time").frame(width: 76, alignment: .leading)
162
+ Text("Status").frame(width: 92, alignment: .leading)
163
+ Spacer().frame(width: 24)
164
+ }.font(.system(size: 11)).foregroundStyle(.secondary).padding(10)
165
+ Divider().opacity(0.5)
166
+ if store.engine.recentPastes.isEmpty {
167
+ Spacer(); Text("Your pastes from this session appear here.").foregroundStyle(.secondary); Spacer()
168
+ } else {
169
+ ScrollView {
170
+ LazyVStack(spacing: 0) {
171
+ ForEach(store.engine.recentPastes) { paste in
172
+ HStack(spacing: 0) {
173
+ HStack(spacing: 10) {
174
+ Image(nsImage: appIcon(paste.bundleIdentifier)).resizable().frame(width: 22, height: 22)
175
+ Text(paste.appName).lineLimit(1)
176
+ }.frame(width: 100, alignment: .leading)
177
+ Text(paste.location).foregroundStyle(.secondary).frame(width: 100, alignment: .leading)
178
+ Text(paste.text).lineLimit(1).frame(maxWidth: .infinity, alignment: .leading)
179
+ Text(paste.timestamp.relativeDescription).foregroundStyle(.secondary).frame(width: 76, alignment: .leading)
180
+ Label(paste.status, systemImage: paste.verified ? "checkmark.circle.fill" : "questionmark.circle")
181
+ .labelStyle(PasteStatusStyle(verified: paste.verified)).frame(width: 92, alignment: .leading)
182
+ Menu {
183
+ Button("Copy pasted text") {
184
+ NSPasteboard.general.clearContents()
185
+ NSPasteboard.general.setString(paste.text, forType: .string)
186
+ }
187
+ } label: { Image(systemName: "ellipsis") }
188
+ .menuStyle(.borderlessButton).menuIndicator(.hidden).frame(width: 24)
189
+ }.font(.system(size: 12)).padding(.horizontal, 10).padding(.vertical, 10)
190
+ Divider().opacity(0.35)
191
+ }
192
+ }
193
+ }
194
+ }
195
+ }.background(GlassInset(radius: 10))
196
+ }.padding(14).background(FrostedBackground(radius: 17, cool: true))
197
+ }
198
+ private func appURL(_ id: String?) -> URL? { id.flatMap { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0) } }
199
+ private func appName(_ id: String?) -> String { appURL(id)?.deletingPathExtension().lastPathComponent ?? "Application" }
200
+ private func appIcon(_ id: String?) -> NSImage {
201
+ appURL(id).map { NSWorkspace.shared.icon(forFile: $0.path) } ?? NSImage(systemSymbolName: "app", accessibilityDescription: nil)!
105
202
  }
203
+ }
106
204
 
107
- private func toggleRecording() {
108
- if store.engine.captureIsActive {
109
- store.engine.stopAndTranscribe()
110
- } else {
111
- store.engine.startRecording()
205
+ private struct PasteStatusStyle: LabelStyle {
206
+ let verified: Bool
207
+ func makeBody(configuration: Configuration) -> some View {
208
+ HStack(spacing: 8) {
209
+ configuration.icon.foregroundStyle(verified ? .green : .orange).font(.system(size: 15))
210
+ configuration.title.foregroundStyle(.secondary)
211
+ }
212
+ }
213
+ }
214
+
215
+ struct PanelPointer: Shape {
216
+ func path(in rect: CGRect) -> Path {
217
+ Path { path in
218
+ path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
219
+ path.addLine(to: CGPoint(x: rect.midX, y: rect.minY))
220
+ path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
221
+ path.closeSubpath()
112
222
  }
113
223
  }
114
224
  }