Skip to content
Open
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
2 changes: 1 addition & 1 deletion .envrc.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export XCODE_VERSION="16.0"
export MACOS_DEPLOYMENT_TARGET="13.0"

# Version (managed by Version.xcconfig, but can be overridden here)
export VERSION="1.1.6"
export VERSION="1.1.7"
export BUILD_NUMBER="1"

# Optional: Set specific Xcode version
Expand Down
24 changes: 24 additions & 0 deletions ModSwitchIME.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
001A7B322C2F3A1A00E5B4C8 /* ImeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B312C2F3A1A00E5B4C8 /* ImeController.swift */; };
001A7B342C2F3A1A00E5B4C8 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B332C2F3A1A00E5B4C8 /* Preferences.swift */; };
001A7B362C2F3A1A00E5B4C8 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B352C2F3A1A00E5B4C8 /* PreferencesView.swift */; };
001A7B442C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B452C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift */; };
001A7B462C2F3A1A00E5B4C8 /* ImeController+StateMonitoring.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B472C2F3A1A00E5B4C8 /* ImeController+StateMonitoring.swift */; };
A1D1A0012F00000100000001 /* ImeController+Diagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D1A0002F00000100000001 /* ImeController+Diagnostics.swift */; };
A1D1A0032F00000100000001 /* DiagnosticLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D1A0022F00000100000001 /* DiagnosticLogger.swift */; };
A1D1A0052F00000100000001 /* ImeController+SwitchVerification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D1A0042F00000100000001 /* ImeController+SwitchVerification.swift */; };
A1D1A0072F00000100000001 /* ImeController+SwitchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D1A0062F00000100000001 /* ImeController+SwitchState.swift */; };
001A7B382C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B372C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift */; };
001A7B3A2C2F3A1A00E5B4C8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 001A7B392C2F3A1A00E5B4C8 /* Assets.xcassets */; };
001A7B3D2C2F3A1A00E5B4C8 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 001A7B3C2C2F3A1A00E5B4C8 /* Preview Assets.xcassets */; };
Expand Down Expand Up @@ -39,6 +45,12 @@
001A7B312C2F3A1A00E5B4C8 /* ImeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImeController.swift; sourceTree = "<group>"; };
001A7B332C2F3A1A00E5B4C8 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
001A7B352C2F3A1A00E5B4C8 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = "<group>"; };
001A7B452C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputSourcePickerViews.swift; sourceTree = "<group>"; };
001A7B472C2F3A1A00E5B4C8 /* ImeController+StateMonitoring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImeController+StateMonitoring.swift"; sourceTree = "<group>"; };
A1D1A0002F00000100000001 /* ImeController+Diagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImeController+Diagnostics.swift"; sourceTree = "<group>"; };
A1D1A0022F00000100000001 /* DiagnosticLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticLogger.swift; sourceTree = "<group>"; };
A1D1A0042F00000100000001 /* ImeController+SwitchVerification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImeController+SwitchVerification.swift"; sourceTree = "<group>"; };
A1D1A0062F00000100000001 /* ImeController+SwitchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImeController+SwitchState.swift"; sourceTree = "<group>"; };
001A7B372C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModSwitchIMEError.swift; sourceTree = "<group>"; };
001A7B392C2F3A1A00E5B4C8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
001A7B3C2C2F3A1A00E5B4C8 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -100,11 +112,17 @@
001A7B2B2C2F3A1A00E5B4C8 /* App.swift */,
001A7B2F2C2F3A1A00E5B4C8 /* MenuBarApp.swift */,
001A7B312C2F3A1A00E5B4C8 /* ImeController.swift */,
001A7B472C2F3A1A00E5B4C8 /* ImeController+StateMonitoring.swift */,
A1D1A0002F00000100000001 /* ImeController+Diagnostics.swift */,
A1D1A0042F00000100000001 /* ImeController+SwitchVerification.swift */,
A1D1A0062F00000100000001 /* ImeController+SwitchState.swift */,
001A7B332C2F3A1A00E5B4C8 /* Preferences.swift */,
001A7B352C2F3A1A00E5B4C8 /* PreferencesView.swift */,
001A7B452C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift */,
001A7B372C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift */,
001A7B402C2F3A1A00E5B4C8 /* KeyMonitor.swift */,
001A7B422C2F3A1A00E5B4C8 /* Logger.swift */,
A1D1A0022F00000100000001 /* DiagnosticLogger.swift */,
3E795C604248469D9AD8FA2E /* InputSourceManager.swift */,
4A8B9C7C2E3F5A2100D6E8F9 /* ErrorHandling.swift */,
5B9C8D3E2F4A6B2300E7F8A9 /* AccessibilityManager.swift */,
Expand Down Expand Up @@ -232,11 +250,17 @@
001A7B2C2C2F3A1A00E5B4C8 /* App.swift in Sources */,
001A7B302C2F3A1A00E5B4C8 /* MenuBarApp.swift in Sources */,
001A7B322C2F3A1A00E5B4C8 /* ImeController.swift in Sources */,
001A7B462C2F3A1A00E5B4C8 /* ImeController+StateMonitoring.swift in Sources */,
A1D1A0012F00000100000001 /* ImeController+Diagnostics.swift in Sources */,
A1D1A0052F00000100000001 /* ImeController+SwitchVerification.swift in Sources */,
A1D1A0072F00000100000001 /* ImeController+SwitchState.swift in Sources */,
001A7B342C2F3A1A00E5B4C8 /* Preferences.swift in Sources */,
001A7B362C2F3A1A00E5B4C8 /* PreferencesView.swift in Sources */,
001A7B442C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift in Sources */,
001A7B382C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift in Sources */,
001A7B412C2F3A1A00E5B4C8 /* KeyMonitor.swift in Sources */,
001A7B432C2F3A1A00E5B4C8 /* Logger.swift in Sources */,
A1D1A0032F00000100000001 /* DiagnosticLogger.swift in Sources */,
3B7B971675E44DF784A30E7D /* InputSourceManager.swift in Sources */,
4A8B9C7D2E3F5A2100D6E8F9 /* ErrorHandling.swift in Sources */,
5B9C8D3F2F4A6B2300E7F8AA /* AccessibilityManager.swift in Sources */,
Expand Down
2 changes: 1 addition & 1 deletion ModSwitchIME/Config/Version.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Centralized version configuration for ModSwitchIME

