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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ All notable changes to TickerBar will be documented in this file.

## [Unreleased]

## [1.5.1] - 2026-08-16

### Fixed
- Quotes keep refreshing after a market closes. Timer refreshes were skipped whenever no watchlist session was live, but that decision read the market state cached by the last fetch — and only a fetch could update it. The first closed session therefore froze the app for the rest of its life: it never noticed the market reopening, so the menu bar kept showing a price from hours or days earlier until TickerBar was relaunched. A closed market now only slows the cadence to every 15 minutes rather than stopping refreshes, so reopenings, holidays and half-days are all picked up without help.
- Waking the Mac refreshes straight away, instead of showing the prices from before it went to sleep until the next scheduled tick.
- The watchlist tops up quotes that have aged past one refresh interval as its panel appears, so what you open is not showing stale numbers.
- A failed refresh is retried after 5, 15 and 60 seconds before falling back to the normal cadence. The refresh moments after wake usually fails because the network isn't up yet, and that attempt used to cost a whole interval.
- Refresh and rotation now run in the common run loop modes, so both keep ticking while the watchlist panel is open. Both also carry a wakeup tolerance, letting macOS coalesce them with other timers instead of waking the CPU on their own.

## [1.5.0] - 2026-08-02

### Changed
Expand Down
4 changes: 2 additions & 2 deletions TickerBar/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.5.0</string>
<string>1.5.1</string>
<key>CFBundleVersion</key>
<string>1.5.0</string>
<string>1.5.1</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
Expand Down
135 changes: 122 additions & 13 deletions TickerBar/Services/StockService.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import Foundation
import SwiftUI
@preconcurrency import UserNotifications
Expand All @@ -20,7 +21,7 @@ final class StockService {
didSet { defaults.set(displayNames, forKey: "displayNames") }
}
var refreshInterval: TimeInterval {
didSet { defaults.set(refreshInterval, forKey: "refreshInterval"); restartRefreshTimer() }
didSet { defaults.set(refreshInterval, forKey: "refreshInterval"); rescheduleRefreshIfRunning() }
}
var rotationEnabled: Bool {
didSet { defaults.set(rotationEnabled, forKey: "rotationEnabled"); restartRotationTimer() }
Expand Down Expand Up @@ -98,8 +99,30 @@ final class StockService {
}

// MARK: - Timers
//
// The refresh timer is a one-shot that re-arms itself after every fetch, so
// its cadence always reflects the market state the fetch just observed.
// A repeating timer plus a "skip the fetch while markets are closed" gate
// deadlocks: the gate reads `marketState`, and only a fetch can update it,
// so the first closed session freezes the app for the rest of its life.
private var refreshTimer: Timer?
private var rotationTimer: Timer?
private var isScheduling = false
private var wakeObserver: NSObjectProtocol?
private var inFlightFetch: Task<Bool, Never>?
private var consecutiveFailures = 0

/// Cadence used when no watchlist session is live. Long enough to cost
/// nothing (96 fetches a day) but short enough that the next session — or a
/// holiday the local-clock heuristic cannot know about — is picked up soon
/// after Yahoo reports it.
static let idleRefreshInterval: TimeInterval = 15 * 60

/// Bounded fast retries, so a fetch that failed only because the network
/// wasn't up yet (the usual case moments after wake) recovers in seconds
/// instead of waiting out a whole cadence. Beyond the last step the normal
/// cadence takes over, so a long Yahoo outage can't turn into a hot loop.
nonisolated static let failureBackoff: [TimeInterval] = [5, 15, 60]

// MARK: - Networking (all Yahoo HTTP/auth/parse lives in YahooFinanceClient)
private let api = YahooFinanceClient()
Expand Down Expand Up @@ -275,13 +298,41 @@ final class StockService {
return stocks.contains(where: isDisplayActive)
}

func fetchAllQuotes(isTimerTriggered: Bool = false) async {
// Timer refreshes pause only when every supported session is closed.
// Manual refreshes, initial load, and add-stock fetches always proceed.
if isTimerTriggered && !anyMarketActive {
/// Fetches every watchlist quote, then re-arms the refresh timer for the
/// market state that fetch observed. Concurrent callers — timer, wake,
/// popover, manual button — coalesce onto the one in-flight fetch. Returns
/// false when the fetch failed outright and last-good data was kept.
@discardableResult
func fetchAllQuotes() async -> Bool {
if let inFlightFetch { return await inFlightFetch.value }
let fetch = Task { @MainActor in await performFetch() }
inFlightFetch = fetch
let succeeded = await fetch.value
inFlightFetch = nil

if succeeded {
consecutiveFailures = 0
} else if consecutiveFailures <= Self.failureBackoff.count {
consecutiveFailures += 1
}
rescheduleRefreshIfRunning()
return succeeded
}

/// Refresh only when the displayed data has aged past one refresh interval.
/// Called as the watchlist opens, so what you actually look at is current
/// even while the slow idle cadence is running.
func refreshIfStale() async {
guard let lastUpdated else {
await fetchAllQuotes()
return
}
if Date().timeIntervalSince(lastUpdated) >= refreshInterval {
await fetchAllQuotes()
}
}

private func performFetch() async -> Bool {
isLoading = true

// Ensure we have a valid crumb before fetching
Expand All @@ -290,7 +341,7 @@ final class StockService {
} catch {
errorMessage = "Authentication failed"
isLoading = false
return
return false
}

let symbols = watchlist
Expand Down Expand Up @@ -341,7 +392,7 @@ final class StockService {
errorMessage = "Couldn't refresh — showing last update"
api.invalidateAuth()
isLoading = false
return
return false
}

// Fetch v7 quote data for pre/post market prices (single batch call)
Expand Down Expand Up @@ -401,6 +452,7 @@ final class StockService {
lastUpdated = Date()
isLoading = false
checkPriceAlerts()
return true
}

/// Merge freshly-fetched quotes with the previous snapshot, preserving
Expand Down Expand Up @@ -693,39 +745,96 @@ final class StockService {
// MARK: - Timer Management

func startTimers() {
restartRefreshTimer()
isScheduling = true
observeWake()
restartRotationTimer()

// Initial fetch
// The initial fetch arms the refresh timer for whatever it observes.
Task { @MainActor in
await fetchAllQuotes()
}
}

func stopTimers() {
isScheduling = false
refreshTimer?.invalidate()
rotationTimer?.invalidate()
refreshTimer = nil
rotationTimer = nil
if let wakeObserver {
NSWorkspace.shared.notificationCenter.removeObserver(wakeObserver)
self.wakeObserver = nil
}
}

private func restartRefreshTimer() {
/// Waking leaves the quotes stale by however long the lid was shut, and the
/// pending one-shot is overdue rather than aligned to the new wall clock.
/// Fetch straight away and let that fetch re-anchor the cadence.
private func observeWake() {
guard wakeObserver == nil else { return }
wakeObserver = NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.didWakeNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
await self?.fetchAllQuotes()
}
}
}

/// The cadence the market state observed by the last fetch calls for. A
/// closed market only slows refreshes down; it never stops them, so
/// `marketState` is always re-read and a reopening is always noticed. The
/// user's interval always wins when it is slower than the idle cadence.
var refreshCadence: TimeInterval {
anyMarketActive ? refreshInterval : max(refreshInterval, Self.idleRefreshInterval)
}

/// How long until the next refresh. Failures pull the next attempt in to a
/// bounded retry — never past the cadence, since retrying slower than the
/// normal rhythm helps nobody — and once the retries are spent the cadence
/// takes back over, so an outage can't become a hot loop.
nonisolated static func refreshDelay(cadence: TimeInterval, consecutiveFailures: Int) -> TimeInterval {
guard consecutiveFailures > 0, consecutiveFailures <= failureBackoff.count else { return cadence }
return min(failureBackoff[consecutiveFailures - 1], cadence)
}

var nextRefreshDelay: TimeInterval {
Self.refreshDelay(cadence: refreshCadence, consecutiveFailures: consecutiveFailures)
}

private func rescheduleRefreshIfRunning() {
guard isScheduling else { return }
refreshTimer?.invalidate()
refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { [weak self] _ in

let delay = nextRefreshDelay
let timer = Timer(timeInterval: delay, repeats: false) { [weak self] _ in
Task { @MainActor in
await self?.fetchAllQuotes(isTimerTriggered: true)
await self?.fetchAllQuotes()
}
}
// Correctness comes from re-arming after each fetch, not from firing on
// an exact second, so let the OS coalesce this wakeup with others.
timer.tolerance = min(delay * 0.1, 30)
// `.common` so a refresh still lands while the watchlist popover is up:
// its menu-tracking run loop doesn't service `.default`-only timers.
RunLoop.main.add(timer, forMode: .common)
refreshTimer = timer
}

private func restartRotationTimer() {
rotationTimer?.invalidate()
guard rotationEnabled else { return }
rotationTimer = Timer.scheduledTimer(withTimeInterval: rotationSpeed, repeats: true) { [weak self] _ in

let timer = Timer(timeInterval: rotationSpeed, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.advanceDisplay()
}
}
timer.tolerance = min(rotationSpeed * 0.1, 1)
RunLoop.main.add(timer, forMode: .common)
rotationTimer = timer
}

}
Expand Down
3 changes: 3 additions & 0 deletions TickerBar/Views/WatchlistView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,9 @@ struct WatchlistView: View {
}
}
.frame(width: 300)
// Opening the watchlist is a direct request to look at the numbers, so
// top them up if the idle cadence has let them age past one interval.
.task { await service.refreshIfStale() }
.background(service.solidPopoverBackground ? Color(nsColor: .windowBackgroundColor) : Color.clear)
.background(
// Measure the content's settled height in SwiftUI space and feed it
Expand Down
62 changes: 62 additions & 0 deletions TickerBarTests/StockServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,68 @@ final class StockServiceTests: XCTestCase {
XCTAssertEqual(service.currentDisplayStock?.symbol, "B")
}

// MARK: - Refresh scheduling

func testClosedMarketsSlowTheCadenceInsteadOfStoppingRefreshes() {
// The bug this defends: refreshes used to be skipped outright whenever
// no session was active. The skip was decided from the cached
// `marketState`, which only a fetch can update, so the first closed
// session froze the app until it was relaunched — it never noticed the
// market reopening. A closed market must only slow the cadence.
let service = StockService(defaults: defaults)
var a = stock("A", price: 1); a.marketState = "CLOSED"
var b = stock("B", price: 2); b.marketState = "CLOSED"
service.stocks = [a, b]

XCTAssertFalse(service.anyMarketActive)
XCTAssertEqual(service.refreshCadence, StockService.idleRefreshInterval)
XCTAssertEqual(service.nextRefreshDelay, StockService.idleRefreshInterval)

// Yahoo reports the reopening on the next idle tick, and the cadence
// tightens back to the user's interval without a relaunch.
b.marketState = "REGULAR"
service.stocks = [a, b]
XCTAssertEqual(service.refreshCadence, service.refreshInterval)
}

func testExtendedSessionKeepsTheFastCadenceWhenEnabled() {
let service = StockService(defaults: defaults)
var post = stock("A", price: 1); post.marketState = "POST"
service.stocks = [post]

XCTAssertEqual(service.refreshCadence, StockService.idleRefreshInterval)
service.extendedHoursEnabled = true
XCTAssertEqual(service.refreshCadence, service.refreshInterval)
}

func testSlowUserIntervalIsNeverOverriddenByIdleCadence() {
let service = StockService(defaults: defaults)
service.refreshInterval = StockService.idleRefreshInterval * 2
var closed = stock("A", price: 1); closed.marketState = "CLOSED"
service.stocks = [closed]

XCTAssertEqual(service.refreshCadence, service.refreshInterval)
}

func testRefreshDelayRetriesFastThenReturnsToCadence() {
let idle = StockService.idleRefreshInterval
XCTAssertEqual(StockService.refreshDelay(cadence: idle, consecutiveFailures: 0), idle)
XCTAssertEqual(StockService.refreshDelay(cadence: idle, consecutiveFailures: 1), 5)
XCTAssertEqual(StockService.refreshDelay(cadence: idle, consecutiveFailures: 2), 15)
XCTAssertEqual(StockService.refreshDelay(cadence: idle, consecutiveFailures: 3), 60)

// Retries are spent: fall back to the cadence rather than hammering a
// provider that is having a bad day.
XCTAssertEqual(StockService.refreshDelay(cadence: idle, consecutiveFailures: 4), idle)
XCTAssertEqual(StockService.refreshDelay(cadence: idle, consecutiveFailures: 99), idle)
}

func testRefreshDelayNeverRetriesSlowerThanTheCadence() {
// A 30s cadence must not be pushed out to the 60s backoff step.
XCTAssertEqual(StockService.refreshDelay(cadence: 30, consecutiveFailures: 3), 30)
XCTAssertEqual(StockService.refreshDelay(cadence: 30, consecutiveFailures: 1), 5)
}

// MARK: - Rotation index sync

func testNormalizeDisplayIndexLandsOnOpenAndMatchesGetter() {
Expand Down