@hasna/recordings 0.3.13 → 0.4.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 (105) hide show
  1. package/README.md +223 -37
  2. package/bun.lock +37 -38
  3. package/dist/cli/index.js +1884 -615
  4. package/dist/cli/macos-shortcut.d.ts +2 -2
  5. package/dist/db/database.d.ts.map +1 -1
  6. package/dist/db/pg-migrations.d.ts.map +1 -1
  7. package/dist/db/recordings.d.ts +6 -0
  8. package/dist/db/recordings.d.ts.map +1 -1
  9. package/dist/http/client.d.ts +183 -22
  10. package/dist/http/client.d.ts.map +1 -1
  11. package/dist/index.d.ts +3 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2858 -2288
  14. package/dist/lib/audio-artifact-storage.d.ts +98 -0
  15. package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
  16. package/dist/lib/capture-probe.d.ts +3 -3
  17. package/dist/lib/capture-probe.d.ts.map +1 -1
  18. package/dist/lib/config.d.ts.map +1 -1
  19. package/dist/lib/local-opt-in.d.ts +132 -0
  20. package/dist/lib/local-opt-in.d.ts.map +1 -0
  21. package/dist/lib/macos-bundle.d.ts +1 -1
  22. package/dist/lib/persistence-probe.d.ts +56 -17
  23. package/dist/lib/persistence-probe.d.ts.map +1 -1
  24. package/dist/lib/transcriber.d.ts.map +1 -1
  25. package/dist/mcp/index.d.ts.map +1 -1
  26. package/dist/mcp/index.js +1689 -456
  27. package/dist/sdk/index.d.ts +13 -5
  28. package/dist/sdk/index.d.ts.map +1 -1
  29. package/dist/sdk/index.js +904 -1
  30. package/dist/sdk/resolve.d.ts +53 -0
  31. package/dist/sdk/resolve.d.ts.map +1 -0
  32. package/dist/sdk/v1.generated.d.ts +6 -0
  33. package/dist/sdk/v1.generated.d.ts.map +1 -1
  34. package/dist/server/client-ip.d.ts +39 -0
  35. package/dist/server/client-ip.d.ts.map +1 -0
  36. package/dist/server/cloud-config.d.ts +12 -0
  37. package/dist/server/cloud-config.d.ts.map +1 -1
  38. package/dist/server/cloud-readiness.d.ts.map +1 -1
  39. package/dist/server/cloud.d.ts +20 -2
  40. package/dist/server/cloud.d.ts.map +1 -1
  41. package/dist/server/index.js +566 -284
  42. package/dist/server/migrate-command.d.ts +1 -1
  43. package/dist/server/migrate-command.d.ts.map +1 -1
  44. package/dist/server/openapi.d.ts +21 -0
  45. package/dist/server/openapi.d.ts.map +1 -1
  46. package/dist/server/repo.d.ts.map +1 -1
  47. package/dist/server/serve.d.ts.map +1 -1
  48. package/dist/storage.d.ts +2 -2
  49. package/dist/storage.d.ts.map +1 -1
  50. package/dist/storage.js +1334 -871
  51. package/dist/store.d.ts +12 -3
  52. package/dist/store.d.ts.map +1 -1
  53. package/dist/types/index.d.ts +15 -0
  54. package/dist/types/index.d.ts.map +1 -1
  55. package/package.json +9 -8
  56. package/packaging/macos/build_release_pkg.sh +3 -3
  57. package/packaging/macos/managed_bootstrap.sh +4 -4
  58. package/packaging/macos/scripts/postinstall +2 -2
  59. package/packaging/macos/scripts/preinstall +2 -2
  60. package/scripts/build_companion_cli.sh +7 -1
  61. package/scripts/install_macos_app.sh +27 -24
  62. package/scripts/macos_artifact.ts +20 -16
  63. package/scripts/migrate.ts +4 -20
  64. package/scripts/smoke_macos_app.sh +21 -21
  65. package/src/native/Recordings/App/ContentView.swift +53 -68
  66. package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
  67. package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
  68. package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
  69. package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
  70. package/src/native/Recordings/App/RecordingsApp.swift +185 -9
  71. package/src/native/Recordings/App/RecordingsListView.swift +60 -112
  72. package/src/native/Recordings/App/RecordingsStore.swift +124 -109
  73. package/src/native/Recordings/App/Theme.swift +73 -61
  74. package/src/native/Recordings/Package.swift +1 -0
  75. package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
  76. package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
  77. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
  78. package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
  79. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +316 -34
  80. package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
  81. package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +102 -0
  82. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
  83. package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
  84. package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
  85. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
  86. package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
  87. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +13 -4
  88. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
  89. package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
  90. package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
  91. package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +195 -0
  92. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
  93. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +30 -0
  94. package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
  95. package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
  96. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
  97. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
  98. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
  99. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
  100. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
  101. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
  102. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
  103. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
  104. package/src/native/Recordings/build.sh +6 -6
  105. package/src/native/Recordings/App/SidebarView.swift +0 -212