// Marketing version shown to users (e.g., 1.0.0, 1.2.3)
MARKETING_VERSION = 1.1.6
MARKETING_VERSION = 1.1.7

// Build number (incremented for each build)
// Can be set to $(CURRENT_PROJECT_VERSION) for automatic increment
Expand Down
219 changes: 219 additions & 0 deletions ModSwitchIME/DiagnosticLogger.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import Cocoa
import Foundation

final class DiagnosticLogger {
static let shared = DiagnosticLogger()

enum Event: String {
case loggingEnabled
case loggingDisabled
case switchRequested
case switchSkipped
case inputSourceResolved
case inputSourceResolutionFailed
case inputSourceStateSnapshot
case selectAttempt
case switchStateCheck
case switchConfirmed
case switchPending
case unexpectedIME
case additionalVerificationMismatch
case retryScheduled
case retrySelectAttempt
case retryLimitReached
case notificationPosted
case notificationSkipped
case uiRefreshRequested
case inputSourceNotification
case appActivated
case focusVerificationMatched
case focusVerificationMismatch
case iconUpdated
case reconciliationMismatch
case systemSleep
case systemWake
}

private let queue = DispatchQueue(label: "com.modswitchime.diagnostic-log")
private let maxFileSize = 1_000_000
private let maxFieldLength = 300
private let allowedFieldKeys: Set<String> = [
"actualIME",
"attempt",
"bundleID",
"category",
"currentIME",
"delay",
"displayedIME",
"enabled",
"enabledCount",
"expectedIME",
"inEnabledList",
"imeId",
"isRetry",
"notification",
"osVersion",
"parentEnabled",
"parentIME",
"parentInEnabledList",
"phase",
"previousIME",
"reason",
"refreshed",
"resolvedIME",
"resultCode",
"retryCount",
"selectCapable",
"source",
"targetIME"
]
private var enabled: Bool
private let dateFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()

#if DEBUG
private var debugLogFileURL: URL?
#endif

private init() {
enabled = UserDefaults.standard.bool(forKey: "diagnosticLoggingEnabled")
}

var isEnabled: Bool {
queue.sync { enabled }
}

var logFileURL: URL {
#if DEBUG
if let debugLogFileURL {
return debugLogFileURL
}
#endif

let libraryURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
return libraryURL
.appendingPathComponent("Logs", isDirectory: true)
.appendingPathComponent("ModSwitchIME", isDirectory: true)
.appendingPathComponent("diagnostic.log")
}

var rotatedLogFileURL: URL {
logFileURL.deletingLastPathComponent().appendingPathComponent("diagnostic.log.1")
}

func setEnabled(_ enabled: Bool) {
queue.async {
let changed = self.enabled != enabled
self.enabled = enabled

guard changed else { return }

if enabled {
self.write(.loggingEnabled, fields: [:], force: true)
} else {
self.write(.loggingDisabled, fields: [:], force: true)
}
}
}

func record(_ event: Event, fields: [String: String] = [:]) {
queue.async {
self.write(event, fields: fields, force: false)
}
}

func revealInFinder() {
let url = logFileURL
if FileManager.default.fileExists(atPath: url.path) {
NSWorkspace.shared.activateFileViewerSelecting([url])
} else {
NSWorkspace.shared.open(url.deletingLastPathComponent())
}
}

private func write(_ event: Event, fields: [String: String], force: Bool) {
guard force || enabled else { return }

var record = sanitizedFields(fields)
record["timestamp"] = dateFormatter.string(from: Date())
record["event"] = event.rawValue

do {
let data = try JSONSerialization.data(withJSONObject: record, options: [.sortedKeys])
var line = data
line.append(0x0A)

try ensureLogDirectory()
try rotateIfNeeded(addingBytes: line.count)
try append(line)
} catch {
Logger.warning("Failed to write diagnostic log: \(error.localizedDescription)", category: .main)
}
}

private func ensureLogDirectory() throws {
try FileManager.default.createDirectory(
at: logFileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
}

private func rotateIfNeeded(addingBytes bytes: Int) throws {
let fileManager = FileManager.default
guard fileManager.fileExists(atPath: logFileURL.path) else { return }

let attributes = try fileManager.attributesOfItem(atPath: logFileURL.path)
let currentSize = attributes[.size] as? NSNumber
guard (currentSize?.intValue ?? 0) + bytes > maxFileSize else { return }

if fileManager.fileExists(atPath: rotatedLogFileURL.path) {
try fileManager.removeItem(at: rotatedLogFileURL)
}
try fileManager.moveItem(at: logFileURL, to: rotatedLogFileURL)
}

private func append(_ data: Data) throws {
let fileManager = FileManager.default
if !fileManager.fileExists(atPath: logFileURL.path) {
_ = fileManager.createFile(atPath: logFileURL.path, contents: nil)
}

let handle = try FileHandle(forWritingTo: logFileURL)
defer {
try? handle.close()
}
try handle.seekToEnd()
try handle.write(contentsOf: data)
}

private func sanitizedFields(_ fields: [String: String]) -> [String: String] {
var sanitized: [String: String] = [:]
for (key, value) in fields where allowedFieldKeys.contains(key) {
sanitized[key] = String(value.prefix(maxFieldLength))
}
return sanitized
}

#if DEBUG
func debugSetLogFileURL(_ url: URL?) {
queue.sync {
debugLogFileURL = url
}
}

func debugReset(enabled: Bool = false) {
queue.sync {
self.enabled = enabled
self.debugLogFileURL = nil
}
}

func debugFlush() {
queue.sync {}
}
#endif
}
Loading