fix(incident): 让新缺陷的事故报告能写出并改存用户目录
- 报告默认写入 ~/.bugger/incidents,不再依赖 ~/Dev/aptsell - 问题描述视为有效描述;启动即拉取,只自动排队上次进程之后新建的缺陷 - 钥匙串读取不再卡住菜单栏应用,锁屏解锁后再继续 - 项目 cli.json 去掉不被接受的 sandbox 字段 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f54f5175c2
commit
f3746d092f
|
|
@ -28,7 +28,7 @@ struct AppConfig: Codable, Equatable {
|
||||||
/// concern.
|
/// concern.
|
||||||
var calibrationEnabled: Bool = false
|
var calibrationEnabled: Bool = false
|
||||||
/// When true, a bug that first appears while Bugger is running is investigated
|
/// 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.
|
/// is never queued. The per-bug button works either way.
|
||||||
var incidentReportsEnabled: Bool = true
|
var incidentReportsEnabled: Bool = true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import Foundation
|
||||||
///
|
///
|
||||||
/// The agent is started in ask mode with the sandbox on, and without `--force`,
|
/// 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
|
/// 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
|
/// file also denies shell and mutating Grafana and TiDB tools. Temporary
|
||||||
/// screenshot copies are deleted after the run.
|
/// screenshot copies are deleted after the run.
|
||||||
enum IncidentAgentRunner {
|
enum IncidentAgentRunner {
|
||||||
|
|
@ -16,7 +16,9 @@ enum IncidentAgentRunner {
|
||||||
}
|
}
|
||||||
|
|
||||||
static var incidentsDirectory: URL {
|
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 {
|
private static var agentBinary: URL {
|
||||||
|
|
@ -73,7 +75,7 @@ enum IncidentAgentRunner {
|
||||||
- Do not change code, git state, configuration, or anything in production.
|
- 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 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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 {
|
if let description = bug.description?.trimmingCharacters(in: .whitespacesAndNewlines), !description.isEmpty {
|
||||||
lines.append("Description:\n\(description)")
|
lines.append("Description:\n\(description)")
|
||||||
|
} else if bug.title != "Untitled" {
|
||||||
|
lines.append("Description (问题描述):\n\(bug.title)")
|
||||||
} else {
|
} else {
|
||||||
lines.append("Description: (none)")
|
lines.append("Description: (none)")
|
||||||
}
|
}
|
||||||
|
|
@ -370,6 +374,8 @@ private enum RunnerError: LocalizedError {
|
||||||
|
|
||||||
/// Writes the workspace permission file the agent session merges in.
|
/// Writes the workspace permission file the agent session merges in.
|
||||||
/// Arrays in cli.json replace the home config's arrays for this session only.
|
/// 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 {
|
private enum IncidentAgentPolicy {
|
||||||
static func write(into incidentsDirectory: URL) throws {
|
static func write(into incidentsDirectory: URL) throws {
|
||||||
let directory = incidentsDirectory.appendingPathComponent(".cursor", isDirectory: true)
|
let directory = incidentsDirectory.appendingPathComponent(".cursor", isDirectory: true)
|
||||||
|
|
@ -379,10 +385,6 @@ private enum IncidentAgentPolicy {
|
||||||
"allow": allow,
|
"allow": allow,
|
||||||
"deny": deny,
|
"deny": deny,
|
||||||
],
|
],
|
||||||
"sandbox": [
|
|
||||||
"mode": "enabled",
|
|
||||||
"networkAccess": "allow_all",
|
|
||||||
],
|
|
||||||
]
|
]
|
||||||
let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
|
let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
|
||||||
try data.write(to: directory.appendingPathComponent("cli.json"))
|
try data.write(to: directory.appendingPathComponent("cli.json"))
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@ import Foundation
|
||||||
|
|
||||||
/// Tracks which bugs may be investigated automatically.
|
/// Tracks which bugs may be investigated automatically.
|
||||||
///
|
///
|
||||||
/// The first successful fetch of a process is a snapshot: those bugs are not
|
/// Bugs created before this process (or before the previous run, so a bug that
|
||||||
/// queued. A bug that appears later, while this process is still running, is
|
/// arrived while the last process was up is not lost) are the baseline and are
|
||||||
/// queued once it has a description or a screenshot, after a 15-minute wait.
|
/// 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
|
/// Quitting during the wait drops the automatic run. The detail-view button
|
||||||
/// skips both rules.
|
/// skips both rules.
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|
@ -18,6 +19,10 @@ final class IncidentReportService {
|
||||||
|
|
||||||
private var didSnapshot = false
|
private var didSnapshot = false
|
||||||
private var baseline: Set<String> = []
|
private var baseline: Set<String> = []
|
||||||
|
/// 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
|
/// Automatic handling already decided for this process (timer armed, or a
|
||||||
/// manual run took its place). Prevents a second automatic run.
|
/// manual run took its place). Prevents a second automatic run.
|
||||||
private var autoArmed: Set<String> = []
|
private var autoArmed: Set<String> = []
|
||||||
|
|
@ -25,7 +30,17 @@ final class IncidentReportService {
|
||||||
private var queue: [Bug] = []
|
private var queue: [Bug] = []
|
||||||
private var runningID: String?
|
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? {
|
func phase(for bugID: String) -> IncidentReportPhase? {
|
||||||
phases[bugID]
|
phases[bugID]
|
||||||
|
|
@ -39,10 +54,13 @@ final class IncidentReportService {
|
||||||
|
|
||||||
func noteFetch(_ bugs: [Bug]) {
|
func noteFetch(_ bugs: [Bug]) {
|
||||||
if !didSnapshot {
|
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
|
didSnapshot = true
|
||||||
BuggerLog.info("IncidentReportService: launch snapshot \(baseline.count) bug(s), none queued")
|
let arrived = bugs.filter { !baseline.contains($0.id) }.count
|
||||||
return
|
BuggerLog.info("IncidentReportService: snapshot \(baseline.count) existing, \(arrived) new since \(eligibleAfter)")
|
||||||
}
|
}
|
||||||
guard AppStateService.shared.config?.incidentReportsEnabled ?? true else { return }
|
guard AppStateService.shared.config?.incidentReportsEnabled ?? true else { return }
|
||||||
for bug in bugs where !baseline.contains(bug.id) {
|
for bug in bugs where !baseline.contains(bug.id) {
|
||||||
|
|
@ -69,11 +87,27 @@ final class IncidentReportService {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
autoArmed.insert(bug.id)
|
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)
|
phases[bug.id] = .scheduled(fireAt: fireAt)
|
||||||
BuggerLog.info("IncidentReportService: scheduled \(bug.id) at \(fireAt)")
|
BuggerLog.info("IncidentReportService: scheduled \(bug.id) at \(fireAt)")
|
||||||
timers[bug.id] = Task { [weak self] in
|
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 }
|
guard !Task.isCancelled, let self else { return }
|
||||||
self.timers[bug.id] = nil
|
self.timers[bug.id] = nil
|
||||||
let latest = BugStore.shared.bugs.first { $0.id == bug.id } ?? bug
|
let latest = BugStore.shared.bugs.first { $0.id == bug.id } ?? bug
|
||||||
|
|
@ -125,6 +159,11 @@ final class IncidentReportService {
|
||||||
!description.isEmpty {
|
!description.isEmpty {
|
||||||
return true
|
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
|
return !bug.screenshots.isEmpty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ final class PollerService {
|
||||||
|
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
private var lastFetchTime: Date?
|
private var lastFetchTime: Date?
|
||||||
|
private var unlockObserver: NSObjectProtocol?
|
||||||
private let fetchGate = FetchGate()
|
private let fetchGate = FetchGate()
|
||||||
|
|
||||||
private(set) var isRunning = false
|
private(set) var isRunning = false
|
||||||
|
|
@ -19,11 +20,25 @@ final class PollerService {
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
func startIfConfigured() async {
|
func startIfConfigured() async {
|
||||||
guard let config = configService.config,
|
switch await tokenManager.approveKeychainIfNeeded() {
|
||||||
config.isConfigured,
|
case .ready:
|
||||||
tokenManager.isAuthenticated else {
|
break
|
||||||
|
case .locked:
|
||||||
|
waitForUnlock()
|
||||||
|
return
|
||||||
|
case .unavailable:
|
||||||
|
BuggerLog.error("PollerService: not authenticated, incident reports will not run")
|
||||||
return
|
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)
|
start(interval: TimeInterval(config.pollIntervalSeconds), fetchImmediately: config.refreshOnStart)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,7 +46,11 @@ final class PollerService {
|
||||||
guard !isRunning else { return }
|
guard !isRunning else { return }
|
||||||
isRunning = true
|
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() }
|
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() {
|
func stop() {
|
||||||
timer?.invalidate()
|
timer?.invalidate()
|
||||||
timer = nil
|
timer = nil
|
||||||
|
|
@ -151,6 +190,7 @@ final class PollerService {
|
||||||
userOpenId: openId
|
userOpenId: openId
|
||||||
)
|
)
|
||||||
|
|
||||||
|
BuggerLog.info("PollerService: fetched \(bugs.count) bugs")
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
bugStore.update(with: bugs)
|
bugStore.update(with: bugs)
|
||||||
bugStore.setLoading(false)
|
bugStore.setLoading(false)
|
||||||
|
|
@ -158,11 +198,13 @@ final class PollerService {
|
||||||
IncidentReportService.shared.noteFetch(bugs)
|
IncidentReportService.shared.noteFetch(bugs)
|
||||||
}
|
}
|
||||||
} catch FeishuError.unauthorized {
|
} catch FeishuError.unauthorized {
|
||||||
|
BuggerLog.error("PollerService: unauthorized")
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
bugStore.setLoading(false)
|
bugStore.setLoading(false)
|
||||||
tokenManager.clearTokens()
|
tokenManager.clearTokens()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
BuggerLog.error("PollerService: fetch failed \(error.localizedDescription)")
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
bugStore.setLoading(false)
|
bugStore.setLoading(false)
|
||||||
bugStore.setError(error)
|
bugStore.setError(error)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import AppKit
|
||||||
|
import CoreGraphics
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
|
|
@ -34,6 +36,53 @@ final class TokenManager {
|
||||||
return false
|
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? {
|
var authorizeURL: URL? {
|
||||||
guard authService.hasCredentials else { return nil }
|
guard authService.hasCredentials else { return nil }
|
||||||
return authService.authorizeURL
|
return authService.authorizeURL
|
||||||
|
|
|
||||||
|
|
@ -10,30 +10,47 @@ final class KeychainHelper {
|
||||||
|
|
||||||
func write(_ value: String, forKey key: String) {
|
func write(_ value: String, forKey key: String) {
|
||||||
let data = Data(value.utf8)
|
let data = Data(value.utf8)
|
||||||
let query: [String: Any] = [
|
delete(key)
|
||||||
|
var query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
kSecAttrAccount as String: key,
|
kSecAttrAccount as String: key,
|
||||||
kSecValueData as String: data
|
kSecValueData as String: data,
|
||||||
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
|
||||||
]
|
]
|
||||||
SecItemDelete(query as CFDictionary)
|
if let access = accessForAnyApplication() {
|
||||||
SecItemAdd(query as CFDictionary, nil)
|
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? {
|
func read(_ key: String) -> String? {
|
||||||
let query: [String: Any] = [
|
let result = copyMatching(key, prompt: false)
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
switch result.status {
|
||||||
kSecAttrService as String: service,
|
case errSecSuccess, errSecItemNotFound:
|
||||||
kSecAttrAccount as String: key,
|
return result.value
|
||||||
kSecReturnData as String: true,
|
case errSecInteractionNotAllowed, errSecAuthFailed:
|
||||||
kSecMatchLimit as String: kSecMatchLimitOne
|
return nil
|
||||||
]
|
default:
|
||||||
var result: AnyObject?
|
BuggerLog.error("Keychain read \(key) failed (\(result.status))")
|
||||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
|
||||||
let data = result as? Data else {
|
|
||||||
return nil
|
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) {
|
func delete(_ key: String) {
|
||||||
|
|
@ -44,4 +61,51 @@ final class KeychainHelper {
|
||||||
]
|
]
|
||||||
SecItemDelete(query as CFDictionary)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,41 @@ import OSLog
|
||||||
/// (filter by subsystem: com.xorbitlab.bugger).
|
/// (filter by subsystem: com.xorbitlab.bugger).
|
||||||
enum BuggerLog {
|
enum BuggerLog {
|
||||||
private static let logger = Logger(subsystem: "com.xorbitlab.bugger", category: "general")
|
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) {
|
static func info(_ message: String) {
|
||||||
logger.info("\(message, privacy: .public)")
|
logger.info("\(message, privacy: .public)")
|
||||||
|
write("INFO", message)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func error(_ message: String) {
|
static func error(_ message: String) {
|
||||||
logger.error("\(message, privacy: .public)")
|
logger.error("\(message, privacy: .public)")
|
||||||
|
write("ERROR", message)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func debug(_ message: String) {
|
static func debug(_ message: String) {
|
||||||
logger.debug("\(message, privacy: .public)")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -130,9 +130,9 @@ struct BugDetailView: View {
|
||||||
private func incidentStatus(_ phase: IncidentReportPhase?) -> String {
|
private func incidentStatus(_ phase: IncidentReportPhase?) -> String {
|
||||||
switch phase {
|
switch phase {
|
||||||
case nil:
|
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:
|
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):
|
case .scheduled(let fireAt):
|
||||||
return "Automatic report at \(fireAt.formatted(date: .omitted, time: .shortened)). It only reads logs and code."
|
return "Automatic report at \(fireAt.formatted(date: .omitted, time: .shortened)). It only reads logs and code."
|
||||||
case .queued:
|
case .queued:
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ struct SettingsView: View {
|
||||||
// MARK: Incident reports
|
// MARK: Incident reports
|
||||||
settingsSection("Incident reports") {
|
settingsSection("Incident reports") {
|
||||||
Toggle("Investigate new bugs", isOn: $config.incidentReportsEnabled)
|
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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
|
||||||
|
|
@ -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): 让新缺陷的事故报告能写出并改存用户目录 — 默认目录、问题描述计数、锁屏后继续
|
||||||
|
|
@ -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): 让新缺陷的事故报告能写出并改存用户目录 — 报告目录、排队条件、启动拉取、钥匙串授权与项目配置
|
||||||
Loading…
Reference in New Issue