@@ -1,136 +1,152 @@
1
1
  import Foundation
2
2
  import SwiftUI
3
3
  import Combine
4
+ import AVFoundation
4
5
  import RecordingsLib
5
6
 
6
- /// Which main pane the canvas shows.
7
- enum Pane: Equatable {
8
- case record // the recording workspace (default / first screen)
9
- case library // browse past recordings
10
- }
11
-
12
- /// Sidebar filter selection for the library.
13
- enum LibraryFilter: Hashable {
14
- case all
15
- case project(String) // project id
16
- case noProject
17
- case mode(String) // "raw" | "enhanced"
18
- case thisMachine
19
- case machine(String)
20
- }
21
-
22
7
  /// Observable application state for the full macOS app. Bridges the live `RecordingEngine`,
23
- /// the `ProjectStore` / `VoiceShortcuts`, and the `recordings` CLI Store.
8
+ /// global cleanup preferences, voice shortcuts, and the `recordings` CLI Store.
24
9
  @MainActor
25
10
  final class RecordingsStore: ObservableObject {
26
11
  let engine: RecordingEngine
27
- let projectStore: ProjectStore
12
+ let preferences: ProjectStore
28
13
  let voiceShortcuts: VoiceShortcuts
29
14
 
30
- @Published var pane: Pane = .record
31
- @Published var library: [Recording] = []
15
+ @Published var library: [Recording] = [] { didSet { updateVisibleRecordings() } }
32
16
  @Published var selection: String?
33
- @Published var filter: LibraryFilter = .all
34
- @Published var searchText: String = ""
17
+ @Published var searchText: String = "" { didSet { updateVisibleRecordings() } }
18
+ @Published private(set) var visibleRecordings: [Recording] = []
19
+ @Published var showMenuBar = UserDefaults.standard.object(forKey: "recordingsShowMenuBar") as? Bool ?? true {
20
+ didSet { UserDefaults.standard.set(showMenuBar, forKey: "recordingsShowMenuBar") }
21
+ }
35
22
  @Published var isLoadingLibrary = false
36
23
  @Published var loadError: String?
37
24
  @Published var operationError: String?
38
25
 
39
- @Published var stats: RecordingStats?
40
- let localMachineID: String
26
+ @Published private(set) var isPlaying = false
27
+ @Published private(set) var playbackTime: Double = 0
28
+ @Published private(set) var playbackProgress: Double = 0
29
+ @Published private(set) var playbackRecordingID: String?
30
+ @Published private(set) var hasPlayback = false
31
+ private var player: AVAudioPlayer?
32
+ private var playbackTimer: Timer?
33
+
34
+ func beginRecording() {
35
+ guard !isVisualPreview else { return }
36
+ clearPlayback()
37
+ engine.startRecording()
38
+ }
39
+
40
+ func prepareLatestPlayback() {
41
+ guard !engine.captureIsActive, let path = engine.latestAudioPath,
42
+ FileManager.default.fileExists(atPath: path) else { return }
43
+ preparePlayback(path: path, id: nil, play: false)
44
+ }
45
+
46
+ func canPlay(_ recording: Recording) -> Bool {
47
+ recording.audioPath.map { FileManager.default.fileExists(atPath: $0) } ?? false
48
+ }
49
+
50
+ func play(_ recording: Recording) {
51
+ guard !engine.captureIsActive else { return }
52
+ if playbackRecordingID == recording.id { toggleLatestPlayback(); return }
53
+ guard let path = recording.audioPath else { return }
54
+ preparePlayback(path: path, id: recording.id, play: true)
55
+ }
56
+
57
+ private func preparePlayback(path: String, id: String?, play: Bool) {
58
+ clearPlayback()
59
+ do {
60
+ player = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path))
61
+ player?.prepareToPlay()
62
+ hasPlayback = true
63
+ playbackRecordingID = id
64
+ playbackTime = player?.duration ?? 0
65
+ if play { toggleLatestPlayback() }
66
+ } catch { operationError = "This recording’s audio could not be opened." }
67
+ }
68
+
69
+ func toggleLatestPlayback() {
70
+ guard let player else { return }
71
+ if isPlaying {
72
+ player.pause(); isPlaying = false; playbackTimer?.invalidate(); return
73
+ }
74
+ guard player.play() else { operationError = "Audio playback could not start."; return }
75
+ isPlaying = true
76
+ playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
77
+ Task { @MainActor [weak self] in
78
+ guard let self, let player = self.player else { return }
79
+ self.playbackTime = player.currentTime
80
+ self.playbackProgress = player.duration > 0 ? player.currentTime / player.duration : 0
81
+ if !player.isPlaying { self.clearPlayback() }
82
+ }
83
+ }
84
+ }
85
+
86
+ func clearPlayback() {
87
+ playbackTimer?.invalidate(); playbackTimer = nil
88
+ player?.stop(); player = nil
89
+ isPlaying = false; hasPlayback = false
90
+ playbackRecordingID = nil; playbackTime = 0; playbackProgress = 0
91
+ if !engine.captureIsActive { engine.recordingDuration = 0 }
92
+ }
93
+
94
+ var isVisualPreview = false
41
95
  private let home: String
