@hasna/recordings 0.1.13 → 0.1.15
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.
- package/dist/cli/index.js +35 -8
- package/dist/mcp/index.js +2 -2
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
- package/scripts/install_macos_app.sh +32 -0
- package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +1 -1
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +52 -7
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +32 -5
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +27 -1
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +13 -0
package/dist/cli/index.js
CHANGED
|
@@ -7,7 +7,7 @@ var __require = import.meta.require;
|
|
|
7
7
|
var require_package = __commonJS((exports, module) => {
|
|
8
8
|
module.exports = {
|
|
9
9
|
name: "@hasna/recordings",
|
|
10
|
-
version: "0.1.
|
|
10
|
+
version: "0.1.15",
|
|
11
11
|
type: "module",
|
|
12
12
|
description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
|
|
13
13
|
repository: {
|
|
@@ -10466,7 +10466,7 @@ async function processText(rawText, config, systemPrompt) {
|
|
|
10466
10466
|
}
|
|
10467
10467
|
|
|
10468
10468
|
// src/version.ts
|
|
10469
|
-
var VERSION = "0.1.
|
|
10469
|
+
var VERSION = "0.1.15";
|
|
10470
10470
|
|
|
10471
10471
|
// src/cli/index.ts
|
|
10472
10472
|
var program = new Command;
|
|
@@ -10792,6 +10792,7 @@ appCommand.command("status").description("Show installed Recordings.app status")
|
|
|
10792
10792
|
console.log(`Native sources: ${status.native_sources_available ? "available" : "missing"}`);
|
|
10793
10793
|
console.log(`Installed app: ${status.installed ? status.installed_app_path : "missing"}`);
|
|
10794
10794
|
console.log(`Executable: ${status.executable ? "available" : "missing"}`);
|
|
10795
|
+
console.log(`Code hash: ${status.app_code_hash ?? "unavailable"}`);
|
|
10795
10796
|
if (process.platform === "darwin") {
|
|
10796
10797
|
console.log(`Microphone: ${status.microphone_permission}`);
|
|
10797
10798
|
console.log(`Accessibility: ${status.accessibility_permission}`);
|
|
@@ -10805,6 +10806,8 @@ appCommand.command("permissions").description("Show macOS permission state for R
|
|
|
10805
10806
|
bundle_id: "com.hasna.recordings",
|
|
10806
10807
|
microphone: status.microphone_permission,
|
|
10807
10808
|
accessibility: status.accessibility_permission,
|
|
10809
|
+
app_code_hash: status.app_code_hash,
|
|
10810
|
+
ad_hoc_signed: status.ad_hoc_signed,
|
|
10808
10811
|
log_path: status.log_path
|
|
10809
10812
|
};
|
|
10810
10813
|
if (program.opts().json) {
|
|
@@ -11316,6 +11319,8 @@ function getMacOSAppStatus() {
|
|
|
11316
11319
|
const logPath = pathJoin(home, ".hasna", "recordings", "Recordings.log");
|
|
11317
11320
|
const installerPath = pathJoin(packageRoot, "scripts", "install_macos_app.sh");
|
|
11318
11321
|
const nativeSourcesPath = pathJoin(packageRoot, "src", "native", "Recordings");
|
|
11322
|
+
const signingInfo = getCodeSigningInfo(installedAppPath);
|
|
11323
|
+
const permissionCodeHash = signingInfo.adHoc ? signingInfo.cdHash : null;
|
|
11319
11324
|
return {
|
|
11320
11325
|
platform: process.platform,
|
|
11321
11326
|
package_root: packageRoot,
|
|
@@ -11327,19 +11332,35 @@ function getMacOSAppStatus() {
|
|
|
11327
11332
|
installed: existsSync6(installedAppPath),
|
|
11328
11333
|
executable_path: executablePath,
|
|
11329
11334
|
executable: existsSync6(executablePath),
|
|
11330
|
-
|
|
11331
|
-
|
|
11335
|
+
app_code_hash: signingInfo.cdHash,
|
|
11336
|
+
ad_hoc_signed: signingInfo.adHoc,
|
|
11337
|
+
microphone_permission: getTccPermission("kTCCServiceMicrophone", home, permissionCodeHash),
|
|
11338
|
+
accessibility_permission: getTccPermission("kTCCServiceAccessibility", home, permissionCodeHash),
|
|
11332
11339
|
log_path: logPath
|
|
11333
11340
|
};
|
|
11334
11341
|
}
|
|
11335
|
-
function
|
|
11342
|
+
function getCodeSigningInfo(appPath) {
|
|
11343
|
+
if (process.platform !== "darwin" || !existsSync6(appPath)) {
|
|
11344
|
+
return { cdHash: null, adHoc: false };
|
|
11345
|
+
}
|
|
11346
|
+
const result = spawnSync("codesign", ["-d", "--verbose=4", appPath], {
|
|
11347
|
+
encoding: "utf8",
|
|
11348
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
11349
|
+
});
|
|
11350
|
+
const output = `${result.stdout}
|
|
11351
|
+
${result.stderr}`;
|
|
11352
|
+
const cdHash = output.match(/^CDHash=([a-fA-F0-9]+)/m)?.[1]?.toLowerCase() ?? null;
|
|
11353
|
+
const adHoc = /Signature=adhoc/.test(output);
|
|
11354
|
+
return { cdHash, adHoc };
|
|
11355
|
+
}
|
|
11356
|
+
function getTccPermission(service, home, currentCodeHash) {
|
|
11336
11357
|
if (process.platform !== "darwin")
|
|
11337
11358
|
return "unsupported";
|
|
11338
11359
|
const dbPaths = [
|
|
11339
11360
|
pathJoin(home, "Library", "Application Support", "com.apple.TCC", "TCC.db"),
|
|
11340
11361
|
pathJoin("/", "Library", "Application Support", "com.apple.TCC", "TCC.db")
|
|
11341
11362
|
];
|
|
11342
|
-
const sql = "select auth_value from access where service = '" + service.replace(/'/g, "''") + "' and client = 'com.hasna.recordings' order by last_modified desc limit 1;";
|
|
11363
|
+
const sql = "select auth_value || '|' || ifnull(hex(csreq), '') from access where service = '" + service.replace(/'/g, "''") + "' and client = 'com.hasna.recordings' order by last_modified desc limit 1;";
|
|
11343
11364
|
for (const dbPath of dbPaths) {
|
|
11344
11365
|
if (!existsSync6(dbPath))
|
|
11345
11366
|
continue;
|
|
@@ -11348,8 +11369,14 @@ function getTccPermission(service, home) {
|
|
|
11348
11369
|
stdio: ["ignore", "pipe", "ignore"]
|
|
11349
11370
|
});
|
|
11350
11371
|
const value = result.stdout.trim();
|
|
11351
|
-
if (value)
|
|
11352
|
-
|
|
11372
|
+
if (!value)
|
|
11373
|
+
continue;
|
|
11374
|
+
const [authValue, csreqHex = ""] = value.split("|");
|
|
11375
|
+
const label = tccAuthValueLabel(authValue ?? "");
|
|
11376
|
+
if (label === "allowed" && currentCodeHash && csreqHex && !csreqHex.toLowerCase().includes(currentCodeHash.toLowerCase())) {
|
|
11377
|
+
return "stale_allowed_for_previous_app_build";
|
|
11378
|
+
}
|
|
11379
|
+
return label;
|
|
11353
11380
|
}
|
|
11354
11381
|
return "not_determined";
|
|
11355
11382
|
}
|
package/dist/mcp/index.js
CHANGED
|
@@ -21,7 +21,7 @@ var __require = import.meta.require;
|
|
|
21
21
|
var require_package = __commonJS((exports, module) => {
|
|
22
22
|
module.exports = {
|
|
23
23
|
name: "@hasna/recordings",
|
|
24
|
-
version: "0.1.
|
|
24
|
+
version: "0.1.15",
|
|
25
25
|
type: "module",
|
|
26
26
|
description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
|
|
27
27
|
repository: {
|
|
@@ -14983,7 +14983,7 @@ async function processText(rawText, config, systemPrompt) {
|
|
|
14983
14983
|
}
|
|
14984
14984
|
|
|
14985
14985
|
// src/version.ts
|
|
14986
|
-
var VERSION = "0.1.
|
|
14986
|
+
var VERSION = "0.1.15";
|
|
14987
14987
|
|
|
14988
14988
|
// src/mcp/index.ts
|
|
14989
14989
|
var config = loadConfig();
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const VERSION = "0.1.
|
|
1
|
+
export declare const VERSION = "0.1.15";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/package.json
CHANGED
|
@@ -73,4 +73,36 @@ rm -rf "$APP_DEST"
|
|
|
73
73
|
mkdir -p "$DATA_DIR"
|
|
74
74
|
cp -R "$APP_SOURCE" "$APP_DEST" || warn_or_fail "failed to copy app bundle"
|
|
75
75
|
|
|
76
|
+
current_cdhash() {
|
|
77
|
+
codesign -d --verbose=4 "$1" 2>&1 | awk -F= '/^CDHash=/ { print toupper($2); exit }'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
tcc_csreq_hex() {
|
|
81
|
+
local db_path="$1"
|
|
82
|
+
local service="$2"
|
|
83
|
+
if [ ! -r "$db_path" ] || ! command -v sqlite3 >/dev/null 2>&1; then
|
|
84
|
+
return 0
|
|
85
|
+
fi
|
|
86
|
+
sqlite3 "$db_path" \
|
|
87
|
+
"SELECT hex(csreq) FROM access WHERE service = '${service}' AND client = 'com.hasna.recordings' ORDER BY last_modified DESC LIMIT 1;" \
|
|
88
|
+
2>/dev/null || true
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
reset_stale_permission() {
|
|
92
|
+
local service="$1"
|
|
93
|
+
local tcc_service="$2"
|
|
94
|
+
local db_path="$3"
|
|
95
|
+
local cdhash="$4"
|
|
96
|
+
local csreq_hex
|
|
97
|
+
csreq_hex="$(tcc_csreq_hex "$db_path" "$tcc_service" | tr '[:lower:]' '[:upper:]')"
|
|
98
|
+
if [ -n "$cdhash" ] && [ -n "$csreq_hex" ] && [[ "$csreq_hex" != *"$cdhash"* ]]; then
|
|
99
|
+
tccutil reset "$service" com.hasna.recordings >/dev/null 2>&1 || true
|
|
100
|
+
echo "Reset stale ${service} permission for the newly installed Recordings.app."
|
|
101
|
+
fi
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
APP_CDHASH="$(current_cdhash "$APP_DEST" || true)"
|
|
105
|
+
reset_stale_permission "Microphone" "kTCCServiceMicrophone" "${HOME}/Library/Application Support/com.apple.TCC/TCC.db" "$APP_CDHASH"
|
|
106
|
+
reset_stale_permission "Accessibility" "kTCCServiceAccessibility" "/Library/Application Support/com.apple.TCC/TCC.db" "$APP_CDHASH"
|
|
107
|
+
|
|
76
108
|
echo "Installed Recordings.app from package: ${APP_DEST}"
|
|
@@ -16,12 +16,19 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
16
16
|
|
|
17
17
|
private var ws: URLSessionWebSocketTask?
|
|
18
18
|
private var receiveTask: Task<Void, Never>?
|
|
19
|
+
private var audioSendTask: Task<Void, Never>?
|
|
19
20
|
private var isConfigured = false
|
|
20
21
|
private var pendingAudioChunks: [Data] = []
|
|
21
22
|
private var itemOrder: [String] = []
|
|
22
23
|
private var deltaTextByItem: [String: String] = [:]
|
|
23
24
|
private var completedTextByItem: [String: String] = [:]
|
|
25
|
+
private var committedItemIDs = Set<String>()
|
|
26
|
+
private var completedItemIDs = Set<String>()
|
|
24
27
|
private var completedEventCount = 0
|
|
28
|
+
private var uncommittedAudioBytes = 0
|
|
29
|
+
private var lastRealtimeEventAt = Date.distantPast
|
|
30
|
+
|
|
31
|
+
private nonisolated static let minimumManualCommitBytes = 5_760
|
|
25
32
|
|
|
26
33
|
private let apiKey: String
|
|
27
34
|
private let homePath: String
|
|
@@ -52,7 +59,11 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
52
59
|
itemOrder.removeAll(keepingCapacity: true)
|
|
53
60
|
deltaTextByItem.removeAll(keepingCapacity: true)
|
|
54
61
|
completedTextByItem.removeAll(keepingCapacity: true)
|
|
62
|
+
committedItemIDs.removeAll(keepingCapacity: true)
|
|
63
|
+
completedItemIDs.removeAll(keepingCapacity: true)
|
|
55
64
|
completedEventCount = 0
|
|
65
|
+
uncommittedAudioBytes = 0
|
|
66
|
+
lastRealtimeEventAt = Date()
|
|
56
67
|
error = nil
|
|
57
68
|
|
|
58
69
|
var request = URLRequest(url: Self.transcriptionURL)
|
|
@@ -98,33 +109,49 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
98
109
|
return
|
|
99
110
|
}
|
|
100
111
|
let base64 = data.base64EncodedString()
|
|
112
|
+
uncommittedAudioBytes += data.count
|
|
101
113
|
let msg: [String: Any] = [
|
|
102
114
|
"type": "input_audio_buffer.append",
|
|
103
115
|
"audio": base64,
|
|
104
116
|
]
|
|
105
|
-
|
|
117
|
+
let previousSendTask = audioSendTask
|
|
118
|
+
audioSendTask = Task { [weak self] in
|
|
119
|
+
await previousSendTask?.value
|
|
106
120
|
try? await self?.sendEvent(msg)
|
|
107
121
|
}
|
|
108
122
|
}
|
|
109
123
|
|
|
110
124
|
/// Signal end of input — triggers final transcription completion.
|
|
111
|
-
|
|
112
|
-
|
|
125
|
+
@discardableResult
|
|
126
|
+
public func commitInput() async -> Bool {
|
|
127
|
+
guard isStreaming, ws != nil else { return false }
|
|
113
128
|
flushPendingAudio()
|
|
129
|
+
await audioSendTask?.value
|
|
130
|
+
guard Self.shouldManuallyCommit(uncommittedAudioBytes: uncommittedAudioBytes) else {
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
114
133
|
let msg: [String: Any] = [
|
|
115
134
|
"type": "input_audio_buffer.commit",
|
|
116
135
|
]
|
|
136
|
+
lastRealtimeEventAt = Date()
|
|
117
137
|
try? await sendEvent(msg)
|
|
138
|
+
return true
|
|
118
139
|
}
|
|
119
140
|
|
|
120
141
|
/// Commit buffered input, wait briefly for a final completed event, then close.
|
|
121
|
-
public func finish(timeoutMilliseconds: UInt64 =
|
|
142
|
+
public func finish(timeoutMilliseconds: UInt64 = 2_800) async -> String {
|
|
122
143
|
guard isStreaming else { return accumulatedText }
|
|
123
|
-
let
|
|
124
|
-
await commitInput()
|
|
144
|
+
let completedCountBeforeCommit = completedEventCount
|
|
145
|
+
let didManualCommit = await commitInput()
|
|
125
146
|
|
|
126
147
|
let deadline = Date().addingTimeInterval(TimeInterval(timeoutMilliseconds) / 1_000)
|
|
127
|
-
while Date() < deadline, isStreaming
|
|
148
|
+
while Date() < deadline, isStreaming {
|
|
149
|
+
let hasIncompleteCommittedItems = !committedItemIDs.subtracting(completedItemIDs).isEmpty
|
|
150
|
+
let hasManualCommitCompletion = !didManualCommit || completedEventCount > completedCountBeforeCommit
|
|
151
|
+
let quietLongEnough = Date().timeIntervalSince(lastRealtimeEventAt) >= 0.35
|
|
152
|
+
if !hasIncompleteCommittedItems, hasManualCommitCompletion, quietLongEnough {
|
|
153
|
+
break
|
|
154
|
+
}
|
|
128
155
|
try? await Task.sleep(for: .milliseconds(50))
|
|
129
156
|
}
|
|
130
157
|
|
|
@@ -139,8 +166,10 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
139
166
|
isConfigured = false
|
|
140
167
|
receiveTask?.cancel()
|
|
141
168
|
ws?.cancel(with: .normalClosure, reason: nil)
|
|
169
|
+
audioSendTask?.cancel()
|
|
142
170
|
ws = nil
|
|
143
171
|
receiveTask = nil
|
|
172
|
+
audioSendTask = nil
|
|
144
173
|
pendingAudioChunks.removeAll(keepingCapacity: true)
|
|
145
174
|
let text = accumulatedText
|
|
146
175
|
return text
|
|
@@ -180,11 +209,15 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
180
209
|
|
|
181
210
|
switch type {
|
|
182
211
|
case "input_audio_buffer.committed":
|
|
212
|
+
lastRealtimeEventAt = Date()
|
|
183
213
|
if let itemID = json["item_id"] as? String {
|
|
184
214
|
registerItem(itemID, previousItemID: json["previous_item_id"] as? String)
|
|
215
|
+
committedItemIDs.insert(itemID)
|
|
185
216
|
}
|
|
217
|
+
uncommittedAudioBytes = 0
|
|
186
218
|
|
|
187
219
|
case "conversation.item.input_audio_transcription.delta":
|
|
220
|
+
lastRealtimeEventAt = Date()
|
|
188
221
|
let itemID = json["item_id"] as? String ?? "__default__"
|
|
189
222
|
registerItem(itemID, previousItemID: nil)
|
|
190
223
|
if let delta = json["delta"] as? String {
|
|
@@ -194,21 +227,25 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
194
227
|
|
|
195
228
|
case "conversation.item.input_audio_transcription.completed":
|
|
196
229
|
// The server may send the complete transcript here
|
|
230
|
+
lastRealtimeEventAt = Date()
|
|
197
231
|
let itemID = json["item_id"] as? String ?? "__default__"
|
|
198
232
|
registerItem(itemID, previousItemID: nil)
|
|
199
233
|
if let text = json["transcript"] as? String, !text.isEmpty {
|
|
200
234
|
completedTextByItem[itemID] = text
|
|
235
|
+
completedItemIDs.insert(itemID)
|
|
201
236
|
completedEventCount += 1
|
|
202
237
|
rebuildAccumulatedText()
|
|
203
238
|
}
|
|
204
239
|
|
|
205
240
|
case "conversation.item.input_audio_transcription.failed":
|
|
241
|
+
lastRealtimeEventAt = Date()
|
|
206
242
|
if let msg = json["error"] as? [String: Any],
|
|
207
243
|
let message = msg["message"] as? String {
|
|
208
244
|
self.error = message
|
|
209
245
|
}
|
|
210
246
|
|
|
211
247
|
case "error":
|
|
248
|
+
lastRealtimeEventAt = Date()
|
|
212
249
|
if let detail = json["error"] as? [String: Any],
|
|
213
250
|
let msg = detail["message"] as? String {
|
|
214
251
|
fputs("[RealtimeClient] Error: \(msg)\n", stderr)
|
|
@@ -295,6 +332,10 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
295
332
|
return output
|
|
296
333
|
}
|
|
297
334
|
|
|
335
|
+
private nonisolated static func shouldManuallyCommit(uncommittedAudioBytes: Int) -> Bool {
|
|
336
|
+
uncommittedAudioBytes >= minimumManualCommitBytes
|
|
337
|
+
}
|
|
338
|
+
|
|
298
339
|
private nonisolated static func transcriptionSessionUpdateEvent(transcription: [String: Any]) -> [String: Any] {
|
|
299
340
|
[
|
|
300
341
|
"type": "session.update",
|
|
@@ -363,6 +404,10 @@ extension RealtimeTranscriptionClient {
|
|
|
363
404
|
joinTranscriptParts(parts)
|
|
364
405
|
}
|
|
365
406
|
|
|
407
|
+
public nonisolated static func shouldManuallyCommitTestHelper(uncommittedAudioBytes: Int) -> Bool {
|
|
408
|
+
shouldManuallyCommit(uncommittedAudioBytes: uncommittedAudioBytes)
|
|
409
|
+
}
|
|
410
|
+
|
|
366
411
|
public nonisolated static func sessionUpdateTestHelper(prompt: String, language: String = "") -> [String: Any] {
|
|
367
412
|
var transcription: [String: Any] = [
|
|
368
413
|
"model": modelID,
|
|
@@ -127,6 +127,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
127
127
|
private var streamingText = ""
|
|
128
128
|
private var recordedPCM = Data()
|
|
129
129
|
private var activeAudioPath: String?
|
|
130
|
+
private var lastAccessibilityPromptAt: Date?
|
|
130
131
|
|
|
131
132
|
// fn key monitor (CGEventTap-based, swallows fn to prevent emoji picker)
|
|
132
133
|
private let fnMonitor = FnKeyMonitor()
|
|
@@ -456,7 +457,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
456
457
|
|
|
457
458
|
self.liveTranscriptionText = ""
|
|
458
459
|
|
|
459
|
-
if let text {
|
|
460
|
+
if let text, !Self.shouldFallbackFromPartialRealtime(text: text, pcmByteCount: self.recordedPCM.count) {
|
|
460
461
|
self.log("finish using realtime text chars=\(text.count)")
|
|
461
462
|
self.isTranscribing = false
|
|
462
463
|
self.finishWithText(
|
|
@@ -467,6 +468,9 @@ public final class RecordingEngine: ObservableObject {
|
|
|
467
468
|
activeProjectName: activeProjectName
|
|
468
469
|
)
|
|
469
470
|
} else if let audioPath, self.writeCapturedWAV(to: audioPath) {
|
|
471
|
+
if let text {
|
|
472
|
+
self.log("realtime text looked partial chars=\(text.count); falling back to CLI")
|
|
473
|
+
}
|
|
470
474
|
self.log("falling back to CLI transcription audioPath=\(audioPath)")
|
|
471
475
|
self.fallbackTranscribe(
|
|
472
476
|
audioPath: audioPath,
|
|
@@ -485,6 +489,13 @@ public final class RecordingEngine: ObservableObject {
|
|
|
485
489
|
}
|
|
486
490
|
}
|
|
487
491
|
|
|
492
|
+
public nonisolated static func shouldFallbackFromPartialRealtime(text: String, pcmByteCount: Int) -> Bool {
|
|
493
|
+
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
494
|
+
guard pcmByteCount >= 48_000, !trimmed.isEmpty else { return false }
|
|
495
|
+
let words = trimmed.split(whereSeparator: { $0.isWhitespace || $0.isNewline })
|
|
496
|
+
return trimmed.count < 12 || words.count <= 2
|
|
497
|
+
}
|
|
498
|
+
|
|
488
499
|
private func finishWithText(_ text: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, activeProjectId: String?, activeProjectName: String?) {
|
|
489
500
|
log("finishWithText mode=\(curMode.rawValue) chars=\(text.count)")
|
|
490
501
|
if curMode == .command {
|
|
@@ -638,7 +649,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
638
649
|
// MARK: - Command Mode
|
|
639
650
|
|
|
640
651
|
private func runCommandMode(instruction: String) {
|
|
641
|
-
guard ensureAccessibilityPermission(prompt:
|
|
652
|
+
guard ensureAccessibilityPermission(prompt: shouldPromptAccessibility()) else {
|
|
642
653
|
log("command mode blocked by accessibility permission")
|
|
643
654
|
statusMessage = "Enable Accessibility permission for Recordings to rewrite selected text"
|
|
644
655
|
return
|
|
@@ -704,9 +715,12 @@ public final class RecordingEngine: ObservableObject {
|
|
|
704
715
|
pb.clearContents()
|
|
705
716
|
pb.setString(text, forType: .string)
|
|
706
717
|
|
|
707
|
-
|
|
718
|
+
let prompted = shouldPromptAccessibility()
|
|
719
|
+
guard ensureAccessibilityPermission(prompt: prompted) else {
|
|
708
720
|
log("paste blocked by accessibility permission; copied to clipboard")
|
|
709
|
-
self.statusMessage =
|
|
721
|
+
self.statusMessage = prompted
|
|
722
|
+
? "Copied — approve Accessibility for this Recordings app"
|
|
723
|
+
: "Copied — waiting for Accessibility approval"
|
|
710
724
|
return
|
|
711
725
|
}
|
|
712
726
|
|
|
@@ -744,14 +758,27 @@ public final class RecordingEngine: ObservableObject {
|
|
|
744
758
|
}
|
|
745
759
|
|
|
746
760
|
private func ensureAccessibilityPermission(prompt: Bool) -> Bool {
|
|
761
|
+
if AXIsProcessTrusted() {
|
|
762
|
+
return true
|
|
763
|
+
}
|
|
747
764
|
if !prompt {
|
|
748
|
-
return
|
|
765
|
+
return false
|
|
749
766
|
}
|
|
750
767
|
return AXIsProcessTrustedWithOptions(
|
|
751
768
|
["AXTrustedCheckOptionPrompt" as CFString: true] as CFDictionary
|
|
752
769
|
)
|
|
753
770
|
}
|
|
754
771
|
|
|
772
|
+
private func shouldPromptAccessibility() -> Bool {
|
|
773
|
+
let now = Date()
|
|
774
|
+
if let lastAccessibilityPromptAt,
|
|
775
|
+
now.timeIntervalSince(lastAccessibilityPromptAt) < 20 {
|
|
776
|
+
return false
|
|
777
|
+
}
|
|
778
|
+
lastAccessibilityPromptAt = now
|
|
779
|
+
return true
|
|
780
|
+
}
|
|
781
|
+
|
|
755
782
|
private func log(_ message: String) {
|
|
756
783
|
NativeAppLog.write(message, homePath: home)
|
|
757
784
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
|
-
import KeyboardShortcuts
|
|
2
|
+
@preconcurrency import KeyboardShortcuts
|
|
3
3
|
|
|
4
4
|
public struct SettingsView: View {
|
|
5
5
|
@ObservedObject public var engine: RecordingEngine
|
|
@@ -45,6 +45,32 @@ public struct SettingsView: View {
|
|
|
45
45
|
.foregroundStyle(.secondary)
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
Section("Permissions") {
|
|
49
|
+
HStack {
|
|
50
|
+
Text("Microphone")
|
|
51
|
+
Spacer()
|
|
52
|
+
Text(engine.microphonePermissionLabel)
|
|
53
|
+
.foregroundStyle(.secondary)
|
|
54
|
+
}
|
|
55
|
+
Button("Request Microphone") {
|
|
56
|
+
engine.requestMicrophonePermission()
|
|
57
|
+
}
|
|
58
|
+
HStack {
|
|
59
|
+
Text("Accessibility")
|
|
60
|
+
Spacer()
|
|
61
|
+
Text(engine.accessibilityPermissionLabel)
|
|
62
|
+
.foregroundStyle(.secondary)
|
|
63
|
+
}
|
|
64
|
+
HStack {
|
|
65
|
+
Button("Request Accessibility") {
|
|
66
|
+
engine.requestAccessibilityPermission()
|
|
67
|
+
}
|
|
68
|
+
Button("Open Accessibility Settings") {
|
|
69
|
+
engine.openAccessibilitySettings()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
48
74
|
Section("System Prompt") {
|
|
49
75
|
TextEditor(text: $projectStore.settings.globalSystemPrompt)
|
|
50
76
|
.frame(height: 80)
|
|
@@ -110,4 +110,17 @@ struct RealtimeTranscriptionTests {
|
|
|
110
110
|
let text = RealtimeTranscriptionClient.joinTranscriptPartsTestHelper(["Hello", "world.", " Next"])
|
|
111
111
|
#expect(text == "Hello world. Next")
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
@Test("Manual commit waits for enough buffered PCM audio")
|
|
115
|
+
func manualCommitThreshold() {
|
|
116
|
+
#expect(RealtimeTranscriptionClient.shouldManuallyCommitTestHelper(uncommittedAudioBytes: 4_799) == false)
|
|
117
|
+
#expect(RealtimeTranscriptionClient.shouldManuallyCommitTestHelper(uncommittedAudioBytes: 5_760) == true)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@Test("Partial realtime text falls back for longer recordings")
|
|
121
|
+
func partialRealtimeFallback() {
|
|
122
|
+
#expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "Hi", pcmByteCount: 96_000) == true)
|
|
123
|
+
#expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "This is a complete sentence.", pcmByteCount: 96_000) == false)
|
|
124
|
+
#expect(RecordingEngine.shouldFallbackFromPartialRealtime(text: "Hi", pcmByteCount: 12_000) == false)
|
|
125
|
+
}
|
|
113
126
|
}
|