From 738a9ee29e4431890222afba72470cbd6289a787 Mon Sep 17 00:00:00 2001 From: Axel Courty Date: Wed, 10 Jun 2026 14:27:50 +0200 Subject: [PATCH 1/3] Fix AI chrono edge cases + 10 bugs across calendar, pomodoro, clipboard, media, shelf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI chrono (residual gaps from v2.9.5): - Treat stop_sequence as a turn terminator (~10% of turns measured; it left the chrono running until the 600s idle fallback). - Trailing JSONL lines of a terminal message no longer wipe the frozen completion (chrono snapped to 00:00 right after "Terminé"). - Drain bytes already written in [fromOffset, eof) when arming the live watcher (vnode events only fire for future writes). - Clean per-session dedup dicts when a session stops. Confirmed bugs from parallel subsystem audits: - Calendar: the calendar filter menu had NO effect (mutated the published id set; events filtered on a stale private array that Defaults overwrote on every notch open). Route through setCalendarSelected. - Pomodoro: sleep-wake replayed every slept second on the main thread (2 Defaults writes each, chained phases/beeps). Sleep now counts as a pause (>60s gap). - Clipboard: a sensitive item pinned within its 30s window was never persisted and vanished at relaunch — pinning lifts the volatile flag. - NowPlaying: leaked continuation on pipe close (stream task hung on every media-source switch), blocking waitUntilExit in deinit, missing @MainActor (data race on @Published playbackState). - SneakPeekEngine: configured widget duration was never honored (coordinator closed at the default 1.5s) and the manual hide forced type .music. - Video download: tools install froze the UI (ditto/xattr waitUntilExit on the main actor); termination handler installed before run() so an instantly-dying process can't hang the download; handlers cleaned up when run() throws. - Shelf zip: waitUntilExit pinned a cooperative-pool thread for the whole compression. - Settings: double relinquishFocus on window close; license footer said "24 h" while the server re-check is weekly (relabeled, localized EN/FR/ES/DE); Shelf settings now inert for free accounts (including yt-dlp/ffmpeg tools install). Co-Authored-By: Claude Fable 5 --- NotchIA/ContentView.swift | 12 +++- NotchIA/Localizable.xcstrings | 20 +++--- .../NowPlayingController.swift | 63 ++++++++++++++----- .../components/Calendar/NotchIACalendar.swift | 15 +++-- .../components/Settings/LicenseSettings.swift | 6 +- .../components/Settings/SettingsView.swift | 8 +++ .../Settings/SettingsWindowController.swift | 9 ++- .../TemporaryFileStorageService.swift | 31 +++++---- .../Shelf/Services/VideoDownloadService.swift | 50 ++++++++++----- NotchIA/managers/ClaudeCodeManager.swift | 49 ++++++++++++--- NotchIA/managers/ClipboardManager.swift | 7 +++ NotchIA/managers/JSONLSessionReader.swift | 10 +++ NotchIA/managers/SneakPeekEngine.swift | 40 ++++++------ 13 files changed, 221 insertions(+), 99 deletions(-) diff --git a/NotchIA/ContentView.swift b/NotchIA/ContentView.swift index 0500e1b..4efdd6c 100644 --- a/NotchIA/ContentView.swift +++ b/NotchIA/ContentView.swift @@ -1955,9 +1955,19 @@ final class PomodoroManager: ObservableObject { private func handleTick(_ now: Date) { guard isRunning else { return } - let elapsed = max(1, Int(now.timeIntervalSince(lastTickDate).rounded(.down))) + let rawElapsed = now.timeIntervalSince(lastTickDate) lastTickDate = now + // A gap over a minute means the Mac was asleep (the runloop timer + // doesn't fire during sleep and delivers ONE tick on wake). Replaying + // it second-by-second froze the main thread for the whole catch-up + // (two Defaults writes per simulated second — 1800 iterations for a + // 30-min nap) and chain-completed phases with a burst of beeps and + // sneak peeks. The user wasn't working while the Mac slept — treat + // sleep as a pause and resume from where the timer stood. + guard rawElapsed < 60 else { return } + + let elapsed = max(1, Int(rawElapsed.rounded(.down))) for _ in 0..? + private var isClosed = false + init() { self.pipe = Pipe() self.fileHandle = pipe.fileHandleForReading @@ -497,24 +516,36 @@ actor JSONLinesPipeHandler { } } + private func resumePendingRead(with data: Data) { + pendingRead?.resume(returning: data) + pendingRead = nil + } + private func readData() async throws -> Data { + // After close(), the file handle is gone — a readabilityHandler + // installed on it would never fire and the continuation would hang. + guard !isClosed else { return Data() } return try await withCheckedThrowingContinuation { continuation in - - fileHandle.readabilityHandler = { handle in + pendingRead = continuation + // The handler runs on an arbitrary GCD queue — never touch actor + // state from it directly; hop back into the actor to resume. + fileHandle.readabilityHandler = { [weak self] handle in let data = handle.availableData handle.readabilityHandler = nil - continuation.resume(returning: data) + guard let self else { return } + Task { await self.resumePendingRead(with: data) } } } } - - func close() async { - do { - fileHandle.readabilityHandler = nil - try fileHandle.close() - try pipe.fileHandleForWriting.close() - } catch { - log.error("Error closing pipe handler: \(error.localizedDescription)") - } + + func close() { + isClosed = true + fileHandle.readabilityHandler = nil + // Unblock a suspended readData(): empty Data makes processLines' + // `guard !data.isEmpty else { break }` end the loop, so the stream + // task finishes instead of leaking its continuation. + resumePendingRead(with: Data()) + try? fileHandle.close() + try? pipe.fileHandleForWriting.close() } } diff --git a/NotchIA/components/Calendar/NotchIACalendar.swift b/NotchIA/components/Calendar/NotchIACalendar.swift index 9472a2d..8e737b8 100644 --- a/NotchIA/components/Calendar/NotchIACalendar.swift +++ b/NotchIA/components/Calendar/NotchIACalendar.swift @@ -461,13 +461,16 @@ struct CalendarView: View { /// Toggle calendar visibility for filter menu. private func toggleCalendarSelection(_ id: String) { - if calendarManager.selectedCalendarIDs.contains(id) { - calendarManager.selectedCalendarIDs.remove(id) - } else { - calendarManager.selectedCalendarIDs.insert(id) - } + guard let calendar = calendarManager.allCalendars.first(where: { $0.id == id }) else { return } + let isSelected = calendarManager.selectedCalendarIDs.contains(id) Task { - await calendarManager.updateEvents() + // Route through setCalendarSelected: it persists to Defaults and + // rebuilds the private `selectedCalendars` array that + // updateEvents()/updateMonthEvents() actually filter on. Mutating + // selectedCalendarIDs directly only moved the checkmark — the + // event list never changed and the toggle was discarded on the + // next notch open (updateSelectedCalendars re-reads Defaults). + await calendarManager.setCalendarSelected(calendar, isSelected: !isSelected) } } diff --git a/NotchIA/components/Settings/LicenseSettings.swift b/NotchIA/components/Settings/LicenseSettings.swift index d125cb7..1c16f77 100644 --- a/NotchIA/components/Settings/LicenseSettings.swift +++ b/NotchIA/components/Settings/LicenseSettings.swift @@ -181,10 +181,12 @@ struct LicenseSettings: View { } private var footerHint: String { + // 7 jours = LicenseManager.revocationInterval. L'UI annonçait « 24 h » + // alors que le check serveur est hebdomadaire. if let last = license.lastVerifiedAt { - return String(localized: "Dernière vérification : \(formattedDateTime(last)). Re-vérifié automatiquement toutes les 24 h.") + return String(localized: "Dernière vérification : \(formattedDateTime(last)). Re-vérifié automatiquement tous les 7 jours.") } - return String(localized: "La licence est revérifiée toutes les 24 h auprès du serveur.") + return String(localized: "La licence est revérifiée tous les 7 jours auprès du serveur.") } private func activate() { diff --git a/NotchIA/components/Settings/SettingsView.swift b/NotchIA/components/Settings/SettingsView.swift index e778602..3db455f 100644 --- a/NotchIA/components/Settings/SettingsView.swift +++ b/NotchIA/components/Settings/SettingsView.swift @@ -1187,6 +1187,12 @@ struct Shelf: View { if !license.state.isPro { proRequiredBanner() } + // Shelf est Pro : les réglages restent visibles mais inertes en + // compte free (le banner et son bouton « Voir Pro » restent + // actifs, hors du Group). Sans ce gate un user free pouvait + // écrire les Defaults Shelf et lancer l'installation des outils + // yt-dlp/ffmpeg depuis la section téléchargement vidéo. + Group { Section { Defaults.Toggle(key: .notchiaShelf) { Text("Activer Shelf") @@ -1275,6 +1281,8 @@ struct Shelf: View { } videoDownloadSection + } + .disabled(!license.state.isPro) } .accentColor(.effectiveAccent) .navigationTitle("Shelf") diff --git a/NotchIA/components/Settings/SettingsWindowController.swift b/NotchIA/components/Settings/SettingsWindowController.swift index 29a5fbf..d024987 100644 --- a/NotchIA/components/Settings/SettingsWindowController.swift +++ b/NotchIA/components/Settings/SettingsWindowController.swift @@ -109,11 +109,10 @@ class SettingsWindowController: NSWindowController { } } - override func close() { - super.close() - relinquishFocus() - } - + // NOTE: no `close()` override. `super.close()` already triggers + // `windowWillClose`, which calls `relinquishFocus()` — overriding here + // ran orderOut + setActivationPolicy(.accessory) twice per close. + private func relinquishFocus() { window?.orderOut(nil) diff --git a/NotchIA/components/Shelf/Services/TemporaryFileStorageService.swift b/NotchIA/components/Shelf/Services/TemporaryFileStorageService.swift index 886c0d4..baffef7 100644 --- a/NotchIA/components/Shelf/Services/TemporaryFileStorageService.swift +++ b/NotchIA/components/Shelf/Services/TemporaryFileStorageService.swift @@ -140,20 +140,27 @@ class TemporaryFileStorageService { return nil } - // Helper to run zip process - func runZip(arguments: [String], currentDirectory: URL) -> Bool { + // Helper to run zip process. Suspends on the terminationHandler + // instead of waitUntilExit(): the synchronous wait pinned a thread of + // the cooperative pool for the whole compression (minutes for a large + // folder), starving other async work. Handler installed BEFORE run() + // so an instantly-exiting process can't be missed. + func runZip(arguments: [String], currentDirectory: URL) async -> Bool { let proc = Process() proc.executableURL = URL(fileURLWithPath: "/usr/bin/zip") proc.arguments = arguments proc.currentDirectoryURL = currentDirectory - do { - try proc.run() - proc.waitUntilExit() - return proc.terminationStatus == 0 - } catch { - print("❌ Failed to run zip: \(error)") - return false + let launched = await withCheckedContinuation { (cont: CheckedContinuation) in + proc.terminationHandler = { _ in cont.resume(returning: true) } + do { + try proc.run() + } catch { + proc.terminationHandler = nil + print("❌ Failed to run zip: \(error)") + cont.resume(returning: false) + } } + return launched && proc.terminationStatus == 0 } // Single-item optimization: do not copy contents into the working dir. @@ -168,7 +175,7 @@ class TemporaryFileStorageService { // Run zip from the parent directory so the folder is stored as top-level entry let parent = src.deletingLastPathComponent() let args = ["-r", "-q", archiveURL.path, baseName] - let ok = runZip(arguments: args, currentDirectory: parent) + let ok = await runZip(arguments: args, currentDirectory: parent) if ok { return archiveURL } else { @@ -181,7 +188,7 @@ class TemporaryFileStorageService { let parent = src.deletingLastPathComponent() // -j to junk paths and store only the file let args = ["-j", "-q", archiveURL.path, baseName] - let ok = runZip(arguments: args, currentDirectory: parent) + let ok = await runZip(arguments: args, currentDirectory: parent) if ok { return archiveURL } else { @@ -209,7 +216,7 @@ class TemporaryFileStorageService { let archiveName = suggestedName ?? "Archive.zip" let archiveURL = workingDir.appendingPathComponent(archiveName) let args = ["-r", "-q", archiveURL.path, "."] - let ok = runZip(arguments: args, currentDirectory: workingDir) + let ok = await runZip(arguments: args, currentDirectory: workingDir) if ok { // Remove the copied (uncompressed) items so the temp folder contains only the archive do { diff --git a/NotchIA/components/Shelf/Services/VideoDownloadService.swift b/NotchIA/components/Shelf/Services/VideoDownloadService.swift index f9f9b34..365f858 100644 --- a/NotchIA/components/Shelf/Services/VideoDownloadService.swift +++ b/NotchIA/components/Shelf/Services/VideoDownloadService.swift @@ -152,7 +152,7 @@ final class VideoDownloadService { let fm = FileManager.default try? fm.removeItem(at: dest) try fm.moveItem(at: tmp, to: dest) - clearQuarantine(dest) + await clearQuarantine(dest) } private func downloadAndUnzipFFmpeg() async throws { @@ -177,8 +177,7 @@ final class VideoDownloadService { let ditto = Process() ditto.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") ditto.arguments = ["-x", "-k", zipURL.path, unzipDir.path] - try ditto.run() - ditto.waitUntilExit() + try await runUntilExit(ditto) guard ditto.terminationStatus == 0 else { throw DownloadError.toolsUnavailable(reason: "décompression ffmpeg échouée") } @@ -190,7 +189,7 @@ final class VideoDownloadService { } try? fm.removeItem(at: ffmpegPath) try fm.moveItem(at: ffmpegBin, to: ffmpegPath) - clearQuarantine(ffmpegPath) + await clearQuarantine(ffmpegPath) } private func makeExecutable(_ url: URL) throws { @@ -200,12 +199,28 @@ final class VideoDownloadService { /// Retire le flag quarantine au cas où (les binaires écrits via /// URLSession n'en ont normalement pas, mais ceinture+bretelles). - private func clearQuarantine(_ url: URL) { + private func clearQuarantine(_ url: URL) async { let p = Process() p.executableURL = URL(fileURLWithPath: "/usr/bin/xattr") p.arguments = ["-d", "com.apple.quarantine", url.path] - try? p.run() - p.waitUntilExit() + try? await runUntilExit(p) + } + + /// Launches the process and suspends until it exits, WITHOUT blocking the + /// main actor (this class is @MainActor — `waitUntilExit()` froze the + /// whole UI for the seconds ditto takes to unzip the ~70 MB ffmpeg + /// archive during first install / tools update). The terminationHandler + /// is installed BEFORE run() so there is no missed-exit race. + private func runUntilExit(_ process: Process) async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + process.terminationHandler = { _ in cont.resume() } + do { + try process.run() + } catch { + process.terminationHandler = nil + cont.resume(throwing: error) + } + } } // MARK: - Téléchargement vidéo @@ -323,16 +338,19 @@ final class VideoDownloadService { stderrText.append(chunk) } - try process.run() - - // Attend la fin sans bloquer le main actor. - await withCheckedContinuation { (cont: CheckedContinuation) in - process.terminationHandler = { _ in cont.resume() } + // Attend la fin sans bloquer le main actor. runUntilExit installe le + // terminationHandler AVANT run() : posé après coup (l'ancien code), + // un process qui meurt instantanément (binaire corrompu/tronqué) + // pouvait ne jamais le déclencher → continuation suspendue et + // téléchargement bloqué « en cours » à jamais. Le defer garantit le + // nettoyage des handlers même quand run() throw (avant, ils + // restaient posés avec currentProcess pointant un process mort). + defer { + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + currentProcess = nil } - - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil - currentProcess = nil + try await runUntilExit(process) guard process.terminationStatus == 0 else { if process.terminationReason == .uncaughtSignal { diff --git a/NotchIA/managers/ClaudeCodeManager.swift b/NotchIA/managers/ClaudeCodeManager.swift index 3ab2677..cac9a1f 100644 --- a/NotchIA/managers/ClaudeCodeManager.swift +++ b/NotchIA/managers/ClaudeCodeManager.swift @@ -823,6 +823,9 @@ final class ClaudeCodeManager: ObservableObject { sessionStates.removeValue(forKey: sessionId) pendingToolChecksBySession.removeValue(forKey: sessionId) isLoadingHistoryBySession.removeValue(forKey: sessionId) + countedMsgIdsBySession.removeValue(forKey: sessionId) + lastNotifiedEndTurnId.removeValue(forKey: sessionId) + lastCompletionNotifAt.removeValue(forKey: sessionId) sessionsLoggedAsMissing.remove(sessionId) // Update sessionsNeedingPermission @@ -987,7 +990,7 @@ final class ClaudeCodeManager: ObservableObject { } } - if (message["stop_reason"] as? String) == "end_turn" { + if stopReasonEndsTurn(message["stop_reason"] as? String) { lastEndTurn = ts ?? lastEndTurn lastEndTurnId = message["id"] as? String ?? lastEndTurnId tailActive = false @@ -1055,6 +1058,9 @@ final class ClaudeCodeManager: ObservableObject { /// Parse message content for a specific session private func parseMessageForSession(_ message: [String: Any], sessionId: String, timestamp: Date? = nil) { let isLoadingHistory = isLoadingHistoryBySession[sessionId] == true + // See parseMessage: trailing lines of a terminal message must not + // re-activate the state the end_turn/stop_sequence just settled. + let endsTurn = Self.stopReasonEndsTurn(message["stop_reason"] as? String) // Extract model if let model = message["model"] as? String { @@ -1090,7 +1096,7 @@ final class ClaudeCodeManager: ObservableObject { sessionStates[sessionId]?.isThinking = true lastActivityTime = Date() } - } else if role == "assistant" { + } else if role == "assistant" && !endsTurn { sessionStates[sessionId]?.isThinking = true } } @@ -1108,8 +1114,7 @@ final class ClaudeCodeManager: ObservableObject { } // End-of-turn — final response received, prompt cycle is over. - if let stopReason = message["stop_reason"] as? String, - stopReason == "end_turn", + if Self.stopReasonEndsTurn(message["stop_reason"] as? String), !isLoadingHistory { captureCompletionIfNeeded(for: sessionId, at: timestamp ?? Date()) sessionStates[sessionId]?.isThinking = false @@ -1128,7 +1133,9 @@ final class ClaudeCodeManager: ObservableObject { .trimmingCharacters(in: .whitespacesAndNewlines) if !preview.isEmpty { sessionStates[sessionId]?.latestThinking = String(preview.prefix(220)) - sessionStates[sessionId]?.isThinking = true + if !endsTurn { + sessionStates[sessionId]?.isThinking = true + } } } @@ -1602,6 +1609,16 @@ final class ClaudeCodeManager: ObservableObject { CodexJSONHelpers.parseDate(value as? String) } + /// Stop reasons that genuinely CLOSE a prompt cycle. Besides `end_turn`, + /// Claude Code ends ~10% of turns with `stop_sequence` (measured 113 vs + /// 1036 end_turn across 25 real transcripts), and in every observed case + /// the next line is a fresh user prompt — never a continuation. Without + /// treating it as terminal those turns left the chrono running until the + /// 600s idle fallback. `tool_use` is the only continuation stop reason. + nonisolated static func stopReasonEndsTurn(_ stopReason: String?) -> Bool { + stopReason == "end_turn" || stopReason == "stop_sequence" + } + nonisolated static func messageIsRealUserPrompt(_ message: [String: Any]) -> Bool { if message["content"] is String { return true } if let items = message["content"] as? [[String: Any]] { @@ -1619,6 +1636,14 @@ final class ClaudeCodeManager: ObservableObject { state.model = model } + // A terminal message (end_turn / stop_sequence) spans SEVERAL JSONL + // lines sharing one msgid (thinking + text). Only the first one runs + // the completion block meaningfully; the trailing ones must NOT + // re-activate "thinking" or call markSelectedStateActive — that wiped + // the frozen completion (chrono snapped back to 00:00 and the + // "Terminé" celebration vanished as soon as the trailing line landed). + let endsTurn = Self.stopReasonEndsTurn(message["stop_reason"] as? String) + // Track prompt-cycle boundary based on message role. // role=user → start of a new prompt cycle. Reset chrono and // cumulative token counter. Claude is about to @@ -1647,7 +1672,7 @@ final class ClaudeCodeManager: ObservableObject { state.isThinking = true lastActivityTime = Date() } - } else if role == "assistant" { + } else if role == "assistant" && !endsTurn { state.isThinking = true markSelectedStateActive() } @@ -1681,8 +1706,7 @@ final class ClaudeCodeManager: ObservableObject { // won't continue without a new user prompt. Stop the chrono now; // strict `hasAnySessionActivity` will hide the notch on the next // SwiftUI tick. - if let stopReason = message["stop_reason"] as? String, - stopReason == "end_turn", + if Self.stopReasonEndsTurn(message["stop_reason"] as? String), !isLoadingHistory { // `hadCycle` is true ONLY when this end_turn closes a real, // tracked prompt cycle (activityStartedAt was set). That gate @@ -1729,8 +1753,13 @@ final class ClaudeCodeManager: ObservableObject { if !preview.isEmpty { state.latestThinking = String(preview.prefix(220)) state.lastMessageTime = Date() - state.isThinking = true - markSelectedStateActive() + // Trailing thinking line of a terminal message: + // update the preview but do NOT revive the + // chrono/celebration the end_turn just froze. + if !endsTurn { + state.isThinking = true + markSelectedStateActive() + } } } diff --git a/NotchIA/managers/ClipboardManager.swift b/NotchIA/managers/ClipboardManager.swift index b3c4892..f3a3a3d 100644 --- a/NotchIA/managers/ClipboardManager.swift +++ b/NotchIA/managers/ClipboardManager.swift @@ -248,6 +248,13 @@ final class ClipboardManager: ObservableObject { func togglePin(_ item: ClipboardItem) { guard let idx = items.firstIndex(where: { $0.id == item.id }) else { return } items[idx].isPinned.toggle() + if items[idx].isPinned { + // Pinning is an explicit "keep this": lift the volatile flag so + // the item is actually persisted. A sensitive item pinned within + // its 30s auto-clear window stayed flagged, was excluded from + // every saveToDisk forever, and silently vanished at relaunch. + volatileItemIDs.remove(item.id) + } saveToDisk() Haptics.play(.selection) } diff --git a/NotchIA/managers/JSONLSessionReader.swift b/NotchIA/managers/JSONLSessionReader.swift index ca3ccc2..18e0287 100644 --- a/NotchIA/managers/JSONLSessionReader.swift +++ b/NotchIA/managers/JSONLSessionReader.swift @@ -181,6 +181,16 @@ final class JSONLSessionReader { self.watcher = source source.resume() + + // vnode events only fire for FUTURE writes. Bytes already sitting in + // [fromOffset, eof) — appended between the replay snapshot and this + // arm — would otherwise wait for the next append to be parsed, which + // can be minutes away (or never, if that write ended the turn). Drain + // them now so the handoff is truly gap-free. + if position < eof { + readNew() + } + return true } diff --git a/NotchIA/managers/SneakPeekEngine.swift b/NotchIA/managers/SneakPeekEngine.swift index 2d44502..038bdcd 100644 --- a/NotchIA/managers/SneakPeekEngine.swift +++ b/NotchIA/managers/SneakPeekEngine.swift @@ -104,14 +104,12 @@ class SneakPeekEngine: ObservableObject { break // Not supported in SneakPeek Engine yet } - // Schedule hide after duration - let duration = Defaults[.sneakPeekEngineDuration] - Task { - try? await Task.sleep(for: .seconds(duration)) - await MainActor.run { - hideWidget() - } - } + // Hide is scheduled by the coordinator (scheduleSneakPeekHide) from + // the duration each show* passes to toggleSneakPeek. The old manual + // hide Task here was both wrong and redundant: the widgets were shown + // with the DEFAULT 1.5s duration (the configured engine duration was + // never honored — the coordinator closed them early), and the late + // manual hide forced type .music, glitching the closing content. } private func enabledWidgetTypes() -> [SneakContentType] { @@ -135,33 +133,33 @@ class SneakPeekEngine: ObservableObject { return types } + private var widgetDuration: TimeInterval { + Defaults[.sneakPeekEngineDuration] + } + private func showMusicWidget() { - coordinator.toggleSneakPeek(status: true, type: .music, value: 0, icon: "music.note") + coordinator.toggleSneakPeek(status: true, type: .music, duration: widgetDuration, value: 0, icon: "music.note") } - + private func showPomodoroWidget() { let pomodoroManager = PomodoroManager.shared let icon = pomodoroManager.isRunning ? "timer" : "pause.fill" - coordinator.toggleSneakPeek(status: true, type: .pomodoro, value: 0, icon: icon) + coordinator.toggleSneakPeek(status: true, type: .pomodoro, duration: widgetDuration, value: 0, icon: icon) } - + private func showClipboardWidget() { - coordinator.toggleSneakPeek(status: true, type: .clipboard, value: 0, icon: "doc.on.clipboard") + coordinator.toggleSneakPeek(status: true, type: .clipboard, duration: widgetDuration, value: 0, icon: "doc.on.clipboard") } - + private func showVolumeWidget() { let volumeManager = VolumeManager.shared let currentVolume = volumeManager.rawVolume - coordinator.toggleSneakPeek(status: true, type: .volume, value: CGFloat(currentVolume), icon: "speaker.wave.2.fill") + coordinator.toggleSneakPeek(status: true, type: .volume, duration: widgetDuration, value: CGFloat(currentVolume), icon: "speaker.wave.2.fill") } - + private func showBrightnessWidget() { let brightnessManager = BrightnessManager.shared let currentBrightness = brightnessManager.rawBrightness - coordinator.toggleSneakPeek(status: true, type: .brightness, value: CGFloat(currentBrightness), icon: "sun.max.fill") - } - - private func hideWidget() { - coordinator.toggleSneakPeek(status: false, type: .music) + coordinator.toggleSneakPeek(status: true, type: .brightness, duration: widgetDuration, value: CGFloat(currentBrightness), icon: "sun.max.fill") } } From 7531ecb8ae9295ee5cfc4546a416af8c792b2b61 Mon Sep 17 00:00:00 2001 From: Anka Date: Wed, 10 Jun 2026 12:29:45 +0000 Subject: [PATCH 2/3] Set version to v2.9.6 (build 20906) --- NotchIA.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/NotchIA.xcodeproj/project.pbxproj b/NotchIA.xcodeproj/project.pbxproj index aa78d2e..6a262c6 100644 --- a/NotchIA.xcodeproj/project.pbxproj +++ b/NotchIA.xcodeproj/project.pbxproj @@ -1289,7 +1289,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20905; + CURRENT_PROJECT_VERSION = 20906; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1297,7 +1297,7 @@ INFOPLIST_KEY_CFBundleDisplayName = NotchIAXPCHelper; INFOPLIST_KEY_NSHumanReadableCopyright = ""; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.9.5; + MARKETING_VERSION = 2.9.6; PRODUCT_BUNDLE_IDENTIFIER = com.coaxel2.notchia.NotchIAXPCHelper; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -1315,7 +1315,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20905; + CURRENT_PROJECT_VERSION = 20906; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1323,7 +1323,7 @@ INFOPLIST_KEY_CFBundleDisplayName = NotchIAXPCHelper; INFOPLIST_KEY_NSHumanReadableCopyright = ""; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.9.5; + MARKETING_VERSION = 2.9.6; PRODUCT_BUNDLE_IDENTIFIER = com.coaxel2.notchia.NotchIAXPCHelper; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -1474,7 +1474,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20905; + CURRENT_PROJECT_VERSION = 20906; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"NotchIA/Preview Content\""; DEVELOPMENT_TEAM = ""; @@ -1502,7 +1502,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.9.5; + MARKETING_VERSION = 2.9.6; PRODUCT_BUNDLE_IDENTIFIER = com.coaxel2.notchia; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1530,7 +1530,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 20905; + CURRENT_PROJECT_VERSION = 20906; DEAD_CODE_STRIPPING = YES; DEPLOYMENT_POSTPROCESSING = YES; DEVELOPMENT_ASSET_PATHS = "\"NotchIA/Preview Content\""; @@ -1558,7 +1558,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.9.5; + MARKETING_VERSION = 2.9.6; PRODUCT_BUNDLE_IDENTIFIER = com.coaxel2.notchia; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; From 48025a195a14efd44eadb7e8c1723f63944bab0f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 10 Jun 2026 12:35:10 +0000 Subject: [PATCH 3/3] Update version to v2.9.6 and appcast --- updater/appcast.xml | 54 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/updater/appcast.xml b/updater/appcast.xml index 9a9816d..f719380 100644 --- a/updater/appcast.xml +++ b/updater/appcast.xml @@ -32,6 +32,50 @@ --> + + 2.9.6 + Wed, 10 Jun 2026 12:35:08 +0000 + https://github.com/coaxel2/NotchIA/releases + 20906 + 2.9.6 + 15.0 + + + 2.9.5 Tue, 09 Jun 2026 19:50:54 +0000 @@ -69,16 +113,6 @@ - - 2.9.3 - Wed, 03 Jun 2026 19:57:23 +0000 - https://github.com/coaxel2/NotchIA/releases - 20903 - 2.9.3 - 15.0 - - - 2.7.3 Mon, 24 Nov 2025 08:07:37 +0000