Skip to content
Closed
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
11 changes: 11 additions & 0 deletions PanBar/Data/Persistence/Repositories/SettingsRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ struct SettingsRepository {
static let tickerShowTotalAssets = "ticker_show_total_assets"
static let tickerShowTodayPnL = "ticker_show_today_pnl"
static let tickerShowAllTimePnL = "ticker_show_alltime_pnl"
static let tickerShowAppIcon = "ticker_show_app_icon"
static let tickerShowQuoteCode = "ticker_show_quote_code"
static let tickerShowQuoteName = "ticker_show_quote_name"
static let tickerMenuBarWidth = "ticker_menu_bar_width"
static let tickerScrollMenuBarWidth = "ticker_scroll_menu_bar_width"
static let tickerCarouselMenuBarWidth = "ticker_carousel_menu_bar_width"
static let tickerCompactMenuBarWidth = "ticker_compact_menu_bar_width"
static let tickerScrollAutoWidth = "ticker_scroll_auto_width"
static let tickerCarouselAutoWidth = "ticker_carousel_auto_width"
static let tickerCompactAutoWidth = "ticker_compact_auto_width"
static let tickerShowDirectionArrow = "ticker_show_direction_arrow"
static let hideOnScreenShare = "hide_on_screen_share"
static let privacyManualHide = "privacy_manual_hide"
static let tickerIndexIDs = "ticker_index_ids"
Expand Down
4 changes: 3 additions & 1 deletion PanBar/Domain/Services/PortfolioService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ actor PortfolioService {
if allSymbols.isEmpty {
quotes = [:]
} else {
quotes = (try? await provider.fetch(Array(allSymbols))) ?? [:]
let fetched = try await provider.fetch(Array(allSymbols))
guard !fetched.isEmpty else { throw ProviderError.empty }
quotes = fetched
}

let converter = await fx.currentConverter()
Expand Down
29 changes: 15 additions & 14 deletions PanBar/Infrastructure/HotkeyBinding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,24 +84,25 @@ struct HotkeyBinding: Codable, Equatable, Sendable {
guard let source = TISCopyCurrentASCIICapableKeyboardLayoutInputSource()?.takeRetainedValue() else { return nil }
guard let layoutData = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { return nil }
let dataRef = unsafeBitCast(layoutData, to: CFData.self)
let layoutPtr = CFDataGetBytePtr(dataRef)!
let keyLayout = unsafeBitCast(layoutPtr, to: UnsafePointer<UCKeyboardLayout>.self)
guard let layoutPtr = CFDataGetBytePtr(dataRef) else { return nil }

var deadKeyState: UInt32 = 0
var actualLength = 0
var chars: [UniChar] = Array(repeating: 0, count: 4)
let status = UCKeyTranslate(
keyLayout,
keyCode,
UInt16(kUCKeyActionDisplay),
0,
UInt32(LMGetKbdType()),
UInt32(kUCKeyTranslateNoDeadKeysBit),
&deadKeyState,
chars.count,
&actualLength,
&chars
)
let status = layoutPtr.withMemoryRebound(to: UCKeyboardLayout.self, capacity: 1) { keyLayout in
UCKeyTranslate(
keyLayout,
keyCode,
UInt16(kUCKeyActionDisplay),
0,
UInt32(LMGetKbdType()),
UInt32(kUCKeyTranslateNoDeadKeysBit),
&deadKeyState,
chars.count,
&actualLength,
&chars
)
}
guard status == noErr, actualLength > 0 else { return nil }
return String(utf16CodeUnits: chars, count: actualLength)
}
Expand Down
14 changes: 9 additions & 5 deletions PanBar/Infrastructure/NotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,15 @@ final class NotificationService: NSObject, ObservableObject {
} else {
Log.app.info("notification auth granted=\(granted, privacy: .public)")
}
DispatchQueue.main.async {
Task { @MainActor in
self?.refreshAuthorizationStatus()
}
}
}

func refreshAuthorizationStatus() {
center.getNotificationSettings { [weak self] settings in
DispatchQueue.main.async {
Task { @MainActor in
self?.authorizationStatus = settings.authorizationStatus
}
}
Expand All @@ -72,16 +72,20 @@ final class NotificationService: NSObject, ObservableObject {
center.add(request) { [weak self] error in
if let error = error {
Log.app.error("notification add failed: \(String(describing: error), privacy: .public)")
self?.diag("add failed: \(error)")
Task { @MainActor in
self?.diag("add failed: \(error)")
}
} else {
self?.diag("add ok id=\(identifier)")
Task { @MainActor in
self?.diag("add ok id=\(identifier)")
}
}
}

// 同步检查权限,如果是 denied 就走弹窗 fallback
center.getNotificationSettings { [weak self] settings in
if settings.authorizationStatus == .denied {
DispatchQueue.main.async {
Task { @MainActor in
self?.showFallbackAlert(title: title, body: body)
}
}
Expand Down
31 changes: 16 additions & 15 deletions PanBar/Infrastructure/QuoteRefresher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,32 +224,33 @@ final class QuoteRefresher: ObservableObject {
}

private func tick() async {
await MainActor.run { self.isRefreshing = true }
guard !isRefreshing else { return }
isRefreshing = true
defer { isRefreshing = false }

do {
let snap = try await service.computeSnapshot()
// 网络空响应(provider.fetch 失败 -> quotes 空)时,不要拿一个全 nil quote 的
// snapshot 去覆盖磁盘 seed 的好数据;原样保留 cached snapshot,等下次 tick。
let hasFreshData = !snap.allQuotes.isEmpty
await MainActor.run {
if hasFreshData {
self.snapshot = snap
self.snapshotIsFromCache = false
self.quotes.merge(snap.allQuotes) { _, new in new }
self.lastUpdated = Date()
self.lastError = nil
self.alertEngine?.evaluate(quotes: self.quotes)
let isEmptyPortfolio = snap.positions.isEmpty && snap.allQuotes.isEmpty
if hasFreshData || isEmptyPortfolio {
snapshot = snap
snapshotIsFromCache = false
if isEmptyPortfolio {
quotes = [:]
} else {
quotes.merge(snap.allQuotes) { _, new in new }
}
lastUpdated = Date()
lastError = nil
alertEngine?.evaluate(quotes: quotes)
}
// 拉到了就异步写盘,下次冷启动可以秒读
if hasFreshData {
quoteCacheRepo?.upsertMany(snap.allQuotes)
}
} catch {
await MainActor.run {
self.lastError = String(describing: error)
}
lastError = String(describing: error)
Log.quote.error("refresh failed: \(String(describing: error), privacy: .public)")
}
await MainActor.run { self.isRefreshing = false }
}
}
151 changes: 145 additions & 6 deletions PanBar/Infrastructure/TickerPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,67 @@ final class TickerPreferences: ObservableObject {
@Published var showAllTimePnL: Bool {
didSet { try? repo.set(SettingsRepository.Keys.tickerShowAllTimePnL, showAllTimePnL ? "1" : "0") }
}
@Published var showAppIcon: Bool {
didSet { try? repo.set(SettingsRepository.Keys.tickerShowAppIcon, showAppIcon ? "1" : "0") }
}
@Published var showQuoteCode: Bool {
didSet { try? repo.set(SettingsRepository.Keys.tickerShowQuoteCode, showQuoteCode ? "1" : "0") }
}
@Published var showQuoteName: Bool {
didSet { try? repo.set(SettingsRepository.Keys.tickerShowQuoteName, showQuoteName ? "1" : "0") }
}
@Published var menuBarWidth: Int {
didSet {
let clamped = Self.clampMenuBarWidth(menuBarWidth)
if clamped != menuBarWidth {
menuBarWidth = clamped
} else {
try? repo.set(SettingsRepository.Keys.tickerMenuBarWidth, "\(menuBarWidth)")
}
}
}
@Published var scrollMenuBarWidth: Int {
didSet {
let clamped = Self.clampScrollMenuBarWidth(scrollMenuBarWidth)
if clamped != scrollMenuBarWidth {
scrollMenuBarWidth = clamped
} else {
try? repo.set(SettingsRepository.Keys.tickerScrollMenuBarWidth, "\(scrollMenuBarWidth)")
}
}
}
@Published var carouselMenuBarWidth: Int {
didSet {
let clamped = Self.clampCarouselMenuBarWidth(carouselMenuBarWidth)
if clamped != carouselMenuBarWidth {
carouselMenuBarWidth = clamped
} else {
try? repo.set(SettingsRepository.Keys.tickerCarouselMenuBarWidth, "\(carouselMenuBarWidth)")
}
}
}
@Published var compactMenuBarWidth: Int {
didSet {
let clamped = Self.clampCompactMenuBarWidth(compactMenuBarWidth)
if clamped != compactMenuBarWidth {
compactMenuBarWidth = clamped
} else {
try? repo.set(SettingsRepository.Keys.tickerCompactMenuBarWidth, "\(compactMenuBarWidth)")
}
}
}
@Published var scrollAutoWidth: Bool {
didSet { try? repo.set(SettingsRepository.Keys.tickerScrollAutoWidth, scrollAutoWidth ? "1" : "0") }
}
@Published var carouselAutoWidth: Bool {
didSet { try? repo.set(SettingsRepository.Keys.tickerCarouselAutoWidth, carouselAutoWidth ? "1" : "0") }
}
@Published var compactAutoWidth: Bool {
didSet { try? repo.set(SettingsRepository.Keys.tickerCompactAutoWidth, compactAutoWidth ? "1" : "0") }
}
@Published var showDirectionArrow: Bool {
didSet { try? repo.set(SettingsRepository.Keys.tickerShowDirectionArrow, showDirectionArrow ? "1" : "0") }
}
/// 哪些大盘指数显示在滚动条中(存 IndexDescriptor.id 集合)。
@Published var tickerIndexIDs: Set<String> {
didSet {
Expand All @@ -56,41 +117,119 @@ final class TickerPreferences: ObservableObject {

private let repo: SettingsRepository

private static func clampMenuBarWidth(_ value: Int) -> Int {
min(520, max(80, value))
}

private static func clampScrollMenuBarWidth(_ value: Int) -> Int {
min(720, max(160, value))
}

private static func clampCarouselMenuBarWidth(_ value: Int) -> Int {
min(360, max(100, value))
}

private static func clampCompactMenuBarWidth(_ value: Int) -> Int {
min(360, max(60, value))
}

init(repo: SettingsRepository) {
self.repo = repo
self.colorScheme = repo.colorScheme
self.scrollSpeed = ScrollSpeed(rawValue: repo.string(SettingsRepository.Keys.tickerSpeed) ?? "") ?? .medium
self.pauseOnHover = repo.string(SettingsRepository.Keys.pauseOnHover) != "0"
self.pauseWhenClosed = repo.string(SettingsRepository.Keys.pauseWhenClosed) != "0"
self.maxItems = Int(repo.string(SettingsRepository.Keys.maxTickerItems) ?? "10") ?? 10
// Summary 三项默认不显示(避免菜单栏一启动就长),用户主动开启
self.showTotalAssets = repo.string(SettingsRepository.Keys.tickerShowTotalAssets) == "1"
self.showTodayPnL = repo.string(SettingsRepository.Keys.tickerShowTodayPnL) == "1"
self.showAllTimePnL = repo.string(SettingsRepository.Keys.tickerShowAllTimePnL) == "1"
let storedDisplayMode = TickerDisplayMode(rawValue: repo.string(SettingsRepository.Keys.tickerDisplayMode) ?? "") ?? .scroll
let storedMinimalMetric = MinimalMetric(rawValue: repo.string(SettingsRepository.Keys.tickerMinimalMetric) ?? "") ?? .todayPnL
let migratesMinimal = storedDisplayMode == .minimal
// Summary 三项默认不显示(避免菜单栏一启动就长),用户主动开启。
// 旧版「极简」迁移为「固定」:只保留原来极简选择的那个指标。
if migratesMinimal {
let migratedShowTotalAssets = storedMinimalMetric == .totalAssets
let migratedShowTodayPnL = storedMinimalMetric == .todayPnL
let migratedShowAllTimePnL = storedMinimalMetric == .allTimePnL
self.showTotalAssets = migratedShowTotalAssets
self.showTodayPnL = migratedShowTodayPnL
self.showAllTimePnL = migratedShowAllTimePnL
try? repo.set(SettingsRepository.Keys.tickerShowTotalAssets, migratedShowTotalAssets ? "1" : "0")
try? repo.set(SettingsRepository.Keys.tickerShowTodayPnL, migratedShowTodayPnL ? "1" : "0")
try? repo.set(SettingsRepository.Keys.tickerShowAllTimePnL, migratedShowAllTimePnL ? "1" : "0")
} else {
self.showTotalAssets = repo.string(SettingsRepository.Keys.tickerShowTotalAssets) == "1"
self.showTodayPnL = repo.string(SettingsRepository.Keys.tickerShowTodayPnL) == "1"
self.showAllTimePnL = repo.string(SettingsRepository.Keys.tickerShowAllTimePnL) == "1"
}
if let json = repo.string(SettingsRepository.Keys.tickerIndexIDs),
let data = json.data(using: .utf8),
let arr = try? JSONDecoder().decode([String].self, from: data) {
self.tickerIndexIDs = Set(arr)
} else {
self.tickerIndexIDs = []
}
self.displayMode = TickerDisplayMode(rawValue: repo.string(SettingsRepository.Keys.tickerDisplayMode) ?? "") ?? .scroll
self.minimalMetric = MinimalMetric(rawValue: repo.string(SettingsRepository.Keys.tickerMinimalMetric) ?? "") ?? .todayPnL
self.displayMode = storedDisplayMode == .scrollNoCode ? .scroll : (migratesMinimal ? .compact : storedDisplayMode)
if migratesMinimal {
try? repo.set(SettingsRepository.Keys.tickerDisplayMode, TickerDisplayMode.compact.rawValue)
}
self.minimalMetric = storedMinimalMetric
self.carouselDwell = Int(repo.string(SettingsRepository.Keys.tickerCarouselDwell) ?? "") ?? 4
self.showAppIcon = repo.string(SettingsRepository.Keys.tickerShowAppIcon) != "0"
if storedDisplayMode == .scrollNoCode {
self.showQuoteCode = false
} else {
self.showQuoteCode = repo.string(SettingsRepository.Keys.tickerShowQuoteCode) != "0"
}
self.showQuoteName = repo.string(SettingsRepository.Keys.tickerShowQuoteName) != "0"
let rawLegacyWidth = Int(repo.string(SettingsRepository.Keys.tickerMenuBarWidth) ?? "") ?? 280
let legacyWidth = Self.clampMenuBarWidth(rawLegacyWidth)
self.menuBarWidth = legacyWidth
try? repo.set(SettingsRepository.Keys.tickerMenuBarWidth, "\(legacyWidth)")
let rawScrollWidth = Int(repo.string(SettingsRepository.Keys.tickerScrollMenuBarWidth) ?? "") ?? max(160, legacyWidth)
let rawCarouselWidth = Int(repo.string(SettingsRepository.Keys.tickerCarouselMenuBarWidth) ?? "") ?? min(360, max(160, legacyWidth))
let rawCompactWidth = Int(repo.string(SettingsRepository.Keys.tickerCompactMenuBarWidth) ?? "") ?? 160
let scrollWidth = Self.clampScrollMenuBarWidth(rawScrollWidth)
let carouselWidth = Self.clampCarouselMenuBarWidth(rawCarouselWidth)
let compactWidth = Self.clampCompactMenuBarWidth(rawCompactWidth)
self.scrollMenuBarWidth = scrollWidth
self.carouselMenuBarWidth = carouselWidth
self.compactMenuBarWidth = compactWidth
try? repo.set(SettingsRepository.Keys.tickerScrollMenuBarWidth, "\(scrollWidth)")
try? repo.set(SettingsRepository.Keys.tickerCarouselMenuBarWidth, "\(carouselWidth)")
try? repo.set(SettingsRepository.Keys.tickerCompactMenuBarWidth, "\(compactWidth)")
self.scrollAutoWidth = repo.string(SettingsRepository.Keys.tickerScrollAutoWidth) == "1"
self.carouselAutoWidth = repo.string(SettingsRepository.Keys.tickerCarouselAutoWidth) == "1"
self.compactAutoWidth = repo.string(SettingsRepository.Keys.tickerCompactAutoWidth) != "0"
self.showDirectionArrow = migratesMinimal || repo.string(SettingsRepository.Keys.tickerShowDirectionArrow) == "1"
if migratesMinimal {
try? repo.set(SettingsRepository.Keys.tickerShowDirectionArrow, "1")
}
}

}

/// 菜单栏 ticker 的展现形式。
enum TickerDisplayMode: String, CaseIterable, Identifiable, Codable {
case scroll // 经典左右滚动(默认)
case scrollNoCode // 旧版本兼容:现在用 showQuoteCode 控制
case carousel // 一条一条上下淡入轮播
case compact // 三个简写卡片(今日 / 总盈亏 / 总市值)
case minimal // 只显示一个用户选定的数字

static let allCases: [TickerDisplayMode] = [.scroll, .carousel, .compact]

var id: String { rawValue }

var usesScrollingView: Bool {
switch self {
case .scroll, .scrollNoCode: return true
case .carousel, .compact, .minimal: return false
}
}

var displayName: String {
switch self {
case .scroll: return L("displayMode.scroll", comment: "")
case .scrollNoCode: return L("displayMode.scroll", comment: "")
case .carousel: return L("displayMode.carousel", comment: "")
case .compact: return L("displayMode.compact", comment: "")
case .minimal: return L("displayMode.minimal", comment: "")
Expand Down
4 changes: 2 additions & 2 deletions PanBar/Infrastructure/Updater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,11 @@ final class Updater: NSObject, ObservableObject {
}
do {
let release = try await self.fetchLatestRelease()
await self.handleResult(release: release, silent: silent)
self.handleResult(release: release, silent: silent)
} catch {
Log.app.warning("update check failed: \(String(describing: error), privacy: .public)")
if !silent {
await self.showError(error)
self.showError(error)
}
}
}
Expand Down
Loading
Loading