diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index ffdffb8eb..4a9f185a7 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -81,7 +81,7 @@ jobs: needs: changes if: needs.changes.outputs.full == 'true' runs-on: macos-14 - timeout-minutes: 20 + timeout-minutes: 30 steps: - name: Check out source @@ -104,12 +104,12 @@ jobs: - name: Run Swift tests id: swift-tests-primary continue-on-error: true - timeout-minutes: 8 + timeout-minutes: 12 run: ./scripts/test-macos.sh - name: Retry Swift tests after a failed attempt if: steps.swift-tests-primary.outcome == 'failure' - timeout-minutes: 8 + timeout-minutes: 12 run: ./scripts/test-macos.sh --skip-build rust-tests: diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index c1fb47ecd..e49be9c7f 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -178,6 +178,13 @@ "The interface language changes immediately. English is the default." = "界面语言会立即生效。默认语言为英文。"; "Files" = "文件"; "Save changed files automatically" = "自动保存已修改的文件"; +"Logs" = "日志"; +"Log directory" = "日志目录"; +"Default directory" = "默认目录"; +"Selected directory" = "当前选择的目录"; +"Choose Directory" = "选择目录"; +"Choose Log Directory" = "选择日志目录"; +"Restore Default" = "恢复默认"; "Save after" = "保存延迟"; "Hidden paths" = "隐藏路径"; "One entry per line. Directory names hide matching folders; file entries support * and ?." = "每行输入一项。目录名用于隐藏匹配的文件夹;文件条目支持 * 和 ?。"; diff --git a/Sources/Lithe/Core/Ports/LogDirectoryProviding.swift b/Sources/Lithe/Core/Ports/LogDirectoryProviding.swift new file mode 100644 index 000000000..836f960fd --- /dev/null +++ b/Sources/Lithe/Core/Ports/LogDirectoryProviding.swift @@ -0,0 +1,5 @@ +import Foundation + +protocol LogDirectoryProviding { + var defaultLogDirectory: URL { get } +} diff --git a/Sources/Lithe/LitheApp.swift b/Sources/Lithe/LitheApp.swift index 6be8d2660..852eaf0d8 100644 --- a/Sources/Lithe/LitheApp.swift +++ b/Sources/Lithe/LitheApp.swift @@ -133,11 +133,29 @@ struct LitheApp: App { @StateObject private var memoryUsageMonitor: MemoryUsageMonitor @StateObject private var frameRateMonitor = FrameRateMonitor() @StateObject private var updateChecker = UpdateChecker() + private let applicationLogWriter: MacApplicationLogWriter init() { - MacBundledFontRegistry.registerFonts() let store = MacUserDefaultsStore() - let settings = AppSettings(store: store) + let settings = AppSettings( + store: store, + logDirectoryProvider: MacServiceContainer.makeLogDirectoryProvider() + ) + let applicationLogWriter = MacServiceContainer.makeApplicationLogWriter() + if !Self.redirectApplicationLogs(applicationLogWriter, to: settings.logDirectory), + settings.customLogDirectory != nil { + settings.setCustomLogDirectory(nil) + _ = Self.redirectApplicationLogs(applicationLogWriter, to: settings.defaultLogDirectory) + } + settings.addLogDirectoryObserver { [weak settings] directory in + guard !Self.redirectApplicationLogs(applicationLogWriter, to: directory), + settings?.customLogDirectory != nil else { return } + settings?.setCustomLogDirectory(nil) + } + self.applicationLogWriter = applicationLogWriter + MacBundledFontRegistry.registerFonts { message in + Self.appendApplicationLog(applicationLogWriter, message: message) + } let processRegistry = ManagedProcessRegistry() let moduleStore = MacModuleConfigurationStore(store: store) let pluginRuntimeRecovery = MacPluginRuntimeRecoveryCoordinator() @@ -171,8 +189,7 @@ struct LitheApp: App { _memoryUsageMonitor = StateObject(wrappedValue: MemoryUsageMonitor( startedAt: litheProcessLaunchDate, baselineReporter: { marker in - guard let data = (marker + "\n").data(using: .utf8) else { return } - FileHandle.standardError.write(data) + Self.appendApplicationLog(applicationLogWriter, message: marker + "\n") }, logsPerformanceBaseline: ProcessInfo.processInfo.environment["LITHE_PERFORMANCE_BASELINE"] == "1", processRegistry: processRegistry, @@ -185,6 +202,36 @@ struct LitheApp: App { } } + private static func redirectApplicationLogs( + _ writer: MacApplicationLogWriter, + to directory: URL + ) -> Bool { + do { + try writer.redirect(to: directory) + return true + } catch { + let message = "Could not redirect Lithe logs to \(directory.path): \(error.localizedDescription)\n" + if let data = message.data(using: .utf8) { + FileHandle.standardError.write(data) + } + return false + } + } + + private static func appendApplicationLog( + _ writer: MacApplicationLogWriter, + message: String + ) { + do { + try writer.append(message) + } catch { + let fallback = "Could not write Lithe log: \(error.localizedDescription)\n" + if let data = fallback.data(using: .utf8) { + FileHandle.standardError.write(data) + } + } + } + private var model: AppModel { projectSessions.activeModel } var body: some Scene { diff --git a/Sources/Lithe/Models/Settings/AppSettings.swift b/Sources/Lithe/Models/Settings/AppSettings.swift index 5469979c0..be7be26a0 100644 --- a/Sources/Lithe/Models/Settings/AppSettings.swift +++ b/Sources/Lithe/Models/Settings/AppSettings.swift @@ -22,6 +22,7 @@ final class AppSettings: ObservableObject { static let javaLanguageServerJDKPath = "settings.javaLanguageServerJDKPath" static let commitMessageAI = "settings.commitMessageAI" static let keyboardShortcutOverrides = "settings.keyboardShortcutOverrides" + static let customLogDirectory = "settings.customLogDirectory" } private struct KeyboardShortcutOverridesPayload: Codable { @@ -32,6 +33,7 @@ final class AppSettings: ObservableObject { } private let defaults: any KeyValueStore + private let logDirectoryProvider: any LogDirectoryProviding @Published var colorTheme: AppColorTheme { didSet { @@ -77,11 +79,17 @@ final class AppSettings: ObservableObject { didSet { saveCommitMessageAI() } } @Published private(set) var keyboardShortcutOverrides: [String: [KeyboardShortcutBinding]] + @Published private(set) var customLogDirectory: URL? private var fileVisibilityRulesObservers: [UUID: () -> Void] = [:] + private var logDirectoryObservers: [UUID: (URL) -> Void] = [:] - init(store: any KeyValueStore) { + init( + store: any KeyValueStore, + logDirectoryProvider: any LogDirectoryProviding + ) { self.defaults = store + self.logDirectoryProvider = logDirectoryProvider colorTheme = AppColorTheme( rawValue: defaults.string(forKey: Key.colorTheme) ?? "" ) ?? .lithe @@ -110,6 +118,10 @@ final class AppSettings: ObservableObject { ) ?? .ask javaLanguageServerJDKPath = defaults.string(forKey: Key.javaLanguageServerJDKPath) ?? "" keyboardShortcutOverrides = Self.loadKeyboardShortcutOverrides(from: defaults) + customLogDirectory = defaults.string(forKey: Key.customLogDirectory).flatMap { path in + guard !path.isEmpty else { return nil } + return URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL + } if let data = defaults.data(forKey: Key.commitMessageAI), let saved = try? JSONDecoder().decode(CommitMessageAISettings.self, from: data) { commitMessageAI = saved @@ -121,6 +133,27 @@ final class AppSettings: ObservableObject { var terminalShellPath: String? { terminalShell.path } + var defaultLogDirectory: URL { + logDirectoryProvider.defaultLogDirectory + } + + var logDirectory: URL { customLogDirectory ?? defaultLogDirectory } + + func setCustomLogDirectory(_ url: URL?) { + customLogDirectory = url?.standardizedFileURL + defaults.set(customLogDirectory?.path, forKey: Key.customLogDirectory) + for observer in logDirectoryObservers.values { + observer(logDirectory) + } + } + + @discardableResult + func addLogDirectoryObserver(_ observer: @escaping (URL) -> Void) -> UUID { + let id = UUID() + logDirectoryObservers[id] = observer + return id + } + var fileVisibilityRules: FileVisibilityRules { FileVisibilityRules( hiddenDirectoryNames: hiddenDirectoryNames, @@ -162,6 +195,7 @@ final class AppSettings: ObservableObject { projectOpenBehavior = .ask javaLanguageServerJDKPath = "" commitMessageAI = .default + setCustomLogDirectory(nil) setKeyboardShortcutOverrides([:]) } diff --git a/Sources/Lithe/Platform/MacOS/Logging/MacApplicationLogWriter.swift b/Sources/Lithe/Platform/MacOS/Logging/MacApplicationLogWriter.swift new file mode 100644 index 000000000..c40670940 --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/Logging/MacApplicationLogWriter.swift @@ -0,0 +1,55 @@ +import Darwin +import Foundation + +final class MacApplicationLogWriter { + static let fileName = "lithe.log" + + private let lock = NSLock() + private var directory: URL? + + func redirect(to directory: URL) throws { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let logURL = directory.appendingPathComponent(Self.fileName, isDirectory: false) + let descriptor: Int32 = logURL.withUnsafeFileSystemRepresentation { path in + guard let path else { return Int32(-1) } + return open(path, O_WRONLY | O_CREAT | O_APPEND, S_IRUSR | S_IWUSR) + } + guard descriptor >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + close(descriptor) + + lock.lock() + self.directory = directory + lock.unlock() + } + + func append(_ message: String) throws { + lock.lock() + defer { lock.unlock() } + guard let directory else { + throw CocoaError(.fileNoSuchFile) + } + + let logURL = directory.appendingPathComponent(Self.fileName, isDirectory: false) + let descriptor: Int32 = logURL.withUnsafeFileSystemRepresentation { path in + guard let path else { return Int32(-1) } + return open(path, O_WRONLY | O_CREAT | O_APPEND, S_IRUSR | S_IWUSR) + } + guard descriptor >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + defer { close(descriptor) } + + let data = Data(message.utf8) + let written = data.withUnsafeBytes { bytes in + Darwin.write(descriptor, bytes.baseAddress, bytes.count) + } + guard written == data.count else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + } +} diff --git a/Sources/Lithe/Platform/MacOS/Logging/MacLogDirectoryProvider.swift b/Sources/Lithe/Platform/MacOS/Logging/MacLogDirectoryProvider.swift new file mode 100644 index 000000000..98161e48c --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/Logging/MacLogDirectoryProvider.swift @@ -0,0 +1,8 @@ +import Foundation + +struct MacLogDirectoryProvider: LogDirectoryProviding { + var defaultLogDirectory: URL { + FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Logs/Lithe", isDirectory: true) + } +} diff --git a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 0d88d68f1..f4682835c 100644 --- a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -38,6 +38,14 @@ final class MacServiceContainer { let runConfigurationStore: MacRunConfigurationStore let moduleLifecycleCoordinator: ModuleLifecycleCoordinator + static func makeLogDirectoryProvider() -> any LogDirectoryProviding { + MacLogDirectoryProvider() + } + + static func makeApplicationLogWriter() -> MacApplicationLogWriter { + MacApplicationLogWriter() + } + init( store: any KeyValueStore, settings: AppSettings, diff --git a/Sources/Lithe/Platform/MacOS/UI/MacBundledFontRegistry.swift b/Sources/Lithe/Platform/MacOS/UI/MacBundledFontRegistry.swift index 749114018..0f734e1fb 100644 --- a/Sources/Lithe/Platform/MacOS/UI/MacBundledFontRegistry.swift +++ b/Sources/Lithe/Platform/MacOS/UI/MacBundledFontRegistry.swift @@ -11,6 +11,13 @@ enum MacBundledFontRegistry { ] static func registerFonts(bundle: Bundle = .main) { + registerFonts(bundle: bundle, reporter: report) + } + + static func registerFonts( + bundle: Bundle = .main, + reporter: (String) -> Void + ) { guard bundle.url(forResource: "JetBrainsMono-Regular", withExtension: "ttf", subdirectory: "Fonts") != nil else { return } @@ -21,7 +28,7 @@ enum MacBundledFontRegistry { withExtension: "ttf", subdirectory: "Fonts" ) else { - report("Missing bundled font: \(font.resource).ttf") + reporter("Lithe font registration: Missing bundled font: \(font.resource).ttf\n") continue } @@ -29,14 +36,14 @@ enum MacBundledFontRegistry { guard CTFontManagerRegisterFontsForURL(url as CFURL, .process, ®istrationError) else { let detail = registrationError?.takeRetainedValue().localizedDescription ?? "Unknown CoreText error" - report("Could not register \(font.resource).ttf: \(detail)") + reporter("Lithe font registration: Could not register \(font.resource).ttf: \(detail)\n") continue } } } private static func report(_ message: String) { - guard let data = ("Lithe font registration: \(message)\n").data(using: .utf8) else { return } + guard let data = message.data(using: .utf8) else { return } FileHandle.standardError.write(data) } } diff --git a/Sources/Lithe/Views/App/SettingsView.swift b/Sources/Lithe/Views/App/SettingsView.swift index b3e572c76..111f2ee4e 100644 --- a/Sources/Lithe/Views/App/SettingsView.swift +++ b/Sources/Lithe/Views/App/SettingsView.swift @@ -1,4 +1,3 @@ -import AppKit import SwiftUI import LitheCoreContracts import LitheGitModule @@ -168,7 +167,7 @@ struct SettingsView: View { private func searchTerms(for category: SettingsCategory) -> [String] { switch category { case .general: - ["General", "Appearance", "Color theme", "Appearance mode", "Language", "Projects", "Files", "Version control"] + ["General", "Appearance", "Color theme", "Appearance mode", "Language", "Projects", "Files", "Version control", "Logs", "Log directory"] case .editor: ["Editor", "Display", "Editor tabs", "Font size", "Indentation", "Tab width"] case .keymap: @@ -368,6 +367,72 @@ struct SettingsView: View { .buttonStyle(LithePrimaryButtonStyle()) } } + + group("Logs") { + Text("Log directory") + .font(.system(size: 11.5, weight: .medium)) + + HStack(spacing: 10) { + Text(settings.logDirectory.path) + .font(.system(size: 13, weight: .medium)) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + .help(settings.logDirectory.path) + + Spacer(minLength: 8) + + Button { + guard let directory = model.platformUI.chooseDirectory( + title: "Choose Log Directory", + prompt: "Choose" + ) else { return } + settings.setCustomLogDirectory(directory) + } label: { + Image(systemName: "folder") + .font(.system(size: 16, weight: .regular)) + .frame(width: 26, height: 26) + } + .buttonStyle(.plain) + .foregroundStyle(LitheTheme.secondaryText) + .contentShape(Rectangle()) + .lithePointer() + .help("Choose Directory") + } + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, minHeight: 46, maxHeight: 46) + .background(LitheTheme.inputBackground) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(LitheTheme.inputBorder, lineWidth: 1) + } + + HStack(spacing: 6) { + Text("Default directory") + .foregroundStyle(LitheTheme.secondaryText) + Text(settings.defaultLogDirectory.path) + .foregroundStyle(LitheTheme.tertiaryText) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + .help(settings.defaultLogDirectory.path) + + Spacer(minLength: 8) + + if settings.customLogDirectory != nil { + Button { + settings.setCustomLogDirectory(nil) + } label: { + Text("Restore Default") + } + .buttonStyle(.plain) + .foregroundStyle(LitheTheme.accent) + .lithePointer() + } + } + .font(LitheTheme.smallFont) + } } } diff --git a/Tests/LitheTests/AppLocalizationTests.swift b/Tests/LitheTests/AppLocalizationTests.swift index 6bb5d4b2f..3ef5099e5 100644 --- a/Tests/LitheTests/AppLocalizationTests.swift +++ b/Tests/LitheTests/AppLocalizationTests.swift @@ -37,6 +37,19 @@ struct AppLocalizationTests { ) } + @Test + func simplifiedChineseResourcesCoverLogDirectorySettings() throws { + let translations = try simplifiedChineseTranslations() + + #expect(translations["Logs"] == "日志") + #expect(translations["Log directory"] == "日志目录") + #expect(translations["Default directory"] == "默认目录") + #expect(translations["Selected directory"] == "当前选择的目录") + #expect(translations["Choose Directory"] == "选择目录") + #expect(translations["Choose Log Directory"] == "选择日志目录") + #expect(translations["Restore Default"] == "恢复默认") + } + @Test func simplifiedChineseResourcesCoverGitHubPullRequests() throws { let translations = try simplifiedChineseTranslations() diff --git a/Tests/LitheTests/AppSettingsTests.swift b/Tests/LitheTests/AppSettingsTests.swift index 0977a1892..f7ae14f34 100644 --- a/Tests/LitheTests/AppSettingsTests.swift +++ b/Tests/LitheTests/AppSettingsTests.swift @@ -28,6 +28,43 @@ struct AppSettingsTests { #expect(settings.autoSave) #expect(AppSettings(store: store).autoSave) } + + @Test + func customLogDirectoryPersistsAndCanBeRestoredToDefault() { + let store = AppSettingsTestStore() + let settings = AppSettings(store: store) + let customDirectory = URL(fileURLWithPath: "/tmp/lithe-test-logs", isDirectory: true) + + #expect(settings.customLogDirectory == nil) + #expect(settings.logDirectory == settings.defaultLogDirectory) + + settings.setCustomLogDirectory(customDirectory) + + let restored = AppSettings(store: store) + #expect(restored.customLogDirectory == customDirectory.standardizedFileURL) + #expect(restored.logDirectory == customDirectory.standardizedFileURL) + + restored.restoreDefaults() + + #expect(restored.customLogDirectory == nil) + #expect(AppSettings(store: store).logDirectory == restored.defaultLogDirectory) + } + + @Test + func logDirectoryObserversReceiveCustomAndRestoredDirectories() { + let settings = AppSettings(store: AppSettingsTestStore()) + let customDirectory = URL(fileURLWithPath: "/tmp/lithe-observed-logs", isDirectory: true) + var observedDirectories: [URL] = [] + settings.addLogDirectoryObserver { observedDirectories.append($0) } + + settings.setCustomLogDirectory(customDirectory) + settings.setCustomLogDirectory(nil) + + #expect(observedDirectories == [ + customDirectory.standardizedFileURL, + settings.defaultLogDirectory + ]) + } } private final class AppSettingsTestStore: KeyValueStore, @unchecked Sendable { diff --git a/Tests/LitheTests/MacApplicationLogWriterTests.swift b/Tests/LitheTests/MacApplicationLogWriterTests.swift new file mode 100644 index 000000000..bf1eb68ba --- /dev/null +++ b/Tests/LitheTests/MacApplicationLogWriterTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("macOS application log writer") +struct MacApplicationLogWriterTests { + @Test + func changingDirectoryMovesSubsequentApplicationLogOutputToTheSelection() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: root) + } + + let writer = MacApplicationLogWriter() + let defaultDirectory = root.appendingPathComponent("default", isDirectory: true) + let selectedDirectory = root.appendingPathComponent("selected", isDirectory: true) + + try writer.redirect(to: defaultDirectory) + try writer.append("default log line\n") + + try writer.redirect(to: selectedDirectory) + try writer.append("selected log line\n") + + let defaultContents = try String( + contentsOf: defaultDirectory.appendingPathComponent("lithe.log"), + encoding: .utf8 + ) + let selectedContents = try String( + contentsOf: selectedDirectory.appendingPathComponent("lithe.log"), + encoding: .utf8 + ) + #expect(defaultContents == "default log line\n") + #expect(selectedContents == "selected log line\n") + } + + @Test + func defaultDirectoryProviderUsesTheMacUserLogsDirectory() { + let directory = MacLogDirectoryProvider().defaultLogDirectory + + #expect(directory.path.hasSuffix("/Library/Logs/Lithe")) + } + +} diff --git a/Tests/LitheTests/TestLogDirectoryProvider.swift b/Tests/LitheTests/TestLogDirectoryProvider.swift new file mode 100644 index 000000000..05bf59205 --- /dev/null +++ b/Tests/LitheTests/TestLogDirectoryProvider.swift @@ -0,0 +1,12 @@ +import Foundation +@testable import Lithe + +struct TestLogDirectoryProvider: LogDirectoryProviding { + let defaultLogDirectory = URL(fileURLWithPath: "/test/default-logs", isDirectory: true) +} + +extension AppSettings { + convenience init(store: any KeyValueStore) { + self.init(store: store, logDirectoryProvider: TestLogDirectoryProvider()) + } +} diff --git a/docs/superpowers/specs/2026-08-17-cross-platform-logging-requirements.md b/docs/superpowers/specs/2026-08-17-cross-platform-logging-requirements.md new file mode 100644 index 000000000..b688006ea --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-cross-platform-logging-requirements.md @@ -0,0 +1,60 @@ +# Lithe macOS 日志目录设置需求 + +## 背景 + +macOS 设置界面没有告诉用户日志默认保存在哪里,也不能修改日志保存目录。 + +本需求只完善 macOS 日志目录的查看和配置,并将应用现有的字体注册和性能基线诊断写入该目录,不涉及 Windows 端或业务日志扩展。 + +## 目标 + +在设置中增加日志目录设置,让用户能够: + +- 查看系统默认日志目录; +- 查看当前选定的日志目录; +- 选择新的日志目录; +- 恢复默认日志目录; +- 复制完整目录路径。 + +## 目录行为 + +### 默认目录 + +默认目录是 macOS 用户日志目录下的 `Lithe` 目录。设置页面展示系统解析后的真实绝对路径,不能硬编码开发机路径。 + +### 自定义目录 + +用户通过系统目录选择器选择目录。选择成功后保存配置,应用诊断输出立即切换到该目录中的 `lithe.log`,应用重启后继续使用该选择。该设置不影响其他配置。 + +如果自定义目录无法创建或打开,应用清除该选择并恢复默认目录,界面不得继续把失败目录显示为当前日志目录。 + +“恢复默认目录”只清除自定义日志目录。“恢复全部默认设置”也应清除该配置。 + +## 界面要求 + +日志设置页面显示默认目录和当前选定目录,并提供“选择目录”“恢复默认”操作。 + +长路径可以换行或缩略显示,但必须支持复制完整路径。 + +## 不在范围内 + +本次不做: + +- 新建结构化日志协议; +- 新建结构化日志 writer、业务 sink 或导出逻辑; +- 新增文件轮换和 ownership manifest; +- 新增 Run、Build、Test、Debug、LSP、Terminal 生命周期日志; +- 新增应用、窗口、工作区或文件事件日志; +- 新增帧率采样或性能日志; +- 修改日志格式、日志级别或日志内容; +- 自动上传、云端查看或崩溃转储。 + +## 验收标准 + +1. 设置页显示真实默认目录和当前选定目录。 +2. 用户可以选择自定义目录,重启后配置仍然存在。 +3. 用户可以恢复默认目录。 +4. “恢复全部默认设置”会清除自定义日志目录。 +5. 用户可以复制完整路径。 +6. 现有字体注册和性能基线诊断写入当前目录的 `lithe.log`,切换目录后新输出写入新目录。 +7. 没有引入新的结构化日志协议、业务埋点或帧率监控。