* Add i18n infrastructure with String Catalog and Japanese translations Introduce String Catalog (.xcstrings) for localization support: - Localizable.xcstrings: 195 UI string entries with en and ja translations - InfoPlist.xcstrings: Info.plist strings (microphone usage, Finder menu items) - project.pbxproj: add xcstrings to build phase and ja to knownRegions * Replace hardcoded UI strings with String(localized:defaultValue:) Migrate all user-facing strings across 11 source files to use String(localized:defaultValue:) API (macOS 13+). Each string references a key in Localizable.xcstrings, with the English text preserved as defaultValue for fallback. Files modified: - KeyboardShortcutSettings: 28 shortcut labels - SocketControlSettings: mode names and descriptions - TabManager: placement labels, color names, close dialogs - BrowserPanel/BrowserPanelView: error pages, context menus, tooltips - UpdateViewModel/UpdatePopoverView/UpdatePill: update UI states - NotificationsPage: notification panel labels - SurfaceSearchOverlay: search bar placeholder and tooltips - AppDelegate: menus, dialogs, command palette items * Fix localization gaps from review feedback Address review comments from CodeRabbit, Greptile, and Cubic Dev AI: - Use interpolated String(localized:) instead of concatenation for version/progress strings in UpdateViewModel - Localize remaining hardcoded strings in AppDelegate: window labels, rename dialog, status menu items, unread notification count - Localize insecure HTTP alert body in BrowserPanel - Add 12 new entries to Localizable.xcstrings with Japanese translations * Fix String(localized:defaultValue:) keys to use StaticString The localized: parameter requires StaticString when defaultValue: is used. Move string interpolation from the key to defaultValue only, and revert maxWidthText to plain strings since they are only used for layout width calculation. * Localize remaining UI strings across all source files Add String(localized:defaultValue:) to all user-facing strings in: - cmuxApp.swift: settings screen, menus, about panel, dialogs (~180 strings) - ContentView.swift: command palette, sidebar context menu, dialogs (~200 strings) - Workspace.swift: rename/move/close tab dialogs, tooltips (~20 strings) - UpdateTitlebarAccessory.swift: titlebar tooltips, notifications popover (~10 strings) - TerminalNotificationStore.swift: notification permission dialog (4 strings) - CmuxWebView.swift: browser context menu items (2 strings) - AppDelegate.swift: CLI install/uninstall alerts (6 strings) Add 418 new entries to Localizable.xcstrings with Japanese translations. Extract sidebar context menu into separate @ViewBuilder to fix Swift type-checker timeout in large body. Fix xcstrings format specifiers for interpolated strings (%lld, %@). Total: 624 localization entries covering the full UI. * Address review feedback: fix missing localizations and terminology - Localize javaScriptDialogTitle URL branch in BrowserPanel - Localize cantReach error message in BrowserPanel - Localize close other tabs dialog message in TabManager - Localize workspace accessibility label in ContentView - Fix unread notification singular/plural (split into two keys) - Fix insecure connection apostrophe inconsistency (unify to U+2019) - Rename socketControl.fullOpen.description to socketControl.allowAll.description - Remove dead code: renameTargetNoun function - Fix terminology inconsistencies in xcstrings: - Unify "Developer Tools" to デベロッパツール - Unify "Jump to Latest Unread" phrasing - Unify "Flash Focused Panel" terminology - Fix dialog.enableNotifications.notNow translation * fix: address remaining PR 819 review feedback * fix: use a single localized key for close-other-tabs * fix: avoid inflection markup in close-other-tabs message * Address review feedback: localize tooltip, fix subtitle concat, unify keys - Localize menubar tooltip unread count (hardcoded English -> localized) - Replace subtitle string concatenation anti-pattern with single localized keys containing interpolation placeholders - Unify workspace fallback key to workspace.displayName.fallback - Remove unused workspace.defaultName key from xcstrings - Add Japanese translations for new tooltip and subtitle keys
625 lines
22 KiB
Swift
625 lines
22 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import UserNotifications
|
|
|
|
// UNUserNotificationCenter.removeDeliveredNotifications(withIdentifiers:) and
|
|
// removePendingNotificationRequests(withIdentifiers:) perform synchronous XPC to
|
|
// usernoted under the hood. When usernoted is slow, this blocks the calling thread
|
|
// indefinitely. These helpers dispatch the calls off the main thread so they never
|
|
// freeze the UI.
|
|
extension UNUserNotificationCenter {
|
|
private static let removalQueue = DispatchQueue(
|
|
label: "com.cmuxterm.notification-removal",
|
|
qos: .utility
|
|
)
|
|
|
|
func removeDeliveredNotificationsOffMain(withIdentifiers ids: [String]) {
|
|
guard !ids.isEmpty else { return }
|
|
Self.removalQueue.async {
|
|
self.removeDeliveredNotifications(withIdentifiers: ids)
|
|
}
|
|
}
|
|
|
|
func removePendingNotificationRequestsOffMain(withIdentifiers ids: [String]) {
|
|
guard !ids.isEmpty else { return }
|
|
Self.removalQueue.async {
|
|
self.removePendingNotificationRequests(withIdentifiers: ids)
|
|
}
|
|
}
|
|
}
|
|
|
|
enum NotificationSoundSettings {
|
|
static let key = "notificationSound"
|
|
static let defaultValue = "default"
|
|
static let customCommandKey = "notificationCustomCommand"
|
|
static let defaultCustomCommand = ""
|
|
|
|
static let systemSounds: [(label: String, value: String)] = [
|
|
("Default", "default"),
|
|
("Basso", "Basso"),
|
|
("Blow", "Blow"),
|
|
("Bottle", "Bottle"),
|
|
("Frog", "Frog"),
|
|
("Funk", "Funk"),
|
|
("Glass", "Glass"),
|
|
("Hero", "Hero"),
|
|
("Morse", "Morse"),
|
|
("Ping", "Ping"),
|
|
("Pop", "Pop"),
|
|
("Purr", "Purr"),
|
|
("Sosumi", "Sosumi"),
|
|
("Submarine", "Submarine"),
|
|
("Tink", "Tink"),
|
|
("None", "none"),
|
|
]
|
|
|
|
static func sound(defaults: UserDefaults = .standard) -> UNNotificationSound? {
|
|
let value = defaults.string(forKey: key) ?? defaultValue
|
|
switch value {
|
|
case "default":
|
|
return .default
|
|
case "none":
|
|
return nil
|
|
default:
|
|
return UNNotificationSound(named: UNNotificationSoundName(rawValue: value))
|
|
}
|
|
}
|
|
|
|
static func isSilent(defaults: UserDefaults = .standard) -> Bool {
|
|
return (defaults.string(forKey: key) ?? defaultValue) == "none"
|
|
}
|
|
|
|
static func previewSound(value: String) {
|
|
switch value {
|
|
case "default":
|
|
NSSound.beep()
|
|
case "none":
|
|
break
|
|
default:
|
|
NSSound(named: NSSound.Name(value))?.play()
|
|
}
|
|
}
|
|
|
|
private static let customCommandQueue = DispatchQueue(
|
|
label: "com.cmuxterm.notification-custom-command",
|
|
qos: .utility
|
|
)
|
|
|
|
static func runCustomCommand(title: String, subtitle: String, body: String, defaults: UserDefaults = .standard) {
|
|
let command = (defaults.string(forKey: customCommandKey) ?? defaultCustomCommand)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !command.isEmpty else { return }
|
|
customCommandQueue.async {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/bin/sh")
|
|
process.arguments = ["-c", command]
|
|
var env = ProcessInfo.processInfo.environment
|
|
env["CMUX_NOTIFICATION_TITLE"] = title
|
|
env["CMUX_NOTIFICATION_SUBTITLE"] = subtitle
|
|
env["CMUX_NOTIFICATION_BODY"] = body
|
|
process.environment = env
|
|
process.standardOutput = FileHandle.nullDevice
|
|
process.standardError = FileHandle.nullDevice
|
|
do {
|
|
try process.run()
|
|
} catch {
|
|
NSLog("Notification command failed to launch: \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
enum NotificationBadgeSettings {
|
|
static let dockBadgeEnabledKey = "notificationDockBadgeEnabled"
|
|
static let defaultDockBadgeEnabled = true
|
|
|
|
static func isDockBadgeEnabled(defaults: UserDefaults = .standard) -> Bool {
|
|
if defaults.object(forKey: dockBadgeEnabledKey) == nil {
|
|
return defaultDockBadgeEnabled
|
|
}
|
|
return defaults.bool(forKey: dockBadgeEnabledKey)
|
|
}
|
|
}
|
|
|
|
enum TaggedRunBadgeSettings {
|
|
static let environmentKey = "CMUX_TAG"
|
|
private static let maxTagLength = 10
|
|
|
|
static func normalizedTag(from env: [String: String] = ProcessInfo.processInfo.environment) -> String? {
|
|
normalizedTag(env[environmentKey])
|
|
}
|
|
|
|
static func normalizedTag(_ rawTag: String?) -> String? {
|
|
guard var tag = rawTag?.trimmingCharacters(in: .whitespacesAndNewlines), !tag.isEmpty else {
|
|
return nil
|
|
}
|
|
if tag.count > maxTagLength {
|
|
tag = String(tag.prefix(maxTagLength))
|
|
}
|
|
return tag
|
|
}
|
|
}
|
|
|
|
enum AppFocusState {
|
|
static var overrideIsFocused: Bool?
|
|
|
|
static func isAppActive() -> Bool {
|
|
if let overrideIsFocused {
|
|
return overrideIsFocused
|
|
}
|
|
return NSApp.isActive
|
|
}
|
|
|
|
static func isAppFocused() -> Bool {
|
|
if let overrideIsFocused {
|
|
return overrideIsFocused
|
|
}
|
|
guard NSApp.isActive else { return false }
|
|
guard let keyWindow = NSApp.keyWindow, keyWindow.isKeyWindow else { return false }
|
|
// Only treat the app as "focused" for notification suppression when a main terminal window
|
|
// is key. If Settings/About/debug panels are key, we still want notifications to show.
|
|
if let raw = keyWindow.identifier?.rawValue {
|
|
return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
struct TerminalNotification: Identifiable, Hashable {
|
|
let id: UUID
|
|
let tabId: UUID
|
|
let surfaceId: UUID?
|
|
let title: String
|
|
let subtitle: String
|
|
let body: String
|
|
let createdAt: Date
|
|
var isRead: Bool
|
|
}
|
|
|
|
@MainActor
|
|
final class TerminalNotificationStore: ObservableObject {
|
|
private struct TabSurfaceKey: Hashable {
|
|
let tabId: UUID
|
|
let surfaceId: UUID?
|
|
}
|
|
|
|
private struct NotificationIndexes {
|
|
var unreadCount = 0
|
|
var unreadCountByTabId: [UUID: Int] = [:]
|
|
var unreadByTabSurface = Set<TabSurfaceKey>()
|
|
var latestUnreadByTabId: [UUID: TerminalNotification] = [:]
|
|
var latestByTabId: [UUID: TerminalNotification] = [:]
|
|
}
|
|
|
|
static let shared = TerminalNotificationStore()
|
|
|
|
static let categoryIdentifier = "com.cmuxterm.app.userNotification"
|
|
static let actionShowIdentifier = "com.cmuxterm.app.userNotification.show"
|
|
|
|
@Published private(set) var notifications: [TerminalNotification] = [] {
|
|
didSet {
|
|
indexes = Self.buildIndexes(for: notifications)
|
|
refreshDockBadge()
|
|
}
|
|
}
|
|
|
|
private let center = UNUserNotificationCenter.current()
|
|
private var hasRequestedAuthorization = false
|
|
private var hasPromptedForSettings = false
|
|
private var userDefaultsObserver: NSObjectProtocol?
|
|
private let settingsPromptWindowRetryDelay: TimeInterval = 0.5
|
|
private let settingsPromptWindowRetryLimit = 20
|
|
private var notificationSettingsWindowProvider: () -> NSWindow? = {
|
|
NSApp.keyWindow ?? NSApp.mainWindow
|
|
}
|
|
private var notificationSettingsAlertFactory: () -> NSAlert = {
|
|
NSAlert()
|
|
}
|
|
private var notificationSettingsScheduler: (_ delay: TimeInterval, _ block: @escaping () -> Void) -> Void = {
|
|
delay,
|
|
block in
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
|
block()
|
|
}
|
|
}
|
|
private var notificationSettingsURLOpener: (URL) -> Void = { url in
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
private var indexes = NotificationIndexes()
|
|
|
|
private init() {
|
|
indexes = Self.buildIndexes(for: notifications)
|
|
userDefaultsObserver = NotificationCenter.default.addObserver(
|
|
forName: UserDefaults.didChangeNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.refreshDockBadge()
|
|
}
|
|
refreshDockBadge()
|
|
}
|
|
|
|
deinit {
|
|
if let userDefaultsObserver {
|
|
NotificationCenter.default.removeObserver(userDefaultsObserver)
|
|
}
|
|
}
|
|
|
|
static func dockBadgeLabel(unreadCount: Int, isEnabled: Bool, runTag: String? = nil) -> String? {
|
|
let unreadLabel: String? = {
|
|
guard isEnabled, unreadCount > 0 else { return nil }
|
|
if unreadCount > 99 {
|
|
return "99+"
|
|
}
|
|
return String(unreadCount)
|
|
}()
|
|
|
|
if let tag = TaggedRunBadgeSettings.normalizedTag(runTag) {
|
|
if let unreadLabel {
|
|
return "\(tag):\(unreadLabel)"
|
|
}
|
|
return tag
|
|
}
|
|
|
|
return unreadLabel
|
|
}
|
|
|
|
var unreadCount: Int {
|
|
indexes.unreadCount
|
|
}
|
|
|
|
func unreadCount(forTabId tabId: UUID) -> Int {
|
|
indexes.unreadCountByTabId[tabId] ?? 0
|
|
}
|
|
|
|
func hasUnreadNotification(forTabId tabId: UUID, surfaceId: UUID?) -> Bool {
|
|
indexes.unreadByTabSurface.contains(TabSurfaceKey(tabId: tabId, surfaceId: surfaceId))
|
|
}
|
|
|
|
func latestNotification(forTabId tabId: UUID) -> TerminalNotification? {
|
|
indexes.latestUnreadByTabId[tabId] ?? indexes.latestByTabId[tabId]
|
|
}
|
|
|
|
func addNotification(tabId: UUID, surfaceId: UUID?, title: String, subtitle: String, body: String) {
|
|
var updated = notifications
|
|
var idsToClear: [String] = []
|
|
updated.removeAll { existing in
|
|
guard existing.tabId == tabId, existing.surfaceId == surfaceId else { return false }
|
|
idsToClear.append(existing.id.uuidString)
|
|
return true
|
|
}
|
|
|
|
let isActiveTab = AppDelegate.shared?.tabManager?.selectedTabId == tabId
|
|
let focusedSurfaceId = AppDelegate.shared?.tabManager?.focusedSurfaceId(for: tabId)
|
|
let isFocusedSurface = surfaceId == nil || focusedSurfaceId == surfaceId
|
|
let isFocusedPanel = isActiveTab && isFocusedSurface
|
|
let isAppFocused = AppFocusState.isAppFocused()
|
|
if isAppFocused && isFocusedPanel {
|
|
if !idsToClear.isEmpty {
|
|
notifications = updated
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
|
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
|
}
|
|
return
|
|
}
|
|
|
|
if WorkspaceAutoReorderSettings.isEnabled() {
|
|
AppDelegate.shared?.tabManager?.moveTabToTop(tabId)
|
|
}
|
|
|
|
let notification = TerminalNotification(
|
|
id: UUID(),
|
|
tabId: tabId,
|
|
surfaceId: surfaceId,
|
|
title: title,
|
|
subtitle: subtitle,
|
|
body: body,
|
|
createdAt: Date(),
|
|
isRead: false
|
|
)
|
|
updated.insert(notification, at: 0)
|
|
notifications = updated
|
|
if !idsToClear.isEmpty {
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
|
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
|
}
|
|
scheduleUserNotification(notification)
|
|
}
|
|
|
|
func markRead(id: UUID) {
|
|
var updated = notifications
|
|
guard let index = updated.firstIndex(where: { $0.id == id }) else { return }
|
|
guard !updated[index].isRead else { return }
|
|
updated[index].isRead = true
|
|
notifications = updated
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: [id.uuidString])
|
|
}
|
|
|
|
func markRead(forTabId tabId: UUID) {
|
|
var updated = notifications
|
|
var idsToClear: [String] = []
|
|
for index in updated.indices {
|
|
if updated[index].tabId == tabId && !updated[index].isRead {
|
|
updated[index].isRead = true
|
|
idsToClear.append(updated[index].id.uuidString)
|
|
}
|
|
}
|
|
if !idsToClear.isEmpty {
|
|
notifications = updated
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
|
}
|
|
}
|
|
|
|
func markRead(forTabId tabId: UUID, surfaceId: UUID?) {
|
|
var updated = notifications
|
|
var idsToClear: [String] = []
|
|
for index in updated.indices {
|
|
if updated[index].tabId == tabId,
|
|
updated[index].surfaceId == surfaceId,
|
|
!updated[index].isRead {
|
|
updated[index].isRead = true
|
|
idsToClear.append(updated[index].id.uuidString)
|
|
}
|
|
}
|
|
if !idsToClear.isEmpty {
|
|
notifications = updated
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
|
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
|
}
|
|
}
|
|
|
|
func markUnread(forTabId tabId: UUID) {
|
|
var updated = notifications
|
|
var didChange = false
|
|
for index in updated.indices {
|
|
if updated[index].tabId == tabId, updated[index].isRead {
|
|
updated[index].isRead = false
|
|
didChange = true
|
|
}
|
|
}
|
|
if didChange {
|
|
notifications = updated
|
|
}
|
|
}
|
|
|
|
func markAllRead() {
|
|
var updated = notifications
|
|
var idsToClear: [String] = []
|
|
for index in updated.indices {
|
|
if !updated[index].isRead {
|
|
updated[index].isRead = true
|
|
idsToClear.append(updated[index].id.uuidString)
|
|
}
|
|
}
|
|
if !idsToClear.isEmpty {
|
|
notifications = updated
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
|
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
|
}
|
|
}
|
|
|
|
func remove(id: UUID) {
|
|
var updated = notifications
|
|
let originalCount = updated.count
|
|
updated.removeAll { $0.id == id }
|
|
guard updated.count != originalCount else { return }
|
|
notifications = updated
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: [id.uuidString])
|
|
}
|
|
|
|
func clearAll() {
|
|
guard !notifications.isEmpty else { return }
|
|
let ids = notifications.map { $0.id.uuidString }
|
|
notifications.removeAll()
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: ids)
|
|
center.removePendingNotificationRequestsOffMain(withIdentifiers: ids)
|
|
}
|
|
|
|
func clearNotifications(forTabId tabId: UUID, surfaceId: UUID?) {
|
|
var updated: [TerminalNotification] = []
|
|
updated.reserveCapacity(notifications.count)
|
|
var idsToClear: [String] = []
|
|
for notification in notifications {
|
|
if notification.tabId == tabId, notification.surfaceId == surfaceId {
|
|
idsToClear.append(notification.id.uuidString)
|
|
} else {
|
|
updated.append(notification)
|
|
}
|
|
}
|
|
guard !idsToClear.isEmpty else { return }
|
|
notifications = updated
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
|
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
|
}
|
|
|
|
func clearNotifications(forTabId tabId: UUID) {
|
|
var updated: [TerminalNotification] = []
|
|
updated.reserveCapacity(notifications.count)
|
|
var idsToClear: [String] = []
|
|
for notification in notifications {
|
|
if notification.tabId == tabId {
|
|
idsToClear.append(notification.id.uuidString)
|
|
} else {
|
|
updated.append(notification)
|
|
}
|
|
}
|
|
guard !idsToClear.isEmpty else { return }
|
|
notifications = updated
|
|
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
|
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
|
}
|
|
|
|
private func scheduleUserNotification(_ notification: TerminalNotification) {
|
|
ensureAuthorization { [weak self] authorized in
|
|
guard let self, authorized else { return }
|
|
|
|
let content = UNMutableNotificationContent()
|
|
let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
|
|
?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
|
|
?? "cmux"
|
|
content.title = notification.title.isEmpty ? appName : notification.title
|
|
content.subtitle = notification.subtitle
|
|
content.body = notification.body
|
|
content.sound = NotificationSoundSettings.sound()
|
|
content.categoryIdentifier = Self.categoryIdentifier
|
|
content.userInfo = [
|
|
"tabId": notification.tabId.uuidString,
|
|
"notificationId": notification.id.uuidString,
|
|
]
|
|
if let surfaceId = notification.surfaceId {
|
|
content.userInfo["surfaceId"] = surfaceId.uuidString
|
|
}
|
|
|
|
let request = UNNotificationRequest(
|
|
identifier: notification.id.uuidString,
|
|
content: content,
|
|
trigger: nil
|
|
)
|
|
|
|
self.center.add(request) { error in
|
|
if let error {
|
|
NSLog("Failed to schedule notification: \(error)")
|
|
} else {
|
|
NotificationSoundSettings.runCustomCommand(
|
|
title: content.title,
|
|
subtitle: content.subtitle,
|
|
body: content.body
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func ensureAuthorization(_ completion: @escaping (Bool) -> Void) {
|
|
center.getNotificationSettings { [weak self] settings in
|
|
guard let self else {
|
|
completion(false)
|
|
return
|
|
}
|
|
|
|
switch settings.authorizationStatus {
|
|
case .authorized, .provisional, .ephemeral:
|
|
completion(true)
|
|
case .denied:
|
|
self.promptToEnableNotifications()
|
|
completion(false)
|
|
case .notDetermined:
|
|
self.requestAuthorizationIfNeeded(completion)
|
|
@unknown default:
|
|
completion(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func requestAuthorizationIfNeeded(_ completion: @escaping (Bool) -> Void) {
|
|
guard !hasRequestedAuthorization else {
|
|
completion(false)
|
|
return
|
|
}
|
|
hasRequestedAuthorization = true
|
|
center.requestAuthorization(options: [.alert, .sound]) { granted, _ in
|
|
completion(granted)
|
|
}
|
|
}
|
|
|
|
private func promptToEnableNotifications() {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self, !self.hasPromptedForSettings else { return }
|
|
self.hasPromptedForSettings = true
|
|
self.presentNotificationSettingsPrompt(attempt: 0)
|
|
}
|
|
}
|
|
|
|
private func presentNotificationSettingsPrompt(attempt: Int) {
|
|
guard let window = notificationSettingsWindowProvider() else {
|
|
guard attempt < settingsPromptWindowRetryLimit else {
|
|
// If no window is available after retries, allow a future denied callback
|
|
// to prompt again when the app has a key/main window.
|
|
hasPromptedForSettings = false
|
|
return
|
|
}
|
|
notificationSettingsScheduler(settingsPromptWindowRetryDelay) { [weak self] in
|
|
self?.presentNotificationSettingsPrompt(attempt: attempt + 1)
|
|
}
|
|
return
|
|
}
|
|
|
|
let alert = notificationSettingsAlertFactory()
|
|
alert.messageText = String(localized: "dialog.enableNotifications.title", defaultValue: "Enable Notifications for cmux")
|
|
alert.informativeText = String(localized: "dialog.enableNotifications.message", defaultValue: "Notifications are disabled for cmux. Enable them in System Settings to see alerts.")
|
|
alert.addButton(withTitle: String(localized: "dialog.enableNotifications.openSettings", defaultValue: "Open Settings"))
|
|
alert.addButton(withTitle: String(localized: "dialog.enableNotifications.notNow", defaultValue: "Not Now"))
|
|
alert.beginSheetModal(for: window) { [weak self] response in
|
|
guard response == .alertFirstButtonReturn,
|
|
let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") else {
|
|
return
|
|
}
|
|
self?.notificationSettingsURLOpener(url)
|
|
}
|
|
}
|
|
|
|
private static func buildIndexes(for notifications: [TerminalNotification]) -> NotificationIndexes {
|
|
var indexes = NotificationIndexes()
|
|
for notification in notifications {
|
|
if indexes.latestByTabId[notification.tabId] == nil {
|
|
indexes.latestByTabId[notification.tabId] = notification
|
|
}
|
|
guard !notification.isRead else { continue }
|
|
indexes.unreadCount += 1
|
|
indexes.unreadCountByTabId[notification.tabId, default: 0] += 1
|
|
indexes.unreadByTabSurface.insert(
|
|
TabSurfaceKey(tabId: notification.tabId, surfaceId: notification.surfaceId)
|
|
)
|
|
if indexes.latestUnreadByTabId[notification.tabId] == nil {
|
|
indexes.latestUnreadByTabId[notification.tabId] = notification
|
|
}
|
|
}
|
|
return indexes
|
|
}
|
|
|
|
#if DEBUG
|
|
func configureNotificationSettingsPromptHooksForTesting(
|
|
windowProvider: @escaping () -> NSWindow?,
|
|
alertFactory: @escaping () -> NSAlert,
|
|
scheduler: @escaping (_ delay: TimeInterval, _ block: @escaping () -> Void) -> Void,
|
|
urlOpener: @escaping (URL) -> Void
|
|
) {
|
|
notificationSettingsWindowProvider = windowProvider
|
|
notificationSettingsAlertFactory = alertFactory
|
|
notificationSettingsScheduler = scheduler
|
|
notificationSettingsURLOpener = urlOpener
|
|
hasPromptedForSettings = false
|
|
}
|
|
|
|
func resetNotificationSettingsPromptHooksForTesting() {
|
|
notificationSettingsWindowProvider = { NSApp.keyWindow ?? NSApp.mainWindow }
|
|
notificationSettingsAlertFactory = { NSAlert() }
|
|
notificationSettingsScheduler = { delay, block in
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
|
block()
|
|
}
|
|
}
|
|
notificationSettingsURLOpener = { url in
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
hasPromptedForSettings = false
|
|
}
|
|
|
|
func promptToEnableNotificationsForTesting() {
|
|
promptToEnableNotifications()
|
|
}
|
|
|
|
func replaceNotificationsForTesting(_ notifications: [TerminalNotification]) {
|
|
self.notifications = notifications
|
|
}
|
|
#endif
|
|
|
|
private func refreshDockBadge() {
|
|
let label = Self.dockBadgeLabel(
|
|
unreadCount: unreadCount,
|
|
isEnabled: NotificationBadgeSettings.isDockBadgeEnabled(),
|
|
runTag: TaggedRunBadgeSettings.normalizedTag()
|
|
)
|
|
NSApp?.dockTile.badgeLabel = label
|
|
}
|
|
}
|