diff --git a/Sources/Models/AppConfig.swift b/Sources/Models/AppConfig.swift index 88d2675..2670015 100644 --- a/Sources/Models/AppConfig.swift +++ b/Sources/Models/AppConfig.swift @@ -28,7 +28,7 @@ struct AppConfig: Codable, Equatable { /// concern. var calibrationEnabled: Bool = false /// When true, a bug that first appears while Bugger is running is investigated - /// 15 minutes after it has a description or a screenshot. The launch snapshot + /// 15 minutes after it has a description, a title (问题描述), or a screenshot. The launch snapshot /// is never queued. The per-bug button works either way. var incidentReportsEnabled: Bool = true diff --git a/Sources/Services/IncidentAgentRunner.swift b/Sources/Services/IncidentAgentRunner.swift index ed33a2e..c1b7b0a 100644 --- a/Sources/Services/IncidentAgentRunner.swift +++ b/Sources/Services/IncidentAgentRunner.swift @@ -4,7 +4,7 @@ import Foundation /// /// The agent is started in ask mode with the sandbox on, and without `--force`, /// so it cannot change code or production data. Bugger saves the agent's final -/// message as the incident report under ~/Dev/aptsell/incidents. A permission +/// message as the incident report under ~/.bugger/incidents. A permission /// file also denies shell and mutating Grafana and TiDB tools. Temporary /// screenshot copies are deleted after the run. enum IncidentAgentRunner { @@ -16,7 +16,9 @@ enum IncidentAgentRunner { } static var incidentsDirectory: URL { - home.appendingPathComponent("Dev/aptsell/incidents", isDirectory: true) + home + .appendingPathComponent(".bugger", isDirectory: true) + .appendingPathComponent("incidents", isDirectory: true) } private static var agentBinary: URL { @@ -73,7 +75,7 @@ enum IncidentAgentRunner { - Do not change code, git state, configuration, or anything in production. - Do not run shell commands. Do not commit, push, or open a pull request. - Do not call any tool that creates, updates, deletes, installs, or writes data. - - The incident report is required. Your final message is that report, in full markdown. Bugger saves it under ~/Dev/aptsell/incidents. That saved report is the only write. Do not write or edit any other file. + - The incident report is required. Your final message is that report, in full markdown. Bugger saves it under ~/.bugger/incidents. That saved report is the only write. Do not write or edit any other file. - Grafana: only list, get, search, and query tools. Never call create_*, update_*, delete_*, alerting_*_write, alerting_manage_routing, add_activity_to_incident, install_plugin, or grafana_api_request. - TiDB: only read (db_query, show_databases, show_tables, switch_database). Never call db_execute, db_create_user, or db_remove_user. Never send SQL that changes data. - Do not modify Feishu, the bug record, dashboards, alerts, or incidents in Grafana. @@ -112,6 +114,8 @@ enum IncidentAgentRunner { """) if let description = bug.description?.trimmingCharacters(in: .whitespacesAndNewlines), !description.isEmpty { lines.append("Description:\n\(description)") + } else if bug.title != "Untitled" { + lines.append("Description (问题描述):\n\(bug.title)") } else { lines.append("Description: (none)") } @@ -370,6 +374,8 @@ private enum RunnerError: LocalizedError { /// Writes the workspace permission file the agent session merges in. /// Arrays in cli.json replace the home config's arrays for this session only. +/// Project cli.json accepts permissions only. Sandbox is turned on with the +/// `--sandbox enabled` process flag, which overrides the home config. private enum IncidentAgentPolicy { static func write(into incidentsDirectory: URL) throws { let directory = incidentsDirectory.appendingPathComponent(".cursor", isDirectory: true) @@ -379,10 +385,6 @@ private enum IncidentAgentPolicy { "allow": allow, "deny": deny, ], - "sandbox": [ - "mode": "enabled", - "networkAccess": "allow_all", - ], ] let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) try data.write(to: directory.appendingPathComponent("cli.json")) diff --git a/Sources/Services/IncidentReportService.swift b/Sources/Services/IncidentReportService.swift index 6bab26e..7d19343 100644 --- a/Sources/Services/IncidentReportService.swift +++ b/Sources/Services/IncidentReportService.swift @@ -2,9 +2,10 @@ import Foundation /// Tracks which bugs may be investigated automatically. /// -/// The first successful fetch of a process is a snapshot: those bugs are not -/// queued. A bug that appears later, while this process is still running, is -/// queued once it has a description or a screenshot, after a 15-minute wait. +/// Bugs created before this process (or before the previous run, so a bug that +/// arrived while the last process was up is not lost) are the baseline and are +/// not queued. A bug created while Bugger is running is queued once it has a +/// description, a title (问题描述), or a screenshot, after a 15-minute wait. /// Quitting during the wait drops the automatic run. The detail-view button /// skips both rules. @MainActor @@ -18,6 +19,10 @@ final class IncidentReportService { private var didSnapshot = false private var baseline: Set = [] + /// Bugs created at or after this instant are eligible. It is the start of + /// the previous process when that was recorded, so a bug that arrived + /// while that process was running is still picked up after a relaunch. + private let eligibleAfter: Date /// Automatic handling already decided for this process (timer armed, or a /// manual run took its place). Prevents a second automatic run. private var autoArmed: Set = [] @@ -25,7 +30,17 @@ final class IncidentReportService { private var queue: [Bug] = [] private var runningID: String? - private init() {} + private static let processStartedAtKey = "bugger.incidentProcessStartedAt" + + private init() { + let now = Date() + if let previous = UserDefaults.standard.object(forKey: Self.processStartedAtKey) as? Date { + eligibleAfter = previous + } else { + eligibleAfter = now + } + UserDefaults.standard.set(now, forKey: Self.processStartedAtKey) + } func phase(for bugID: String) -> IncidentReportPhase? { phases[bugID] @@ -39,10 +54,13 @@ final class IncidentReportService { func noteFetch(_ bugs: [Bug]) { if !didSnapshot { - baseline = Set(bugs.map(\.id)) + // Only bugs that already existed before this run are the snapshot. + // A bug created while Bugger is running must still be queued when + // the first fetch of the process is the one that sees it. + baseline = Set(bugs.filter { $0.createdAt < eligibleAfter }.map(\.id)) didSnapshot = true - BuggerLog.info("IncidentReportService: launch snapshot \(baseline.count) bug(s), none queued") - return + let arrived = bugs.filter { !baseline.contains($0.id) }.count + BuggerLog.info("IncidentReportService: snapshot \(baseline.count) existing, \(arrived) new since \(eligibleAfter)") } guard AppStateService.shared.config?.incidentReportsEnabled ?? true else { return } for bug in bugs where !baseline.contains(bug.id) { @@ -69,11 +87,27 @@ final class IncidentReportService { return } autoArmed.insert(bug.id) - let fireAt = Date().addingTimeInterval(Self.detailGrace) + // The wait is so the Feishu record can be filled in. If the bug was + // filed more than 15 minutes ago, that wait has already elapsed. + let elapsed = Date().timeIntervalSince(bug.createdAt) + let remaining: TimeInterval + if elapsed >= Self.detailGrace { + remaining = 0 + } else if elapsed >= 0 { + remaining = Self.detailGrace - elapsed + } else { + remaining = Self.detailGrace + } + if remaining == 0 { + BuggerLog.info("IncidentReportService: \(bug.id) already past the wait, investigating now") + enqueue(bug) + return + } + let fireAt = Date().addingTimeInterval(remaining) phases[bug.id] = .scheduled(fireAt: fireAt) BuggerLog.info("IncidentReportService: scheduled \(bug.id) at \(fireAt)") timers[bug.id] = Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(Self.detailGrace * 1_000_000_000)) + try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) guard !Task.isCancelled, let self else { return } self.timers[bug.id] = nil let latest = BugStore.shared.bugs.first { $0.id == bug.id } ?? bug @@ -125,6 +159,11 @@ final class IncidentReportService { !description.isEmpty { return true } + // 问题描述 is mapped as the title. A text-only bug still has enough to investigate. + let title = bug.title.trimmingCharacters(in: .whitespacesAndNewlines) + if !title.isEmpty, title != "Untitled" { + return true + } return !bug.screenshots.isEmpty } } diff --git a/Sources/Services/PollerService.swift b/Sources/Services/PollerService.swift index 9a203ee..71c1444 100644 --- a/Sources/Services/PollerService.swift +++ b/Sources/Services/PollerService.swift @@ -12,6 +12,7 @@ final class PollerService { private var timer: Timer? private var lastFetchTime: Date? + private var unlockObserver: NSObjectProtocol? private let fetchGate = FetchGate() private(set) var isRunning = false @@ -19,11 +20,25 @@ final class PollerService { private init() {} func startIfConfigured() async { - guard let config = configService.config, - config.isConfigured, - tokenManager.isAuthenticated else { + switch await tokenManager.approveKeychainIfNeeded() { + case .ready: + break + case .locked: + waitForUnlock() + return + case .unavailable: + BuggerLog.error("PollerService: not authenticated, incident reports will not run") return } + guard let config = configService.config, config.isConfigured else { + BuggerLog.error("PollerService: not configured, incident reports will not run") + return + } + guard tokenManager.isAuthenticated else { + BuggerLog.error("PollerService: not authenticated, incident reports will not run") + return + } + BuggerLog.info("PollerService: starting") start(interval: TimeInterval(config.pollIntervalSeconds), fetchImmediately: config.refreshOnStart) } @@ -31,7 +46,11 @@ final class PollerService { guard !isRunning else { return } isRunning = true - if fetchImmediately { + // Incident reports need a fetch at launch. Otherwise the first fetch + // can be hours later (daily schedule, refresh-on-launch off) and is + // the change that delivered the new bug. + let investigate = configService.config?.incidentReportsEnabled ?? true + if fetchImmediately || investigate { Task { await performFetch() } } @@ -49,6 +68,26 @@ final class PollerService { } } + /// Keychain Allow dialogs cannot appear while the screen is locked. + /// Retry the launch fetch once the user unlocks. + private func waitForUnlock() { + guard unlockObserver == nil else { return } + BuggerLog.info("Screen is locked, so the Feishu token cannot be read. Incident reports start after unlock.") + unlockObserver = DistributedNotificationCenter.default().addObserver( + forName: Notification.Name("com.apple.screenIsUnlocked"), + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + if let token = self.unlockObserver { + DistributedNotificationCenter.default().removeObserver(token) + self.unlockObserver = nil + } + BuggerLog.info("Screen unlocked, reading the Feishu token") + Task { await self.startIfConfigured() } + } + } + func stop() { timer?.invalidate() timer = nil @@ -151,6 +190,7 @@ final class PollerService { userOpenId: openId ) + BuggerLog.info("PollerService: fetched \(bugs.count) bugs") await MainActor.run { bugStore.update(with: bugs) bugStore.setLoading(false) @@ -158,11 +198,13 @@ final class PollerService { IncidentReportService.shared.noteFetch(bugs) } } catch FeishuError.unauthorized { + BuggerLog.error("PollerService: unauthorized") await MainActor.run { bugStore.setLoading(false) tokenManager.clearTokens() } } catch { + BuggerLog.error("PollerService: fetch failed \(error.localizedDescription)") await MainActor.run { bugStore.setLoading(false) bugStore.setError(error) diff --git a/Sources/Services/TokenManager.swift b/Sources/Services/TokenManager.swift index 826a924..8009f33 100644 --- a/Sources/Services/TokenManager.swift +++ b/Sources/Services/TokenManager.swift @@ -1,3 +1,5 @@ +import AppKit +import CoreGraphics import Foundation @Observable @@ -34,6 +36,53 @@ final class TokenManager { return false } + enum KeychainGate { + case ready + case locked + case unavailable + } + + /// The login-keychain item is tied to the code signature that wrote it. + /// An ad-hoc rebuild cannot read it until the user clicks Allow. The main + /// thread has to stay free so that dialog can appear. A locked screen + /// cannot present it at all. + @MainActor + func approveKeychainIfNeeded() async -> KeychainGate { + if isAuthenticated { return .ready } + guard keychain.needsApproval(accessTokenKey) else { return .unavailable } + if Self.screenIsLocked() { + return .locked + } + BuggerLog.info("Keychain approval required for the Feishu token") + let previous = NSApp.activationPolicy() + NSApp.setActivationPolicy(.regular) + NSApp.activate() + try? await Task.sleep(nanoseconds: 300_000_000) + let key = accessTokenKey + let helper = keychain + let token = await Task.detached { + helper.readPrompting(key) + }.value + if previous == .accessory { + NSApp.setActivationPolicy(.accessory) + } + if token != nil { + state = .authenticated + BuggerLog.info("Keychain approval granted") + return .ready + } + BuggerLog.error("Keychain approval was not granted") + return .unavailable + } + + private static func screenIsLocked() -> Bool { + guard let raw = CGSessionCopyCurrentDictionary() else { return false } + let value = (raw as NSDictionary)["CGSSessionScreenIsLocked"] + if let locked = value as? Bool { return locked } + if let number = value as? NSNumber { return number.intValue != 0 } + return false + } + var authorizeURL: URL? { guard authService.hasCredentials else { return nil } return authService.authorizeURL diff --git a/Sources/Utils/KeychainHelper.swift b/Sources/Utils/KeychainHelper.swift index 0a7ca79..7d27c43 100644 --- a/Sources/Utils/KeychainHelper.swift +++ b/Sources/Utils/KeychainHelper.swift @@ -10,30 +10,47 @@ final class KeychainHelper { func write(_ value: String, forKey key: String) { let data = Data(value.utf8) - let query: [String: Any] = [ + delete(key) + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: key, - kSecValueData as String: data + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock ] - SecItemDelete(query as CFDictionary) - SecItemAdd(query as CFDictionary, nil) + if let access = accessForAnyApplication() { + query[kSecAttrAccess as String] = access + } + let status = SecItemAdd(query as CFDictionary, nil) + if status != errSecSuccess { + BuggerLog.error("Keychain write \(key) failed (\(status))") + } } + /// Never shows UI. A prompting read from a background thread blocks forever + /// without a dialog when the app is a menu-bar item. func read(_ key: String) -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne - ] - var result: AnyObject? - guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, - let data = result as? Data else { + let result = copyMatching(key, prompt: false) + switch result.status { + case errSecSuccess, errSecItemNotFound: + return result.value + case errSecInteractionNotAllowed, errSecAuthFailed: + return nil + default: + BuggerLog.error("Keychain read \(key) failed (\(result.status))") return nil } - return String(data: data, encoding: .utf8) + } + + func needsApproval(_ key: String) -> Bool { + let status = copyMatching(key, prompt: false).status + return status == errSecInteractionNotAllowed || status == errSecAuthFailed + } + + /// Call on the main thread, after the app is a regular foreground app, + /// so the Allow dialog is visible. + func readPrompting(_ key: String) -> String? { + copyMatching(key, prompt: true).value } func delete(_ key: String) { @@ -44,4 +61,51 @@ final class KeychainHelper { ] SecItemDelete(query as CFDictionary) } + + private func copyMatching(_ key: String, prompt: Bool) -> (value: String?, status: OSStatus) { + // These items live in the legacy login keychain. kSecUseAuthenticationUI + // is ignored there, and a background read waits forever on a dialog + // that a menu-bar app never shows. + SecKeychainSetUserInteractionAllowed(prompt) + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + if !prompt { + query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail + } + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { + return (nil, status) + } + return (String(data: data, encoding: .utf8), status) + } + + /// Each ad-hoc build has a new code signature. The default keychain ACL + /// then blocks in a permission dialog, and launch never reaches the fetch. + /// Trust every application on this Mac so the next build can still read + /// the Feishu tokens. + private func accessForAnyApplication() -> SecAccess? { + var access: SecAccess? + guard SecAccessCreate("Bugger" as CFString, nil, &access) == errSecSuccess, + let access else { return nil } + var list: CFArray? + guard SecAccessCopyACLList(access, &list) == errSecSuccess, + let acls = list as? [SecACL] else { + return access + } + for acl in acls { + var applications: CFArray? + var description: CFString? + var prompt = SecKeychainPromptSelector() + guard SecACLCopyContents(acl, &applications, &description, &prompt) == errSecSuccess else { continue } + let label = description ?? "Bugger" as CFString + SecACLSetContents(acl, nil, label, []) + } + return access + } } diff --git a/Sources/Utils/Logger.swift b/Sources/Utils/Logger.swift index f990518..ea0a174 100644 --- a/Sources/Utils/Logger.swift +++ b/Sources/Utils/Logger.swift @@ -5,16 +5,41 @@ import OSLog /// (filter by subsystem: com.xorbitlab.bugger). enum BuggerLog { private static let logger = Logger(subsystem: "com.xorbitlab.bugger", category: "general") + private static let queue = DispatchQueue(label: "com.xorbitlab.bugger.log") + private static let logURL: URL = { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Logs/Bugger.log") + }() static func info(_ message: String) { logger.info("\(message, privacy: .public)") + write("INFO", message) } static func error(_ message: String) { logger.error("\(message, privacy: .public)") + write("ERROR", message) } static func debug(_ message: String) { logger.debug("\(message, privacy: .public)") + write("DEBUG", message) + } + + /// Unified logging for this ad-hoc app has been empty, so keep a file too. + private static func write(_ level: String, _ message: String) { + queue.async { + let stamp = ISO8601DateFormatter().string(from: Date()) + let line = "\(stamp) \(level) \(message)\n" + guard let data = line.data(using: .utf8) else { return } + if FileManager.default.fileExists(atPath: logURL.path), + let handle = try? FileHandle(forWritingTo: logURL) { + defer { try? handle.close() } + _ = try? handle.seekToEnd() + try? handle.write(contentsOf: data) + } else { + try? data.write(to: logURL) + } + } } } diff --git a/Sources/Views/MainWindow/BugDetailView.swift b/Sources/Views/MainWindow/BugDetailView.swift index 88505f7..2e4669e 100644 --- a/Sources/Views/MainWindow/BugDetailView.swift +++ b/Sources/Views/MainWindow/BugDetailView.swift @@ -130,9 +130,9 @@ struct BugDetailView: View { private func incidentStatus(_ phase: IncidentReportPhase?) -> String { switch phase { case nil: - return "Read-only. Writes a Chinese and English report under ~/Dev/aptsell/incidents. Does not change code or data." + return "Read-only. Writes a Chinese and English report under ~/.bugger/incidents. Does not change code or data." case .waitingForDetail: - return "Automatic report waits until this bug has a description or a screenshot, then another 15 minutes." + return "Automatic report waits until this bug has a description (问题描述 counts) or a screenshot, then another 15 minutes." case .scheduled(let fireAt): return "Automatic report at \(fireAt.formatted(date: .omitted, time: .shortened)). It only reads logs and code." case .queued: diff --git a/Sources/Views/Settings/SettingsView.swift b/Sources/Views/Settings/SettingsView.swift index edf31c1..948e37b 100644 --- a/Sources/Views/Settings/SettingsView.swift +++ b/Sources/Views/Settings/SettingsView.swift @@ -96,7 +96,7 @@ struct SettingsView: View { // MARK: Incident reports settingsSection("Incident reports") { Toggle("Investigate new bugs", isOn: $config.incidentReportsEnabled) - Text("When a bug arrives while Bugger is running and it has a description or a screenshot, wait 15 minutes, then write a read-only incident report to ~/Dev/aptsell/incidents. Bugs already loaded at launch are skipped. The button on a bug runs immediately.") + Text("When a bug arrives while Bugger is running and it has a description (问题描述 counts) or a screenshot, wait 15 minutes, then write a read-only incident report to ~/.bugger/incidents. Bugs already loaded at launch are skipped. The button on a bug runs immediately.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/fsd/incident/fsd_incident_report.md b/fsd/incident/fsd_incident_report.md new file mode 100644 index 0000000..d34ac65 --- /dev/null +++ b/fsd/incident/fsd_incident_report.md @@ -0,0 +1,24 @@ +# FSD:只读事故报告 + +| 要素 | 内容 | +|------|------| +| 时间 | 2026-10-25 | +| 关联服务 | Bugger(macOS) | +| 关联功能模块 | incident | +| 提交 | | + +技术规格:[tsd_incident_report.md](../../tsd/incident/tsd_incident_report.md). + +## 1. 行为 + +| # | 行为 | +|---|------| +| F1 | 设置里“Investigate new bugs”默认开启。Bugger 运行期间新出现、且有问题描述、描述或截图的缺陷会生成中英双语只读事故报告。启动时已经存在的缺陷不会自动排队。 | +| F2 | 自动调查从缺陷创建时间起等待 15 分钟,便于补全飞书记录。已经超过 15 分钟则马上开始。详情页按钮跳过等待和基线,立即调查。 | +| F3 | 报告文件出现在 `~/.bugger/incidents`。不需要预先创建 `~/Dev/aptsell`。调查不改代码、不改生产数据。 | +| F4 | 屏幕锁定时不弹出钥匙串授权。解锁后继续登录并拉取。需要授权时出现 Allow 对话框。 | +| F5 | 调查进行中按钮显示等待状态,窗口其余部分仍可使用。完成后用系统通知给出报告路径或失败原因。 | + +## 变更记录 + +- 2026-10-25 fix(incident): 让新缺陷的事故报告能写出并改存用户目录 — 默认目录、问题描述计数、锁屏后继续 diff --git a/tsd/incident/tsd_incident_report.md b/tsd/incident/tsd_incident_report.md new file mode 100644 index 0000000..81dfdb5 --- /dev/null +++ b/tsd/incident/tsd_incident_report.md @@ -0,0 +1,38 @@ +# TSD:只读事故报告 + +| 要素 | 内容 | +|------|------| +| 时间 | 2026-10-25 | +| 关联服务 | Bugger(macOS) | +| 关联功能模块 | incident | +| 提交 | | + +功能规格:[fsd_incident_report.md](../../fsd/incident/fsd_incident_report.md). + +## 1. 决定 + +| # | 决定 | +|---|------| +| P1 | 报告由 Bugger 写入 `~/.bugger/incidents`。目录在首次运行时创建。cursor-agent 的工作区就是该目录,代码仓库仍通过 `--add-dir` 加入。 | +| P2 | 自动排队只包含 `createdAt` 不早于上一进程启动时间的缺陷。进程启动时间记在 `bugger.incidentProcessStartedAt`。启动时已存在的缺陷不自动排队。 | +| P3 | 问题描述映射为标题。非空且不是 `Untitled` 的标题、描述或截图都算作已有细节。等待从 `createdAt` 起算 15 分钟,已超过则立即调查。 | +| P4 | 事故报告开启时,启动即拉取一次,不依赖“启动时刷新”或每日时刻。 | +| P5 | 项目 `.cursor/cli.json` 只写 permissions。沙箱由进程参数 `--sandbox enabled` 打开。 | +| P6 | 钥匙串读取默认不弹窗。需要授权时把应用切到前台,在后台线程弹出 Allow,主线程保持可绘制。锁屏时不弹窗,解锁后再试一次。新写入的项允许本机任意应用读取,避免 ad-hoc 重签名后启动卡死。 | +| P7 | 同一进程同时只跑一个 cursor-agent。日志追加到 `~/Library/Logs/Bugger.log`。 | + +## 2. 代码 + +- `Sources/Services/IncidentAgentRunner.swift` +- `Sources/Services/IncidentReportService.swift` +- `Sources/Services/PollerService.swift` +- `Sources/Services/TokenManager.swift` +- `Sources/Utils/KeychainHelper.swift` +- `Sources/Utils/Logger.swift` +- `Sources/Views/MainWindow/BugDetailView.swift` +- `Sources/Views/Settings/SettingsView.swift` +- `Sources/Models/AppConfig.swift` + +## 变更记录 + +- 2026-10-25 fix(incident): 让新缺陷的事故报告能写出并改存用户目录 — 报告目录、排队条件、启动拉取、钥匙串授权与项目配置