Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Sources/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ final class AppState: ObservableObject {
if permsJustChanged, settings.playSounds { NSSound(named: "Glass")?.play() }
}
}

// Reclaim the WebView's WebContent memory when idle (ChatGPT provider only).
// Cheap to call every tick; it self-gates on idle time and never fires mid-use.
if settings.provider == .chatgpt, !busy, !recorder.isRecording {
bridge.recycleIfIdle()
}
}

/// The backend selected in Settings.
Expand Down
2 changes: 2 additions & 0 deletions Sources/Audio/AudioRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ final class AudioRecorder: NSObject, ObservableObject, AVAudioRecorderDelegate {
rec.delegate = self
rec.isMeteringEnabled = true
guard rec.record() else {
// AVAudioRecorder may have already created the file on disk — don't orphan it.
try? FileManager.default.removeItem(at: url)
throw NSError(domain: "LizardType", code: 1,
userInfo: [NSLocalizedDescriptionKey: "AVAudioRecorder failed to start (mic permission?)"])
}
Expand Down
41 changes: 39 additions & 2 deletions Sources/Bridge/ChatGPTBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ final class ChatGPTBridge: NSObject, WKNavigationDelegate {
private var tokenFetchedAt: Date = .distantPast
private let tokenTTL: TimeInterval = 240

/// Last time the bridge did real work, and last time we recycled the page.
/// Used to reclaim WebContent memory when the app has been idle (see `recycleIfIdle`).
private var lastActivity = Date()
private var lastReload = Date()

override init() {
let cfg = WKWebViewConfiguration()
cfg.websiteDataStore = .default()
Expand Down Expand Up @@ -64,11 +69,41 @@ final class ChatGPTBridge: NSObject, WKNavigationDelegate {
// small settle for any CF/app bootstrapping
try? await Task.sleep(nanoseconds: 1_500_000_000)
self.isReady = true
let waiters = self.readyWaiters; self.readyWaiters.removeAll()
for w in waiters { w.resume() }
self.resumeWaiters()
}
}

// If a navigation fails we must still resume anyone parked in `waitUntilReady()`,
// otherwise those continuations (and their captured state) leak and `warmBridge` hangs.
nonisolated func webView(_ wv: WKWebView, didFail nav: WKNavigation!, withError error: Error) {
Task { @MainActor in self.resumeWaiters() }
}

nonisolated func webView(_ wv: WKWebView, didFailProvisionalNavigation nav: WKNavigation!, withError error: Error) {
Task { @MainActor in self.resumeWaiters() }
}

private func resumeWaiters() {
let waiters = readyWaiters; readyWaiters.removeAll()
for w in waiters { w.resume() }
}

/// Reclaim WebContent-process memory by reloading the warmed page after a long
/// idle stretch. ChatGPT's SPA never frees what it accumulates across dictations,
/// so a fresh load bounds the growth. No-op while busy / recently used so it never
/// interrupts an in-flight request. Cookies persist in the data store, so the
/// reloaded page stays logged in.
func recycleIfIdle(idleThreshold: TimeInterval = 600) {
guard isReady else { return } // mid-load: leave it alone
let now = Date()
guard now.timeIntervalSince(lastActivity) > idleThreshold,
now.timeIntervalSince(lastReload) > idleThreshold else { return }
lastReload = now
isReady = false
cachedToken = nil
webView.reload()
}

// MARK: - Auth

@discardableResult
Expand Down Expand Up @@ -99,6 +134,7 @@ final class ChatGPTBridge: NSObject, WKNavigationDelegate {
/// POST audio to /backend-api/transcribe. Returns the raw transcript text.
func transcribe(audioURL: URL, language: String) async throws -> String {
guard isReady else { throw BridgeError.notReady }
lastActivity = Date()
let data = try Data(contentsOf: audioURL)
let b64 = data.base64EncodedString()
let name = audioURL.lastPathComponent
Expand Down Expand Up @@ -151,6 +187,7 @@ final class ChatGPTBridge: NSObject, WKNavigationDelegate {
/// Send `prompt + raw` to /backend-api/conversation; return the cleaned text.
func cleanup(raw: String, prompt: String, model: String, language: String) async throws -> String {
guard isReady else { throw BridgeError.notReady }
lastActivity = Date()
let message = Prompts.cleanupMessage(prompt: prompt, raw: raw)
let token = try await accessToken()
let js = """
Expand Down