cmux/Sources/Update/UpdateViewModel.swift
atani 2c330efb8a
feat: add Japanese localization with String Catalog (#819)
* 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
2026-03-04 14:58:28 -08:00

533 lines
21 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import AppKit
import SwiftUI
import Sparkle
class UpdateViewModel: ObservableObject {
@Published var state: UpdateState = .idle
@Published var overrideState: UpdateState?
#if DEBUG
@Published var debugOverrideText: String?
#endif
var effectiveState: UpdateState {
overrideState ?? state
}
var text: String {
#if DEBUG
if let debugOverrideText { return debugOverrideText }
#endif
switch effectiveState {
case .idle:
return ""
case .permissionRequest:
return String(localized: "update.permissionRequest.text", defaultValue: "Enable Automatic Updates?")
case .checking:
return String(localized: "update.checking", defaultValue: "Checking for Updates…")
case .updateAvailable(let update):
let version = update.appcastItem.displayVersionString
if !version.isEmpty {
return String(localized: "update.available.withVersion", defaultValue: "Update Available: \(version)")
}
return String(localized: "update.available.short", defaultValue: "Update Available")
case .downloading(let download):
if let expectedLength = download.expectedLength, expectedLength > 0 {
let progress = Double(download.progress) / Double(expectedLength)
let percent = String(format: "%.0f%%", progress * 100)
return String(localized: "update.downloading.progress", defaultValue: "Downloading: \(percent)")
}
return String(localized: "update.downloading.status", defaultValue: "Downloading…")
case .extracting(let extracting):
let percent = String(format: "%.0f%%", extracting.progress * 100)
return String(localized: "update.extracting.progress", defaultValue: "Preparing: \(percent)")
case .installing(let install):
return install.isAutoUpdate ? String(localized: "update.restartToComplete", defaultValue: "Restart to Complete Update") : String(localized: "update.installing.status", defaultValue: "Installing…")
case .notFound:
return String(localized: "update.noUpdates.title", defaultValue: "No Updates Available")
case .error(let err):
return Self.userFacingErrorTitle(for: err.error)
}
}
var maxWidthText: String {
switch effectiveState {
case .downloading:
return "Downloading: 100%"
case .extracting:
return "Preparing: 100%"
default:
return text
}
}
var iconName: String? {
switch effectiveState {
case .idle:
return nil
case .permissionRequest:
return "questionmark.circle"
case .checking:
return "arrow.triangle.2.circlepath"
case .updateAvailable:
return "shippingbox.fill"
case .downloading:
return "arrow.down.circle"
case .extracting:
return "shippingbox"
case .installing:
return "power.circle"
case .notFound:
return "info.circle"
case .error:
return "exclamationmark.triangle.fill"
}
}
var description: String {
switch effectiveState {
case .idle:
return ""
case .permissionRequest:
return String(localized: "update.configureAutoUpdates", defaultValue: "Configure automatic update preferences")
case .checking:
return String(localized: "update.pleaseWait", defaultValue: "Please wait while we check for available updates")
case .updateAvailable(let update):
return update.releaseNotes?.label ?? String(localized: "update.downloadAndInstall", defaultValue: "Download and install the latest version")
case .downloading:
return String(localized: "update.downloadingPackage", defaultValue: "Downloading the update package")
case .extracting:
return String(localized: "update.preparingUpdate", defaultValue: "Extracting and preparing the update")
case let .installing(install):
return install.isAutoUpdate ? String(localized: "update.restartToComplete", defaultValue: "Restart to Complete Update") : String(localized: "update.installingAndRestarting", defaultValue: "Installing update and preparing to restart")
case .notFound:
return String(localized: "update.noUpdates.message", defaultValue: "You are running the latest version")
case .error(let err):
return Self.userFacingErrorMessage(for: err.error)
}
}
var badge: String? {
switch effectiveState {
case .updateAvailable(let update):
let version = update.appcastItem.displayVersionString
return version.isEmpty ? nil : version
case .downloading(let download):
if let expectedLength = download.expectedLength, expectedLength > 0 {
let percentage = Double(download.progress) / Double(expectedLength) * 100
return String(format: "%.0f%%", percentage)
}
return nil
case .extracting(let extracting):
return String(format: "%.0f%%", extracting.progress * 100)
default:
return nil
}
}
var iconColor: Color {
switch effectiveState {
case .idle:
return .secondary
case .permissionRequest:
return .white
case .checking:
return .secondary
case .updateAvailable:
return cmuxAccentColor()
case .downloading, .extracting, .installing:
return .secondary
case .notFound:
return .secondary
case .error:
return .orange
}
}
var backgroundColor: Color {
switch effectiveState {
case .permissionRequest:
return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.3, of: .black) ?? .systemBlue)
case .updateAvailable:
return cmuxAccentColor()
case .notFound:
return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.5, of: .black) ?? .systemBlue)
case .error:
return .orange.opacity(0.2)
default:
return Color(nsColor: .controlBackgroundColor)
}
}
var foregroundColor: Color {
switch effectiveState {
case .permissionRequest:
return .white
case .updateAvailable:
return .white
case .notFound:
return .white
case .error:
return .orange
default:
return .primary
}
}
static func userFacingErrorTitle(for error: Swift.Error) -> String {
let nsError = error as NSError
if let networkError = networkError(from: nsError) {
switch networkError.code {
case NSURLErrorNotConnectedToInternet:
return String(localized: "update.error.noInternet.title", defaultValue: "No Internet Connection")
case NSURLErrorTimedOut:
return String(localized: "update.error.timedOut.title", defaultValue: "Update Timed Out")
case NSURLErrorCannotFindHost:
return String(localized: "update.error.serverNotFound.title", defaultValue: "Server Not Found")
case NSURLErrorCannotConnectToHost:
return String(localized: "update.error.serverUnreachable.title", defaultValue: "Server Unreachable")
case NSURLErrorNetworkConnectionLost:
return String(localized: "update.error.connectionLost.title", defaultValue: "Connection Lost")
case NSURLErrorSecureConnectionFailed,
NSURLErrorServerCertificateUntrusted,
NSURLErrorServerCertificateHasBadDate,
NSURLErrorServerCertificateHasUnknownRoot,
NSURLErrorServerCertificateNotYetValid:
return String(localized: "update.error.secureConnectionFailed.title", defaultValue: "Secure Connection Failed")
default:
break
}
}
if nsError.domain == SUSparkleErrorDomain {
switch nsError.code {
case 4005:
return String(localized: "update.error.permissionError.title", defaultValue: "Updater Permission Error")
case 2001:
return String(localized: "update.error.downloadFailed.title", defaultValue: "Couldn't Download Update")
case 1000, 1002:
return String(localized: "update.error.feedError.title", defaultValue: "Update Feed Error")
case 4:
return String(localized: "update.error.invalidFeed.title", defaultValue: "Invalid Update Feed")
case 3:
return String(localized: "update.error.insecureFeed.title", defaultValue: "Insecure Update Feed")
case 1, 2, 3001, 3002:
return String(localized: "update.error.signatureError.title", defaultValue: "Update Signature Error")
case 1003, 1005:
return String(localized: "update.error.appLocation.title", defaultValue: "App Location Issue")
default:
break
}
}
return String(localized: "update.error.failed.title", defaultValue: "Update Failed")
}
static func userFacingErrorMessage(for error: Swift.Error) -> String {
let nsError = error as NSError
if let networkError = networkError(from: nsError) {
switch networkError.code {
case NSURLErrorNotConnectedToInternet:
return String(localized: "update.error.noInternet.message", defaultValue: "cmux cant reach the update server. Check your internet connection and try again.")
case NSURLErrorTimedOut:
return String(localized: "update.error.timedOut.message", defaultValue: "The update server took too long to respond. Try again in a moment.")
case NSURLErrorCannotFindHost:
return String(localized: "update.error.serverNotFound.message", defaultValue: "The update server cant be found. Check your connection or try again later.")
case NSURLErrorCannotConnectToHost:
return String(localized: "update.error.serverUnreachable.message", defaultValue: "cmux couldnt connect to the update server. Check your connection or try again later.")
case NSURLErrorNetworkConnectionLost:
return String(localized: "update.error.connectionLost.message", defaultValue: "The network connection was lost while checking for updates. Try again.")
case NSURLErrorSecureConnectionFailed,
NSURLErrorServerCertificateUntrusted,
NSURLErrorServerCertificateHasBadDate,
NSURLErrorServerCertificateHasUnknownRoot,
NSURLErrorServerCertificateNotYetValid:
return String(localized: "update.error.secureConnectionFailed.message", defaultValue: "A secure connection to the update server couldnt be established. Try again later.")
default:
break
}
}
if nsError.domain == SUSparkleErrorDomain {
switch nsError.code {
case 2001:
return String(localized: "update.error.feedDownload.message", defaultValue: "cmux couldn't download the update feed. Check your connection and try again.")
case 1000, 1002:
return String(localized: "update.error.feedRead.message", defaultValue: "The update feed could not be read. Please try again later.")
case 4:
return String(localized: "update.error.invalidFeed.message", defaultValue: "The update feed URL is invalid. Please contact support.")
case 3:
return String(localized: "update.error.insecureFeed.message", defaultValue: "The update feed is insecure. Please contact support.")
case 1, 2, 3001, 3002:
return String(localized: "update.error.signatureError.message", defaultValue: "The update's signature could not be verified. Please try again later.")
case 1003, 1005, 4005:
return String(localized: "update.error.permissionError.message", defaultValue: "Move cmux into Applications and relaunch to enable updates.")
default:
break
}
}
return nsError.localizedDescription
}
static func errorDetails(for error: Swift.Error, technicalDetails: String?, feedURLString: String?) -> String {
let nsError = error as NSError
var lines: [String] = []
lines.append("Message: \(nsError.localizedDescription)")
lines.append("Domain: \(nsError.domain)")
if nsError.domain == SUSparkleErrorDomain,
let sparkleName = sparkleErrorCodeName(for: nsError.code) {
lines.append("Code: \(sparkleName) (\(nsError.code))")
} else {
lines.append("Code: \(nsError.code)")
}
if let url = nsError.userInfo[NSURLErrorFailingURLErrorKey] as? URL {
lines.append("URL: \(url.absoluteString)")
} else if let urlString = nsError.userInfo[NSURLErrorFailingURLStringErrorKey] as? String {
lines.append("URL: \(urlString)")
}
if let failure = nsError.userInfo[NSLocalizedFailureReasonErrorKey] as? String,
!failure.isEmpty {
lines.append("Failure: \(failure)")
}
if let recovery = nsError.userInfo[NSLocalizedRecoverySuggestionErrorKey] as? String,
!recovery.isEmpty {
lines.append("Recovery: \(recovery)")
}
if let feedURLString, !feedURLString.isEmpty {
lines.append("Feed: \(feedURLString)")
}
if let technicalDetails, !technicalDetails.isEmpty {
lines.append("Debug: \(technicalDetails)")
}
lines.append("Log: \(UpdateLogStore.shared.logPath())")
return lines.joined(separator: "\n")
}
private static func networkError(from error: NSError) -> NSError? {
if error.domain == NSURLErrorDomain {
return error
}
if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError,
underlying.domain == NSURLErrorDomain {
return underlying
}
return nil
}
private static func sparkleErrorCodeName(for code: Int) -> String? {
switch code {
case 1: return "SUNoPublicDSAFoundError"
case 2: return "SUInsufficientSigningError"
case 3: return "SUInsecureFeedURLError"
case 4: return "SUInvalidFeedURLError"
case 1000: return "SUAppcastParseError"
case 1001: return "SUNoUpdateError"
case 1002: return "SUAppcastError"
case 1003: return "SURunningFromDiskImageError"
case 1005: return "SURunningTranslocated"
case 2001: return "SUDownloadError"
case 3001: return "SUSignatureError"
case 3002: return "SUValidationError"
default:
return nil
}
}
}
enum UpdateState: Equatable {
case idle
case permissionRequest(PermissionRequest)
case checking(Checking)
case updateAvailable(UpdateAvailable)
case notFound(NotFound)
case error(Error)
case downloading(Downloading)
case extracting(Extracting)
case installing(Installing)
var isIdle: Bool {
if case .idle = self { return true }
return false
}
var isInstallable: Bool {
switch self {
case .checking,
.updateAvailable,
.downloading,
.extracting,
.installing:
return true
default:
return false
}
}
func cancel() {
switch self {
case .checking(let checking):
checking.cancel()
case .updateAvailable(let available):
available.reply(.dismiss)
case .downloading(let downloading):
downloading.cancel()
case .notFound(let notFound):
notFound.acknowledgement()
case .error(let err):
err.dismiss()
default:
break
}
}
func confirm() {
switch self {
case .updateAvailable(let available):
available.reply(.install)
default:
break
}
}
static func == (lhs: UpdateState, rhs: UpdateState) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle):
return true
case (.permissionRequest, .permissionRequest):
return true
case (.checking, .checking):
return true
case (.updateAvailable(let lUpdate), .updateAvailable(let rUpdate)):
return lUpdate.appcastItem.displayVersionString == rUpdate.appcastItem.displayVersionString
case (.notFound, .notFound):
return true
case (.error(let lErr), .error(let rErr)):
return lErr.error.localizedDescription == rErr.error.localizedDescription
case (.downloading(let lDown), .downloading(let rDown)):
return lDown.progress == rDown.progress && lDown.expectedLength == rDown.expectedLength
case (.extracting(let lExt), .extracting(let rExt)):
return lExt.progress == rExt.progress
case (.installing(let lInstall), .installing(let rInstall)):
return lInstall.isAutoUpdate == rInstall.isAutoUpdate
default:
return false
}
}
struct NotFound {
let acknowledgement: () -> Void
}
struct PermissionRequest {
let request: SPUUpdatePermissionRequest
let reply: @Sendable (SUUpdatePermissionResponse) -> Void
}
struct Checking {
let cancel: () -> Void
}
struct UpdateAvailable {
let appcastItem: SUAppcastItem
let reply: @Sendable (SPUUserUpdateChoice) -> Void
var releaseNotes: ReleaseNotes? {
ReleaseNotes(displayVersionString: appcastItem.displayVersionString)
}
}
enum ReleaseNotes {
case commit(URL)
case tagged(URL)
init?(displayVersionString: String) {
let version = displayVersionString
if let semver = Self.extractSemanticVersion(from: version) {
let tag = semver.hasPrefix("v") ? semver : "v\(semver)"
if let url = URL(string: "https://github.com/manaflow-ai/cmux/releases/tag/\(tag)") {
self = .tagged(url)
return
}
}
guard let newHash = Self.extractGitHash(from: version) else {
return nil
}
if let url = URL(string: "https://github.com/manaflow-ai/cmux/commit/\(newHash)") {
self = .commit(url)
} else {
return nil
}
}
private static func extractSemanticVersion(from version: String) -> String? {
let pattern = #"v?\d+\.\d+\.\d+"#
if let range = version.range(of: pattern, options: .regularExpression) {
return String(version[range])
}
return nil
}
private static func extractGitHash(from version: String) -> String? {
let pattern = #"[0-9a-f]{7,40}"#
if let range = version.range(of: pattern, options: .regularExpression) {
return String(version[range])
}
return nil
}
var url: URL {
switch self {
case .commit(let url): return url
case .tagged(let url): return url
}
}
var label: String {
switch self {
case .commit: return String(localized: "update.viewGitHubCommit", defaultValue: "View GitHub Commit")
case .tagged: return String(localized: "update.viewReleaseNotes", defaultValue: "View Release Notes")
}
}
}
struct Error {
let error: any Swift.Error
let retry: () -> Void
let dismiss: () -> Void
let technicalDetails: String?
let feedURLString: String?
init(error: any Swift.Error,
retry: @escaping () -> Void,
dismiss: @escaping () -> Void,
technicalDetails: String? = nil,
feedURLString: String? = nil) {
self.error = error
self.retry = retry
self.dismiss = dismiss
self.technicalDetails = technicalDetails
self.feedURLString = feedURLString
}
}
struct Downloading {
let cancel: () -> Void
let expectedLength: UInt64?
let progress: UInt64
}
struct Extracting {
let progress: Double
}
struct Installing {
var isAutoUpdate = false
let retryTerminatingApplication: () -> Void
let dismiss: () -> Void
}
}