cmux/Sources/SocketControlSettings.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

519 lines
18 KiB
Swift

import Foundation
#if canImport(Security)
import Security
#endif
enum SocketControlMode: String, CaseIterable, Identifiable {
case off
case cmuxOnly
case automation
case password
/// Full open access (all local users/processes) with no ancestry or password gate.
case allowAll
var id: String { rawValue }
static var uiCases: [SocketControlMode] { [.off, .cmuxOnly, .automation, .password, .allowAll] }
var displayName: String {
switch self {
case .off:
return String(localized: "socketControl.off.name", defaultValue: "Off")
case .cmuxOnly:
return String(localized: "socketControl.cmuxOnly.name", defaultValue: "cmux processes only")
case .automation:
return String(localized: "socketControl.automation.name", defaultValue: "Automation mode")
case .password:
return String(localized: "socketControl.password.name", defaultValue: "Password mode")
case .allowAll:
return String(localized: "socketControl.allowAll.name", defaultValue: "Full open access")
}
}
var description: String {
switch self {
case .off:
return String(localized: "socketControl.off.description", defaultValue: "Disable the local control socket.")
case .cmuxOnly:
return String(localized: "socketControl.cmuxOnly.description", defaultValue: "Only processes started inside cmux terminals can send commands.")
case .automation:
return String(localized: "socketControl.automation.description", defaultValue: "Allow external local automation clients from this macOS user (no ancestry check).")
case .password:
return String(localized: "socketControl.password.description", defaultValue: "Require socket authentication with a password stored in a local file.")
case .allowAll:
return String(localized: "socketControl.allowAll.description", defaultValue: "Allow any local process and user to connect with no auth. Unsafe.")
}
}
var socketFilePermissions: UInt16 {
switch self {
case .allowAll:
return 0o666
case .off, .cmuxOnly, .automation, .password:
return 0o600
}
}
var requiresPasswordAuth: Bool {
self == .password
}
}
enum SocketControlPasswordStore {
static let directoryName = "cmux"
static let fileName = "socket-control-password"
private static let keychainMigrationDefaultsKey = "socketControlPasswordMigrationVersion"
private static let keychainMigrationVersion = 1
private static let legacyKeychainService = "com.cmuxterm.app.socket-control"
private static let legacyKeychainAccount = "local-socket-password"
private struct LazyKeychainFallbackCache {
var hasLoaded = false
var password: String?
}
private static let lazyKeychainFallbackLock = NSLock()
private static var lazyKeychainFallbackCache = LazyKeychainFallbackCache()
static func configuredPassword(
environment: [String: String] = ProcessInfo.processInfo.environment,
fileURL: URL? = nil,
allowLazyKeychainFallback: Bool = false,
loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() }
) -> String? {
if let envPassword = normalized(environment[SocketControlSettings.socketPasswordEnvKey]) {
return envPassword
}
let filePassword: String?
do {
filePassword = try loadPassword(fileURL: fileURL)
} catch {
filePassword = nil
}
if let filePassword {
return filePassword
}
guard allowLazyKeychainFallback else {
return nil
}
return cachedLazyKeychainFallbackPassword(loadKeychainPassword: loadKeychainPassword)
}
static func hasConfiguredPassword(
environment: [String: String] = ProcessInfo.processInfo.environment,
fileURL: URL? = nil,
allowLazyKeychainFallback: Bool = false,
loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() }
) -> Bool {
guard let configured = configuredPassword(
environment: environment,
fileURL: fileURL,
allowLazyKeychainFallback: allowLazyKeychainFallback,
loadKeychainPassword: loadKeychainPassword
) else { return false }
return !configured.isEmpty
}
static func verify(
password candidate: String,
environment: [String: String] = ProcessInfo.processInfo.environment,
fileURL: URL? = nil,
allowLazyKeychainFallback: Bool = false,
loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() }
) -> Bool {
guard let expected = configuredPassword(
environment: environment,
fileURL: fileURL,
allowLazyKeychainFallback: allowLazyKeychainFallback,
loadKeychainPassword: loadKeychainPassword
), !expected.isEmpty else {
return false
}
return expected == candidate
}
static func migrateLegacyKeychainPasswordIfNeeded(
defaults: UserDefaults = .standard,
fileURL: URL? = nil,
loadLegacyPassword: () -> String? = { loadLegacyPasswordFromKeychain() },
deleteLegacyPassword: () -> Bool = { deleteLegacyPasswordFromKeychain() }
) {
guard defaults.integer(forKey: keychainMigrationDefaultsKey) < keychainMigrationVersion else {
return
}
guard let legacyPassword = normalized(loadLegacyPassword()) else {
defaults.set(keychainMigrationVersion, forKey: keychainMigrationDefaultsKey)
return
}
do {
if try loadPassword(fileURL: fileURL) == nil {
try savePassword(legacyPassword, fileURL: fileURL)
}
guard deleteLegacyPassword() else {
return
}
defaults.set(keychainMigrationVersion, forKey: keychainMigrationDefaultsKey)
} catch {
// Leave migration unset so it retries on next launch.
}
}
static func loadPassword(fileURL: URL? = nil) throws -> String? {
guard let fileURL = fileURL ?? defaultPasswordFileURL() else {
return nil
}
guard FileManager.default.fileExists(atPath: fileURL.path) else {
return nil
}
let data = try Data(contentsOf: fileURL)
guard let password = String(data: data, encoding: .utf8) else {
return nil
}
return normalized(password)
}
static func savePassword(_ password: String, fileURL: URL? = nil) throws {
let normalized = password.trimmingCharacters(in: .newlines)
if normalized.isEmpty {
try clearPassword(fileURL: fileURL)
return
}
guard let fileURL = fileURL ?? defaultPasswordFileURL() else {
throw NSError(
domain: NSCocoaErrorDomain,
code: NSFileNoSuchFileError,
userInfo: [NSLocalizedDescriptionKey: String(localized: "socketControl.error.passwordFilePath", defaultValue: "Unable to resolve socket password file path.")]
)
}
let directory = fileURL.deletingLastPathComponent()
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
let data = Data(normalized.utf8)
try data.write(to: fileURL, options: .atomic)
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path)
}
static func clearPassword(fileURL: URL? = nil) throws {
guard let fileURL = fileURL ?? defaultPasswordFileURL() else {
return
}
guard FileManager.default.fileExists(atPath: fileURL.path) else {
return
}
try FileManager.default.removeItem(at: fileURL)
}
static func resetLazyKeychainFallbackCacheForTests() {
lazyKeychainFallbackLock.lock()
lazyKeychainFallbackCache = LazyKeychainFallbackCache()
lazyKeychainFallbackLock.unlock()
}
static func defaultPasswordFileURL(
appSupportDirectory: URL? = nil,
fileManager: FileManager = .default
) -> URL? {
let resolvedAppSupport: URL
if let appSupportDirectory {
resolvedAppSupport = appSupportDirectory
} else if let discovered = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
resolvedAppSupport = discovered
} else {
return nil
}
return resolvedAppSupport
.appendingPathComponent(directoryName, isDirectory: true)
.appendingPathComponent(fileName, isDirectory: false)
}
private static func loadLegacyPasswordFromKeychain() -> String? {
#if canImport(Security)
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: legacyKeychainService,
kSecAttrAccount: legacyKeychainAccount,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
#else
return nil
#endif
}
private static func deleteLegacyPasswordFromKeychain() -> Bool {
#if canImport(Security)
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: legacyKeychainService,
kSecAttrAccount: legacyKeychainAccount,
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
#else
return false
#endif
}
private static func cachedLazyKeychainFallbackPassword(
loadKeychainPassword: () -> String?
) -> String? {
lazyKeychainFallbackLock.lock()
defer { lazyKeychainFallbackLock.unlock() }
if lazyKeychainFallbackCache.hasLoaded {
return lazyKeychainFallbackCache.password
}
lazyKeychainFallbackCache.hasLoaded = true
lazyKeychainFallbackCache.password = normalized(loadKeychainPassword())
return lazyKeychainFallbackCache.password
}
private static func normalized(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .newlines)
return trimmed.isEmpty ? nil : trimmed
}
}
struct SocketControlSettings {
static let appStorageKey = "socketControlMode"
static let legacyEnabledKey = "socketControlEnabled"
static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE"
static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD"
static let launchTagEnvKey = "CMUX_TAG"
static let baseDebugBundleIdentifier = "com.cmuxterm.app.debug"
private static func normalizeMode(_ raw: String) -> String {
raw
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.replacingOccurrences(of: "_", with: "")
.replacingOccurrences(of: "-", with: "")
}
private static func parseMode(_ raw: String) -> SocketControlMode? {
switch normalizeMode(raw) {
case "off":
return .off
case "cmuxonly":
return .cmuxOnly
case "automation":
return .automation
case "password":
return .password
case "allowall", "openaccess", "fullopenaccess":
return .allowAll
// Legacy values from the old socket mode model.
case "notifications":
return .automation
case "full":
return .allowAll
default:
return nil
}
}
/// Map persisted values to the current enum values.
static func migrateMode(_ raw: String) -> SocketControlMode {
parseMode(raw) ?? defaultMode
}
static var defaultMode: SocketControlMode {
return .cmuxOnly
}
private static var isDebugBuild: Bool {
#if DEBUG
true
#else
false
#endif
}
static func launchTag(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> String? {
guard let raw = environment[launchTagEnvKey] else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
static func shouldBlockUntaggedDebugLaunch(
environment: [String: String] = ProcessInfo.processInfo.environment,
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
isDebugBuild: Bool = SocketControlSettings.isDebugBuild
) -> Bool {
guard isDebugBuild else { return false }
if isRunningUnderXCTest(environment: environment) {
return false
}
// XCUITest launches the app as a separate process without XCTest env vars,
// so isRunningUnderXCTest() misses it. Check for any CMUX_UI_TEST_ env var.
if environment.keys.contains(where: { $0.hasPrefix("CMUX_UI_TEST_") }) {
return false
}
guard let bundleIdentifier = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
!bundleIdentifier.isEmpty else {
return false
}
if bundleIdentifier.hasPrefix("\(baseDebugBundleIdentifier).") {
return false
}
guard bundleIdentifier == baseDebugBundleIdentifier else {
return false
}
return launchTag(environment: environment) == nil
}
static func isRunningUnderXCTest(environment: [String: String]) -> Bool {
let indicators = [
"XCTestConfigurationFilePath",
"XCTestBundlePath",
"XCTestSessionIdentifier",
"XCInjectBundle",
"XCInjectBundleInto",
]
if indicators.contains(where: { key in
guard let value = environment[key] else { return false }
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}) {
return true
}
if environment["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true {
return true
}
return false
}
static func socketPath(
environment: [String: String] = ProcessInfo.processInfo.environment,
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
isDebugBuild: Bool = SocketControlSettings.isDebugBuild
) -> String {
let fallback = defaultSocketPath(bundleIdentifier: bundleIdentifier, isDebugBuild: isDebugBuild)
guard let override = environment["CMUX_SOCKET_PATH"], !override.isEmpty else {
return fallback
}
if shouldHonorSocketPathOverride(
environment: environment,
bundleIdentifier: bundleIdentifier,
isDebugBuild: isDebugBuild
) {
return override
}
return fallback
}
static func defaultSocketPath(bundleIdentifier: String?, isDebugBuild: Bool) -> String {
if bundleIdentifier == "com.cmuxterm.app.nightly" {
return "/tmp/cmux-nightly.sock"
}
if isDebugLikeBundleIdentifier(bundleIdentifier) || isDebugBuild {
return "/tmp/cmux-debug.sock"
}
if isStagingBundleIdentifier(bundleIdentifier) {
return "/tmp/cmux-staging.sock"
}
return "/tmp/cmux.sock"
}
static func shouldHonorSocketPathOverride(
environment: [String: String],
bundleIdentifier: String?,
isDebugBuild: Bool
) -> Bool {
if isTruthy(environment[allowSocketPathOverrideKey]) {
return true
}
if isDebugLikeBundleIdentifier(bundleIdentifier) || isStagingBundleIdentifier(bundleIdentifier) {
return true
}
return isDebugBuild
}
static func isDebugLikeBundleIdentifier(_ bundleIdentifier: String?) -> Bool {
guard let bundleIdentifier else { return false }
return bundleIdentifier == "com.cmuxterm.app.debug"
|| bundleIdentifier.hasPrefix("com.cmuxterm.app.debug.")
}
static func isStagingBundleIdentifier(_ bundleIdentifier: String?) -> Bool {
guard let bundleIdentifier else { return false }
return bundleIdentifier == "com.cmuxterm.app.staging"
|| bundleIdentifier.hasPrefix("com.cmuxterm.app.staging.")
}
static func isTruthy(_ raw: String?) -> Bool {
guard let raw else { return false }
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
static func envOverrideEnabled(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> Bool? {
guard let raw = environment["CMUX_SOCKET_ENABLE"], !raw.isEmpty else {
return nil
}
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return nil
}
}
static func envOverrideMode(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> SocketControlMode? {
guard let raw = environment["CMUX_SOCKET_MODE"], !raw.isEmpty else {
return nil
}
return parseMode(raw)
}
static func effectiveMode(
userMode: SocketControlMode,
environment: [String: String] = ProcessInfo.processInfo.environment
) -> SocketControlMode {
if let overrideEnabled = envOverrideEnabled(environment: environment) {
if !overrideEnabled {
return .off
}
if let overrideMode = envOverrideMode(environment: environment) {
return overrideMode
}
return userMode == .off ? .cmuxOnly : userMode
}
if let overrideMode = envOverrideMode(environment: environment) {
return overrideMode
}
return userMode
}
}