42
96
  private var cancellables = Set<AnyCancellable>()
97
+ private var connectionRevision = 0
43
98
 
44
99
  init(engine: RecordingEngine = RecordingEngine(),
45
- projectStore: ProjectStore = ProjectStore(),
100
+ preferences: ProjectStore = ProjectStore(),
46
101
  voiceShortcuts: VoiceShortcuts = VoiceShortcuts()) {
47
102
  self.engine = engine
48
- self.projectStore = projectStore
103
+ self.preferences = preferences
49
104
  self.voiceShortcuts = voiceShortcuts
50
105
  self.home = FileManager.default.homeDirectoryForCurrentUser.path
51
- self.localMachineID = NativeMachineIdentity.current()
52
- engine.projectStore = projectStore
106
+ engine.projectStore = nil
107
+ engine.globalRecordingPreferences = preferences
53
108
  engine.voiceShortcuts = voiceShortcuts
54
109
 
55
110
  // Re-publish the wrapped ObservableObjects so views that observe only this store
56
- // refresh on live recording changes (timer, live text) and project edits.
111
+ // refresh on live recording changes (timer, live text) and preference edits.
57
112
  engine.objectWillChange
58
113
  .sink { [weak self] in self?.objectWillChange.send() }
59
114
  .store(in: &cancellables)
60
115
  engine.$persistedRecordingRevision
61
116
  .dropFirst()
62
- .sink { [weak self] _ in self?.loadLibrary() }
117
+ .sink { [weak self] _ in self?.loadLibrary(); self?.prepareLatestPlayback()
118
+ if UserDefaults.standard.bool(forKey: "recordingsNotificationSound") { NSSound(named: "Pop")?.play() } }
119
+ .store(in: &cancellables)
120
+ engine.$isRecording.dropFirst().filter { $0 }
121
+ .sink { [weak self] _ in self?.clearPlayback() }
63
122
  .store(in: &cancellables)
64
- projectStore.objectWillChange
123
+ preferences.objectWillChange
65
124
  .sink { [weak self] in self?.objectWillChange.send() }
66
125
  .store(in: &cancellables)
67
126
  voiceShortcuts.objectWillChange
68
127
  .sink { [weak self] in self?.objectWillChange.send() }
69
128
  .store(in: &cancellables)
70
-
71
- reconcileProjects()
72
- }
73
-
74
- func reconcileProjects() {
75
- let home = self.home
76
- Task { [weak self] in
77
- guard let self else { return }
78
- do {
79
- try await projectStore.reconcileWithCanonicalStore(home: home)
80
- self.operationError = nil
81
- } catch {
82
- self.operationError = projectStore.persistenceError
83
- ?? (error as? RecordingsCLI.Failure)?.message
84
- ?? error.localizedDescription
129
+ NotificationCenter.default.publisher(for: ServiceAPIConfiguration.didChangeNotification)
130
+ .receive(on: RunLoop.main)
131
+ .sink { [weak self] _ in
132
+ guard let self else { return }
133
+ self.connectionRevision += 1
134
+ self.library = []
135
+ self.selection = nil
136
+ self.loadError = nil
137
+ self.loadLibrary()
85
138
  }
86
- }
87
- }
88
-
89
- // MARK: - Derived collections
90
-
91
- var projects: [RecProject] { projectStore.settings.projects }
92
-
93
- var machines: [String] {
94
- Set(library.compactMap { $0.machineId }.filter { !$0.isEmpty })
95
- .sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
96
- }
97
-
98
- var lastUpdated: Date? { library.compactMap(\.createdDate).max() }
99
-
100
- var visibleRecordings: [Recording] {
101
- library.filter { matchesFilter($0) && matchesSearch($0) }
102
- }
103
-
104
- private func matchesFilter(_ r: Recording) -> Bool {
105
- switch filter {
106
- case .all: return true
107
- case .project(let id): return r.projectId == id
108
- case .noProject: return r.projectId == nil
109
- case .mode(let m): return r.processingMode == m
110
- case .thisMachine: return r.machineId == localMachineID
111
- case .machine(let m): return r.machineId == m
112
- }
139
+ .store(in: &cancellables)
113
140
  }
114
141
 
115
- private func matchesSearch(_ r: Recording) -> Bool {
142
+ // Recording ticks and transcript deltas must not rescan the entire history.
143
+ private func updateVisibleRecordings() {
116
144
  let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
117
- guard !q.isEmpty else { return true }
118
- if r.displayText.localizedCaseInsensitiveContains(q) { return true }
119
- if r.tags.contains(where: { $0.localizedCaseInsensitiveContains(q) }) { return true }
120
- return false
121
- }
122
-
123
- func count(for filter: LibraryFilter) -> Int {
124
- library.filter {
125
- switch filter {
126
- case .all: return true
127
- case .project(let id): return $0.projectId == id
128
- case .noProject: return $0.projectId == nil
129
- case .mode(let m): return $0.processingMode == m
130
- case .thisMachine: return $0.machineId == localMachineID
131
- case .machine(let m): return $0.machineId == m
132
- }
133
- }.count
145
+ guard !q.isEmpty else { visibleRecordings = library; return }
146
+ visibleRecordings = library.filter { recording in
147
+ recording.displayText.localizedCaseInsensitiveContains(q) ||
148
+ recording.tags.contains { $0.localizedCaseInsensitiveContains(q) }
149
+ }
134
150
  }
135
151
 
136
152
  var selectedRecording: Recording? {
@@ -138,40 +154,38 @@ final class RecordingsStore: ObservableObject {
138
154
  return library.first { $0.id == id }
139
155
  }
140
156
 
141
- func projectName(_ id: String?) -> String? {
142
- guard let id else { return nil }
143
- return projects.first { $0.id == id }?.name
144
- }
145
-
146
157
  // MARK: - Library loading
147
158
 
148
159
  private var reloadRequestedDuringLoad = false
149
160
 
150
161
  func loadLibrary() {
162
+ guard !isVisualPreview else { return }
151
163
  guard !isLoadingLibrary else { reloadRequestedDuringLoad = true; return }
152
164
  isLoadingLibrary = true
153
165
  let home = home
166
+ let revision = connectionRevision
154
167
  Task.detached(priority: .userInitiated) {
155
- let result: Result<([Recording], RecordingStats?), Error>
168
+ let result: Result<[Recording], Error>
156
169
  do {
157
170
  let recs = try RecordingsCLI.listAll(home: home)
158
- let stats = try? RecordingsCLI.stats(home: home)
159
- result = .success((recs, stats))
171
+ result = .success(recs)
160
172
  } catch {
161
173
  result = .failure(error)
162
174
  }
163
175
  await MainActor.run {
164
176
  self.isLoadingLibrary = false
165
- switch result {
166
- case .success(let (recs, stats)):
167
- self.library = recs
168
- self.stats = stats
169
- self.loadError = nil
170
- if self.selection == nil || !recs.contains(where: { $0.id == self.selection }) {
171
- self.selection = self.visibleRecordings.first?.id
177
+ // A response from the previous endpoint must not repopulate this library.
178
+ if revision == self.connectionRevision {
179
+ switch result {
180
+ case .success(let recs):
181
+ self.library = recs
182
+ self.loadError = nil
183
+ if let selection = self.selection, !recs.contains(where: { $0.id == selection }) {
184
+ self.selection = nil
185
+ }
186
+ case .failure(let error):
187
+ self.loadError = (error as? RecordingsCLI.Failure)?.message ?? error.localizedDescription
172
188
  }
173
- case .failure(let error):
174
- self.loadError = (error as? RecordingsCLI.Failure)?.message ?? error.localizedDescription
175
189
  }
176
190
  if self.reloadRequestedDuringLoad {
177
191
  self.reloadRequestedDuringLoad = false
@@ -182,6 +196,7 @@ final class RecordingsStore: ObservableObject {
182
196
  }
183
197
 
184
198
  func delete(id: String) {
199
+ if isVisualPreview { library.removeAll { $0.id == id }; selection = nil; return }
185
200
  let home = home
186
201
  Task.detached(priority: .userInitiated) {
187
202
  let result: Result<Void, Error>
@@ -195,7 +210,7 @@ final class RecordingsStore: ObservableObject {
195
210
  switch result {
196
211
  case .success:
197
212
  self.library.removeAll { $0.id == id }
198
- if self.selection == id { self.selection = self.visibleRecordings.first?.id }
213
+ if self.selection == id { self.selection = nil }
199
214
  case .failure(let error):
200
215
  self.operationError = (error as? RecordingsCLI.Failure)?.message ?? error.localizedDescription
201
216
  }
@@ -1,87 +1,99 @@
1
- import RecordingsLib
2
1
  import SwiftUI
2
+ import AppKit
3
+ import RecordingsLib
3
4
 
4
- /// Design tokens and Liquid Glass helpers for the Recordings app. Mirrors the Hasna Notes
5
- /// visual system (narrow colored Liquid-Glass sidebar + one continuous canvas, hairline
6
- /// dividers, rounded type) with a Recordings identity: an "infinity violet→indigo" sidebar.
7
5
  enum Theme {
8
- /// Accent used for selection highlights and small affordances.
9
- static let accent = Color(red: 0.42, green: 0.34, blue: 0.92)
10
- static let recordRed = Color(red: 0.92, green: 0.26, blue: 0.30)
11
-
6
+ static let accent = Color.primary
7
+ static let recordRed = Color(red: 1, green: 0.24, blue: 0.25)
12
8
  static let cornerLarge: CGFloat = 22
13
9
  static let cornerMedium: CGFloat = 14
14
10
  static let cornerSmall: CGFloat = 9
11
+ static func canvas(_ scheme: ColorScheme) -> Color { Color(NSColor.windowBackgroundColor) }
12
+ static func clock(_ seconds: Double) -> String {
13
+ let total = max(0, Int(seconds))
14
+ return String(format: "%02d:%02d", total / 60, total % 60)
15
+ }
16
+ }
15
17
 
16
- /// Deliberately narrow, Apple-style sidebar.
17
- static let sidebarWidth: CGFloat = 204
18
+ /// A single native backdrop per window; controls share lightweight glass highlights.
19
+ struct FrostedBackground: View {
20
+ var radius: CGFloat = 16
21
+ var cool = false
22
+ var body: some View { RecordingGlassBackground(radius: radius) }
23
+ }
18
24
 
19
- /// Continuous main canvas color: pure white in light mode, system window background in dark.
20
- static func canvas(_ scheme: ColorScheme) -> Color {
21
- scheme == .dark ? Color(NSColor.windowBackgroundColor) : .white
25
+ struct GlassInset: View {
26
+ var radius: CGFloat = 8
27
+ var body: some View {
28
+ RoundedRectangle(cornerRadius: radius)
29
+ .fill(LinearGradient(colors: [.white.opacity(0.26), .white.opacity(0.06)], startPoint: .topLeading, endPoint: .bottomTrailing))
30
+ .overlay(RoundedRectangle(cornerRadius: radius).strokeBorder(.white.opacity(0.45), lineWidth: 0.5))
31
+ .shadow(color: .black.opacity(0.04), radius: 2, y: 1)
22
32
  }
33
+ }
23
34
 
24
- /// "Infinity violet" sidebar gradient rendered behind the Liquid Glass rows so the glass
25
- /// refracts the color. Darkened in dark mode so white text stays legible.
26
- static func sidebarGradient(_ scheme: ColorScheme) -> LinearGradient {
27
- let colors: [Color] = scheme == .dark
28
- ? [Color(red: 0.16, green: 0.09, blue: 0.34),
29
- Color(red: 0.20, green: 0.12, blue: 0.44),
30
- Color(red: 0.28, green: 0.12, blue: 0.46)]
31
- : [Color(red: 0.30, green: 0.18, blue: 0.66),
32
- Color(red: 0.28, green: 0.20, blue: 0.76),
33
- Color(red: 0.44, green: 0.18, blue: 0.70)]
34
- return LinearGradient(colors: colors, startPoint: .top, endPoint: .bottom)
35
+ struct GlassButtonStyle: ButtonStyle {
36
+ func makeBody(configuration: Configuration) -> some View {
37
+ configuration.label.padding(6).background(GlassInset())
38
+ .opacity(configuration.isPressed ? 0.65 : 1)
35
39
  }
36
40
  }
37
41
 
38
- extension View {
39
- /// Liquid Glass surface on macOS 26, honoring reduce-transparency. Used sparingly —
40
- /// chiefly the sidebar rows and the record hero — never as boxed panels in the canvas.
41
- @ViewBuilder
42
- func glassSurface(cornerRadius: CGFloat, tint: Color? = nil, interactive: Bool = false) -> some View {
43
- modifier(GlassSurface(cornerRadius: cornerRadius, tint: tint, interactive: interactive))
42
+ struct GlassCircle: View {
43
+ let symbol: String
44
+ var size: CGFloat = 44
45
+ var red = false
46
+ var progress: Double? = nil
47
+ var body: some View {
48
+ ZStack {
49
+ Circle().fill(.white.opacity(0.20)).padding(-3)
50
+ Circle()
51
+ .fill(LinearGradient(colors: red
52
+ ? [Color(red: 1, green: 0.38, blue: 0.38), Color(red: 0.94, green: 0.18, blue: 0.19)]
53
+ : [.white.opacity(0.58), .white.opacity(0.06), .white.opacity(0.25)], startPoint: .topLeading, endPoint: .bottomTrailing))
54
+ .shadow(color: red ? .red.opacity(0.26) : .black.opacity(0.10), radius: red ? 22 : 9, y: 4)
55
+ Circle().strokeBorder(.white.opacity(red ? 0.8 : 0.95), lineWidth: 1.2)
56
+ Circle().strokeBorder(.black.opacity(red ? 0.05 : 0.07), lineWidth: 1).padding(-2)
57
+ if let progress {
58
+ Circle().trim(from: 0, to: min(1, max(0, progress)))
59
+ .stroke(.gray.opacity(0.72), style: StrokeStyle(lineWidth: 4, lineCap: .round))
60
+ .rotationEffect(.degrees(-90)).padding(-5)
61
+ }
62
+ Image(systemName: symbol)
63
+ .font(.system(size: size * (symbol == "mic.fill" ? 0.34 : size > 100 ? 0.34 : 0.46), weight: .regular))
64
+ .foregroundStyle(red ? .white : Theme.accent)
65
+ .shadow(color: .white.opacity(0.85), radius: 0, y: 1)
66
+ }
67
+ .frame(width: size, height: size)
44
68
  }
45
69
  }
46
70
 
47
- private struct GlassSurface: ViewModifier {
48
- let cornerRadius: CGFloat
49
- let tint: Color?
50
- let interactive: Bool
51
- @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
52
-
53
- @ViewBuilder
54
- func body(content: Content) -> some View {
55
- switch ChromeSurface.forReducedTransparency(reduceTransparency) {
56
- case .opaque:
57
- // Reduce Transparency: opaque system background, never a translucent material.
58
- content
59
- .background(
60
- Color(NSColor.windowBackgroundColor),
61
- in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
62
- )
63
- .overlay(
64
- RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
65
- .strokeBorder(.separator, lineWidth: 1)
66
- )
67
- case .liquidGlass:
68
- content.glassEffect(makeGlass(), in: .rect(cornerRadius: cornerRadius))
69
- }
71
+ struct GlassIconButton: View {
72
+ let symbol: String
73
+ let label: String
74
+ var size: CGFloat = 28
75
+ let action: () -> Void
76
+ var body: some View {
77
+ Button(action: action) { GlassCircle(symbol: symbol, size: size) }
78
+ .buttonStyle(.plain).help(label).accessibilityLabel(label)
70
79
  }
80
+ }
71
81
 
72
- private func makeGlass() -> Glass {
73
- var glass: Glass = .regular
74
- if let tint { glass = glass.tint(tint) }
75
- if interactive { glass = glass.interactive() }
76
- return glass
82
+ extension View {
83
+ func glassSurface(cornerRadius: CGFloat, tint: Color? = nil, interactive: Bool = false) -> some View {
84
+ background(FrostedBackground(radius: cornerRadius))
77
85
  }
78
86
  }
79
87
 
80
88
  extension Date {
81
- /// Compact relative description for list rows, e.g. "2h ago".
82
89
  var relativeDescription: String {
83
- let f = RelativeDateTimeFormatter()
84
- f.unitsStyle = .abbreviated
90
+ let f = RelativeDateTimeFormatter(); f.unitsStyle = .abbreviated
85
91
  return f.localizedString(for: self, relativeTo: Date())
86
92
  }
93
+ var recordingDateLabel: String {
94
+ let time = formatted(date: .omitted, time: .shortened)
95
+ if Calendar.current.isDateInToday(self) { return "Today, \(time)" }
96
+ if Calendar.current.isDateInYesterday(self) { return "Yesterday, \(time)" }
97
+ return formatted(.dateTime.month(.abbreviated).day().year())
98
+ }
87
99
  }
@@ -8,6 +8,7 @@ let package = Package(
8
8
  .macOS(.v26)
9
9
  ],
10
10
  products: [
11
+ .library(name: "RecordingsLib", targets: ["RecordingsLib"]),
11
12
  .executable(name: "App", targets: ["App"]),
12
13
  .library(name: "RecordingsUpdateProtocol", targets: ["RecordingsUpdateProtocol"]),
13
14
  .executable(name: "recordings-update-broker", targets: ["RecordingsUpdateBroker"]),
@@ -0,0 +1,65 @@
1
+ import Foundation
2
+
3
+ /// Keeps pause and metering on the audio-delivery queue, without publishing every PCM chunk.
4
+ final class CaptureMonitor: @unchecked Sendable {
5
+ private let lock = NSLock()
6
+ private var paused = false
7
+ private var bytes = 0
8
+ private var level: Double = 0
9
+
10
+ func setPaused(_ value: Bool) { lock.withLock { paused = value; if value { level = 0 } } }
11
+
12
+ func admit(_ data: Data) -> Bool {
13
+ lock.withLock {
14
+ guard !paused else { return false }
15
+ bytes += data.count
16
+ var sum: Double = 0
17
+ var count = 0
18
+ data.withUnsafeBytes { raw in
19
+ for offset in stride(from: 0, to: max(0, raw.count - 1), by: 2) {
20
+ let value = Double(Int16(bitPattern: UInt16(raw[offset]) | UInt16(raw[offset + 1]) << 8)) / 32768
21
+ sum += value * value
22
+ count += 1
23
+ }
24
+ }
25
+ level = count > 0 ? min(1, sqrt(sum / Double(count)) * 5) : 0
26
+ return true
27
+ }
28
+ }
29
+
30
+ func snapshot() -> (duration: Double, level: Double) {
31
+ lock.withLock { (Double(bytes) / 48_000, level) }
32
+ }
33
+ }
34
+
35
+ public enum RecentPasteDeliveryStatus: String, Codable, Sendable {
36
+ case confirmed
37
+ case unconfirmed
38
+ case notDelivered
39
+ }
40
+
41
+ public struct RecentPaste: Identifiable, Sendable {
42
+ public let id = UUID()
43
+ public let text: String
44
+ public let bundleIdentifier: String?
45
+ public let appName: String
46
+ public let location: String
47
+ public let timestamp = Date()
48
+ public let status: String
49
+ public let verified: Bool
50
+ public let captureID: String?
51
+ public let deliveryStatus: RecentPasteDeliveryStatus
52
+
53
+ public init(text: String, bundleIdentifier: String?, appName: String, location: String,
54
+ status: String, verified: Bool, captureID: String? = nil,
55
+ deliveryStatus: RecentPasteDeliveryStatus? = nil) {
56
+ self.text = text
57
+ self.bundleIdentifier = bundleIdentifier
58
+ self.appName = appName
59
+ self.location = location
60
+ self.status = status
61
+ self.verified = verified
62
+ self.captureID = captureID
63
+ self.deliveryStatus = deliveryStatus ?? (verified ? .confirmed : .unconfirmed)
64
+ }
65
+ }
@@ -9,7 +9,7 @@
9
9
  <key>NSSupportsAutomaticGraphicsSwitching</key>
10
10
  <true/>
11
11
  <key>CFBundleName</key>
12
- <string>Recordings</string>
12
+ <string>Hasna Recordings</string>
13
13
  <key>CFBundleDisplayName</key>
14
14
  <string>Hasna Recordings</string>
15
15
  <key>CFBundleExecutable</key>
@@ -17,11 +17,11 @@
17
17
  <key>CFBundleIdentifier</key>
18
18
  <string>com.hasna.recordings</string>
19
19
  <key>CFBundleVersion</key>
20
- <string>0.3.13</string>
20
+ <string>0.4.0</string>
21
21
  <key>CFBundlePackageType</key>
22
22
  <string>APPL</string>
23
23
  <key>CFBundleShortVersionString</key>
24
- <string>0.3.13</string>
24
+ <string>0.4.0</string>
25
25
  <key>LSMinimumSystemVersion</key>
26
26
  <string>26.0</string>
27
27
  <key>NSMicrophoneUsageDescription</key>