Security + upstream fixes

- Network core moves to a root-only folder; BroV talks to a narrow root helper (netctl.py) over a user-only socket; install script verifies the mihomo SHA256 and migrates keys (scripts/netcore)
- Hardened runtime, no get-task-allow; bypassPermissions removed from the chat; concealed clipboard items are not restored; DangerCheck knows core, LaunchAgents and hook paths; dropped-file copies expire after 7 days
- Ported from upstream Coucou: 1h crash fix (d05f22b), safe settings.json writes (918d30e), Escape/fold for pending approvals (6012900, 40e3ba8), auto-close delay + reopen (74984f2, ea244a7), full AskUserQuestion (52b1562)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
maksarsanjeev
2026-10-07 23:49:11 +03:00
parent dc47247340
commit 52e1e8288b
21 changed files with 1070 additions and 188 deletions
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Hardened runtime: BroV drives Terminal, Music and Mail through Apple Events. -->
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>
+20
View File
@@ -23,6 +23,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
exit(0)
}
BroVLaunchAgent.ensure()
Self.pruneInbox()
setupMenuBarItem()
CodexUsageMonitor.shared.start()
AgentWatch.shared.start()
@@ -33,6 +34,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
#endif
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
openIsland()
return true
}
/// Copies of dropped files (HookServer.supportDir/inbox) are kept 7 days, then removed.
private static func pruneInbox() {
let inbox = HookServer.supportDir.appendingPathComponent("inbox")
let fm = FileManager.default
guard let files = try? fm.contentsOfDirectory(at: inbox, includingPropertiesForKeys: [.contentModificationDateKey]) else { return }
let cutoff = Date().addingTimeInterval(-7 * 86400)
for f in files {
let d = (try? f.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantFuture
if d < cutoff { try? fm.removeItem(at: f) }
}
try? fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: inbox.path)
}
// MARK: - Menu bar
private func setupMenuBarItem() {
@@ -56,6 +75,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// MARK: - Actions
@objc private func openIsland() {
islandController?.fsm.openedExternally()
islandController?.expand(to: .overview)
}
+7 -3
View File
@@ -378,7 +378,9 @@ final class AppState: ObservableObject {
@Published var pendingApproval: ApprovalInfo? = nil
// Pending AskUserQuestion from Claude Code hook
@Published var pendingQuestion: AskQuestion? = nil
@Published var pendingQuestion: AskQuestion? = nil {
didSet { QuestionLayout.height = pendingQuestion?.estimatedIslandHeight }
}
// Per-pill flat list of FileDiffs, in order of reception.
// Not @Published — steps[] changes already trigger redraws.
@@ -411,11 +413,13 @@ final class AppState: ObservableObject {
private func resetSessionDiffTimer(for pillId: String) {
sessionDiffTimers[pillId]?.cancel()
// The closure is MainActor-isolated (AppState is @MainActor): it must run on the main
// queue. Scheduled on a global queue, Swift 6's isolation check traps and the app quits.
let work = DispatchWorkItem { [weak self] in
DispatchQueue.main.async { self?.clearSessionDiffs(for: pillId) }
self?.clearSessionDiffs(for: pillId)
}
sessionDiffTimers[pillId] = work
DispatchQueue.global().asyncAfter(deadline: .now() + 3600, execute: work)
DispatchQueue.main.asyncAfter(deadline: .now() + 3600, execute: work)
}
#if !APPSTORE
+32
View File
@@ -14,9 +14,41 @@ struct AskQuestionItem: Equatable {
var multiSelect: Bool
}
/// Island height of the pending question, readable from the nonisolated `islandSize`. Written on the main actor only.
enum QuestionLayout {
nonisolated(unsafe) static var height: CGFloat?
}
extension AskQuestionItem {
/// True when at least one option carries a description: the card then lists options vertically.
var hasDescriptions: Bool { options.contains { !$0.description.isEmpty } }
}
struct AskQuestion: Equatable {
var questions: [AskQuestionItem] // 1–4 questions
/// Island height that fits the tallest question without truncation (rough estimate, text wraps at ~500 pt).
var estimatedIslandHeight: CGFloat {
func lines(_ text: String, charWidth: CGFloat) -> CGFloat {
max(1, (CGFloat(text.count) * charWidth / 500).rounded(.up))
}
let tallest = questions.map { item -> CGFloat in
var h: CGFloat = 20 + lines(item.question, charWidth: 7) * 17 + 64
if !item.header.isEmpty { h += 14 }
if item.hasDescriptions {
for opt in item.options {
h += 34 + (opt.description.isEmpty ? 0 : lines(opt.description, charWidth: 6.4) * 14)
}
h += 40 // "Другое…" row
} else {
h += item.options.count >= 3 ? 74 : 40
}
if item.multiSelect { h += 34 }
return h
}.max() ?? 160
return min(max(tallest, 160), 560)
}
// MARK: - Parse from tool_input dict
// Returns nil if the payload is malformed (fallback → Allow/Deny card).
static func parse(toolInput: [String: Any]) -> AskQuestion? {
+3 -1
View File
@@ -108,7 +108,9 @@ final class ClaudeCodeCLI {
"--append-system-prompt", Self.notchPrompt]
if let sessionID { args += ["--resume", sessionID] }
if !model.isEmpty { args += ["--model", model] }
if !permissionMode.isEmpty, permissionMode != "default" { args += ["--permission-mode", permissionMode] }
// Never bypass: the chat gets untrusted input (dropped files, window titles, URLs).
let allowedModes: Set<String> = ["acceptEdits", "plan"]
if allowedModes.contains(permissionMode) { args += ["--permission-mode", permissionMode] }
let p = Process()
p.executableURL = URL(fileURLWithPath: binary)
@@ -0,0 +1,144 @@
import Foundation
// MARK: - ClaudeSettingsFile
// Reads and rewrites a settings file BroV does not own (~/.claude/settings.json).
// Never start from an empty object when the file is there but unusable, always
// take a backup, and only ever write over the exact bytes the user was shown.
enum ClaudeSettingsFile {
enum Failure: LocalizedError, Equatable {
case unreadable(String)
case invalid(String)
case changed(String)
case backupFailed(String)
case writeFailed(String)
case unexpectedHooks(String)
var errorDescription: String? {
switch self {
case .unreadable(let name):
return "Не удалось прочитать \(name) — BroV его не трогал."
case .invalid(let name):
return "\(name) — некорректный JSON, BroV его не трогал."
case .changed(let name):
return "\(name) изменился после предпросмотра. Ничего не записано — откройте предпросмотр заново."
case .backupFailed(let name):
return "Не удалось сделать резервную копию \(name). Ничего не записано."
case .writeFailed(let name):
return "Не удалось записать \(name). Оригинал не тронут."
case .unexpectedHooks(let name):
return "\(name): \"hooks\" имеет неожиданный тип — BroV его не трогал."
}
}
}
/// The "hooks" object of a settings file. Absent → empty.
/// Present but not an object → throws, so it is never replaced.
static func hooks(in settings: [String: Any], name: String) throws -> [String: Any] {
guard let value = settings["hooks"] else { return [:] }
guard let hooks = value as? [String: Any] else { throw Failure.unexpectedHooks(name) }
return hooks
}
/// The hook groups already declared for one event. Absent → empty.
/// Present but not a list of objects → throws, so it is never replaced.
static func hookGroups(in hooks: [String: Any], event: String, name: String) throws -> [[String: Any]] {
guard let value = hooks[event] else { return [] }
guard let groups = value as? [[String: Any]] else { throw Failure.unexpectedHooks(name) }
return groups
}
/// The settings object and the bytes it was parsed from.
/// Absent file → empty object and nil bytes. An empty file is an empty object.
/// Present but unreadable, or anything that is not a JSON object → throws:
/// not knowing what is in there is not the same as empty.
static func read(at url: URL) throws -> (object: [String: Any], bytes: Data?) {
guard FileManager.default.fileExists(atPath: url.path) else { return ([:], nil) }
let name = url.lastPathComponent
guard let bytes = try? Data(contentsOf: url) else { throw Failure.unreadable(name) }
if bytes.allSatisfy({ $0 == 0x20 || $0 == 0x09 || $0 == 0x0A || $0 == 0x0D }) {
return ([:], bytes)
}
guard let object = (try? JSONSerialization.jsonObject(with: bytes)) as? [String: Any] else {
throw Failure.invalid(name)
}
return (object, bytes)
}
/// Replaces the file with `data`, after a dated backup.
///
/// `original` is what `read` returned when `data` was computed. If the file
/// holds anything else by now — another tool, the user's own editor — nothing
/// is written. Returns the backup, or nil when there was no file to back up.
@discardableResult
static func write(_ data: Data, to url: URL, expecting original: Data?) throws -> URL? {
let fm = FileManager.default
let name = url.lastPathComponent
let exists = fm.fileExists(atPath: url.path)
var current: Data? = nil
if exists {
guard let bytes = try? Data(contentsOf: url) else { throw Failure.unreadable(name) }
current = bytes
}
guard current == original else { throw Failure.changed(name) }
// A dotfiles setup often makes settings.json a symlink: write to the file
// it points at, so the link survives the rename below.
let target = url.resolvingSymlinksInPath()
var backupURL: URL? = nil
// settings.json can hold API keys in its `env` block: a new file is ours
// only, and a rewrite keeps the permissions the original had.
var mode = 0o600
if exists {
let backup = freeBackupURL(for: url)
do { try fm.copyItem(at: target, to: backup) } catch { throw Failure.backupFailed(name) }
backupURL = backup
if let found = (try? fm.attributesOfItem(atPath: target.path))?[.posixPermissions] as? NSNumber {
mode = found.intValue & 0o777
}
} else {
try? fm.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
}
// Written beside the target and renamed over it: a crash or a full disk
// leaves the original intact rather than half a file.
let temp = target.deletingLastPathComponent()
.appendingPathComponent("\(target.lastPathComponent).brov-\(ProcessInfo.processInfo.processIdentifier)")
try? fm.removeItem(at: temp)
guard fm.createFile(atPath: temp.path, contents: data,
attributes: [.posixPermissions: NSNumber(value: 0o600)]) else {
throw Failure.writeFailed(name)
}
do {
try fm.setAttributes([.posixPermissions: NSNumber(value: mode)], ofItemAtPath: temp.path)
} catch {
try? fm.removeItem(at: temp)
throw Failure.writeFailed(name)
}
guard rename(temp.path, target.path) == 0 else {
try? fm.removeItem(at: temp)
throw Failure.writeFailed(name)
}
return backupURL
}
/// Down to the second, and never an existing name: installing then
/// uninstalling in the same second must not lose the first backup.
private static func freeBackupURL(for url: URL) -> URL {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyyMMdd-HHmmss"
let base = "\(url.lastPathComponent).bak-\(formatter.string(from: Date()))"
let dir = url.deletingLastPathComponent()
var candidate = dir.appendingPathComponent(base)
var n = 2
while FileManager.default.fileExists(atPath: candidate.path) {
candidate = dir.appendingPathComponent("\(base)-\(n)")
n += 1
}
return candidate
}
}
+14 -1
View File
@@ -56,12 +56,25 @@ enum DangerCheck {
(#"/\.(zshrc|bashrc|zprofile|bash_profile|gitconfig)$"#, "правит конфиг оболочки или git"),
(#"/\.git/"#, "правит внутренности репозитория (.git)"),
(#"(id_rsa|id_ed25519|\.pem|\.key|credentials|secrets?)(\.|$)"#, "трогает ключи или секреты"),
(#"/(NotchBuddy|BroV)/netcore(/|$)|\.vpnkey$"#, "меняет сетевое ядро BroV или его ключи"),
(#"/Library/Launch(Agents|Daemons)/"#, "меняет автозапуск (LaunchAgents / LaunchDaemons)"),
(#"/NotchBuddy/nb-hook"#, "меняет хуки BroV"),
]
/// The same sensitive places when a shell command writes, moves or deletes there.
static let shellPathRules: [Rule] = [
Rule(pattern: #"(>|\btee\b|\bcp\b|\bmv\b|\brm\b|\bln\b|\bchmod\b|\bchown\b|sed\s+-i|\bpython3?\b|\bperl\b).*(NotchBuddy/netcore|BroV/netcore|\.vpnkey)"#,
reason: "меняет сетевое ядро BroV или его ключи"),
Rule(pattern: #"(>|\btee\b|\bcp\b|\bmv\b|\brm\b|\bln\b|\blaunchctl\b|\bplutil\b).*Library/Launch(Agents|Daemons)"#,
reason: "меняет автозапуск (LaunchAgents / LaunchDaemons)"),
Rule(pattern: #"(>|\btee\b|\bcp\b|\bmv\b|\brm\b|sed\s+-i).*(\.claude/settings(\.local)?\.json|NotchBuddy/nb-hook)"#,
reason: "меняет настройки или хуки Claude Code"),
]
static func reasons(tool: String, input: [String: Any]) -> [String] {
var out: [String] = []
if let command = input["command"] as? String {
for rule in shellRules where matches(rule.pattern, command) && !out.contains(rule.reason) {
for rule in shellRules + shellPathRules where matches(rule.pattern, command) && !out.contains(rule.reason) {
out.append(rule.reason)
}
}
+43 -57
View File
@@ -1156,40 +1156,34 @@ final class HookServer: @unchecked Sendable {
// MARK: - Claude Code settings.json hook installer
private var _pendingHooksData: Data?
/// The bytes of settings.json the pending preview was computed from.
private var _pendingHooksOriginal: Data?
/// Returns preview JSON without writing — call writeClaudeHooks() to confirm.
func previewClaudeHooks() throws -> String {
let data = try buildHooksData()
let (data, original) = try buildHooksData()
_pendingHooksData = data
_pendingHooksOriginal = original
return String(data: data, encoding: .utf8) ?? ""
}
/// Writes the hooks to disk (call after user confirms preview).
/// Refused if settings.json changed since the preview, or cannot be backed up.
func writeClaudeHooks() throws {
guard let data = _pendingHooksData else { return }
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json")
// Backup first
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmm"
let stamp = formatter.string(from: Date())
let backupURL = settingsURL.deletingLastPathComponent()
.appendingPathComponent("settings.json.bak-\(stamp)")
try? FileManager.default.copyItem(at: settingsURL, to: backupURL)
try? FileManager.default.createDirectory(at: settingsURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: settingsURL, options: .atomic)
try ClaudeSettingsFile.write(data, to: settingsURL, expecting: _pendingHooksOriginal)
_pendingHooksData = nil
_pendingHooksOriginal = nil
}
private func buildHooksData() throws -> Data {
private func buildHooksData() throws -> (data: Data, original: Data?) {
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json")
var settings: [String: Any] = [:]
if let data = try? Data(contentsOf: settingsURL),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
settings = parsed
}
// Unreadable or invalid settings must stop here, never count as empty.
let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
var settings = snapshot.object
let hookPath = Self.hookScriptPath
#if APPSTORE
// Sandboxed apps create quarantined files; /bin/sh bypasses the quarantine flag
@@ -1206,9 +1200,10 @@ final class HookServer: @unchecked Sendable {
("Stop", 10), ("StopFailure", 10),
("SubagentStart", 10), ("SubagentStop", 10),
]
var hooks = settings["hooks"] as? [String: Any] ?? [:]
// "hooks" in a shape we do not know is refused, never replaced.
var hooks = try ClaudeSettingsFile.hooks(in: settings, name: "settings.json")
for (event, timeout) in events {
var existing = hooks[event] as? [[String: Any]] ?? []
var existing = try ClaudeSettingsFile.hookGroups(in: hooks, event: event, name: "settings.json")
existing.removeAll { ($0["hooks"] as? [[String: Any]])?.contains { ($0["command"] as? String)?.contains("NotchBuddy") == true || ($0["command"] as? String)?.contains("coucou") == true } ?? false }
existing.append(["hooks": [["type": "command", "command": quotedCmd, "timeout": timeout]]])
hooks[event] = existing
@@ -1221,15 +1216,16 @@ final class HookServer: @unchecked Sendable {
])
hooks["PreToolUse"] = preToolUse
settings["hooks"] = hooks
return try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
let data = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
return (data, snapshot.bytes)
}
func uninstallClaudeHooks() throws {
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json")
guard let data = try? Data(contentsOf: settingsURL),
var settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
var hooks = settings["hooks"] as? [String: Any] else { return }
let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
var settings = snapshot.object
guard var hooks = settings["hooks"] as? [String: Any] else { return }
for key in hooks.keys {
if var matchers = hooks[key] as? [[String: Any]] {
@@ -1245,7 +1241,7 @@ final class HookServer: @unchecked Sendable {
}
settings["hooks"] = hooks
let newData = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
try newData.write(to: settingsURL, options: .atomic)
try ClaudeSettingsFile.write(newData, to: settingsURL, expecting: snapshot.bytes)
}
// MARK: - Claude plan status line installer
@@ -1266,6 +1262,8 @@ final class HookServer: @unchecked Sendable {
}
private var _pendingStatusLineData: Data?
/// The bytes of settings.json the pending preview was computed from.
private var _pendingStatusLineOriginal: Data?
private var _pendingPreviousData: Data?
private var _pendingDeletePrevious: Bool = false
@@ -1273,11 +1271,9 @@ final class HookServer: @unchecked Sendable {
func previewStatusLine(install: Bool) throws -> String {
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json")
var settings: [String: Any] = [:]
if let d = try? Data(contentsOf: settingsURL),
let parsed = (try? JSONSerialization.jsonObject(with: d)) as? [String: Any] {
settings = parsed
}
// Unreadable or invalid settings must stop here, never count as empty.
let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
let settings = snapshot.object
let hookPath = Self.hookScriptPath
let quotedPath = hookPath.replacingOccurrences(of: "\"", with: "\\\"")
let quotedCmd = "\"\(quotedPath)\" --statusline"
@@ -1346,6 +1342,7 @@ final class HookServer: @unchecked Sendable {
let data = try JSONSerialization.data(withJSONObject: newSettings,
options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes])
_pendingStatusLineData = data
_pendingStatusLineOriginal = snapshot.bytes
// Build a compact diff: show only the statusLine key before → after
func slJSON(_ val: [String: Any]?) throws -> String {
@@ -1363,15 +1360,7 @@ final class HookServer: @unchecked Sendable {
guard let data = _pendingStatusLineData else { return }
let settingsURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/settings.json")
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmm"
let stamp = formatter.string(from: Date())
let backupURL = settingsURL.deletingLastPathComponent()
.appendingPathComponent("settings.json.bak-\(stamp)")
try? FileManager.default.copyItem(at: settingsURL, to: backupURL)
try? FileManager.default.createDirectory(at: settingsURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: settingsURL, options: .atomic)
try ClaudeSettingsFile.write(data, to: settingsURL, expecting: _pendingStatusLineOriginal)
// Commit side effects only after successful write
if let prevData = _pendingPreviousData {
try? prevData.write(to: statusLinePreviousURL, options: .atomic)
@@ -1380,6 +1369,7 @@ final class HookServer: @unchecked Sendable {
try? FileManager.default.removeItem(at: statusLinePreviousURL)
}
_pendingStatusLineData = nil
_pendingStatusLineOriginal = nil
_pendingPreviousData = nil
_pendingDeletePrevious = false
}
@@ -1390,7 +1380,7 @@ final class HookServer: @unchecked Sendable {
/// Writes nb-hook script and updates settings.json in one shot.
/// claudeURL must be a URL from NSOpenPanel (sandbox access is granted immediately — no security scope needed).
func installAndWriteClaudeHooksAppStore(claudeURL: URL) throws {
let data = try buildHooksData(claudeURL: claudeURL)
let (data, original) = try buildHooksData(claudeURL: claudeURL)
// Write nb-hook (shell wrapper) + nb-hook.py (Python relay) into ~/.claude/coucou/
let coucouDir = claudeURL.appendingPathComponent("coucou")
@@ -1404,19 +1394,15 @@ final class HookServer: @unchecked Sendable {
// Write settings.json (with backup)
let settingsURL = claudeURL.appendingPathComponent("settings.json")
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmm"
let backupURL = claudeURL.appendingPathComponent("settings.json.bak-\(formatter.string(from: Date()))")
try? FileManager.default.copyItem(at: settingsURL, to: backupURL)
try data.write(to: settingsURL, options: .atomic)
try ClaudeSettingsFile.write(data, to: settingsURL, expecting: original)
UserDefaults.standard.set(true, forKey: "coucouHooksInstalled")
}
func uninstallClaudeHooksAppStore(claudeURL: URL) throws {
let settingsURL = claudeURL.appendingPathComponent("settings.json")
guard let data = try? Data(contentsOf: settingsURL),
var settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
var hooks = settings["hooks"] as? [String: Any] else { return }
let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
var settings = snapshot.object
guard var hooks = settings["hooks"] as? [String: Any] else { return }
for key in hooks.keys {
if var matchers = hooks[key] as? [[String: Any]] {
matchers.removeAll { matcher in
@@ -1431,17 +1417,15 @@ final class HookServer: @unchecked Sendable {
}
settings["hooks"] = hooks
let newData = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
try newData.write(to: settingsURL, options: .atomic)
try ClaudeSettingsFile.write(newData, to: settingsURL, expecting: snapshot.bytes)
UserDefaults.standard.set(false, forKey: "coucouHooksInstalled")
}
private func buildHooksData(claudeURL: URL) throws -> Data {
private func buildHooksData(claudeURL: URL) throws -> (data: Data, original: Data?) {
let settingsURL = claudeURL.appendingPathComponent("settings.json")
var settings: [String: Any] = [:]
if let data = try? Data(contentsOf: settingsURL),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
settings = parsed
}
// Unreadable or invalid settings must stop here, never count as empty.
let snapshot = try ClaudeSettingsFile.read(at: settingsURL)
var settings = snapshot.object
// Derive hook path from the panel-selected claudeURL (real ~/.claude, not container)
let hookPath = claudeURL.appendingPathComponent("coucou/nb-hook").path
let quotedCmd = "/bin/sh \"\(hookPath.replacingOccurrences(of: "\"", with: "\\\""))\""
@@ -1454,9 +1438,10 @@ final class HookServer: @unchecked Sendable {
("Stop", 10), ("StopFailure", 10),
("SubagentStart", 10), ("SubagentStop", 10),
]
var hooks = settings["hooks"] as? [String: Any] ?? [:]
// "hooks" in a shape we do not know is refused, never replaced.
var hooks = try ClaudeSettingsFile.hooks(in: settings, name: "settings.json")
for (event, timeout) in events {
var existing = hooks[event] as? [[String: Any]] ?? []
var existing = try ClaudeSettingsFile.hookGroups(in: hooks, event: event, name: "settings.json")
existing.removeAll { ($0["hooks"] as? [[String: Any]])?.contains {
($0["command"] as? String)?.contains("coucou") == true ||
($0["command"] as? String)?.contains("NotchBuddy") == true
@@ -1472,7 +1457,8 @@ final class HookServer: @unchecked Sendable {
])
hooks["PreToolUse"] = preToolUse
settings["hooks"] = hooks
return try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
let data = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys])
return (data, snapshot.bytes)
}
#endif
@@ -20,8 +20,14 @@ final class IslandStateMachine {
/// When non-nil and returns true, timers and mouse-leave never auto-collapse or hide the island.
var isHeldOpen: (() -> Bool)?
/// home → petit delay (seconds). Override for debug.
var homeToPetitDelay: TimeInterval = 15
/// home → petit delay (seconds), kept in sync with the auto-close preference.
var homeToPetitDelay: TimeInterval = 15 {
didSet {
guard homeToPetitDelay != oldValue,
state == .home, homeCollapseWork != nil else { return }
scheduleHomeCollapse()
}
}
/// petit → hidden delay (seconds). Override for debug.
var petitToHiddenDelay: TimeInterval = 60
/// coucou → petit delay after greeting animation ends (no hover). ~0.6s syncs with canvas collapse.
+38 -1
View File
@@ -395,7 +395,7 @@ struct QuestionView: View {
Text(item.question)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(hex: "#F5F6F8"))
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
// Options (wrapping) or "Other…" compact inline row
if curOther {
HStack(spacing: 6) {
@@ -429,6 +429,43 @@ struct QuestionView: View {
.buttonStyle(.plain)
.foregroundColor(Color(hex: "#6B7079"))
}
} else if item.hasDescriptions {
// Options with descriptions: a vertical list, label + description underneath.
VStack(alignment: .leading, spacing: 6) {
ForEach(Array(item.options.enumerated()), id: \.offset) { idx, opt in
let isSelected = curSel.contains(opt.label)
Button {
if isMulti {
toggleSelection(qi: qi, label: opt.label)
} else {
selectAndProceed(q: q, qi: qi, label: opt.label, isLast: isLast)
}
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(opt.label)
.font(.system(size: 12, weight: .medium))
.foregroundColor(isSelected ? Color(hex: "#67E8F9") : Color(hex: "#F5F6F8"))
if !opt.description.isEmpty {
Text(opt.description)
.font(.system(size: 11))
.foregroundColor(Color(hex: "#9AA0A8"))
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10).padding(.vertical, 6)
.background(isSelected ? Color(hex: "#22D3EE").opacity(0.22) : Color.white.opacity(0.07))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(isSelected ? Color(hex: "#22D3EE").opacity(0.55) : Color.white.opacity(0.1), lineWidth: 1))
}
.buttonStyle(.plain)
.keyboardShortcut(KeyEquivalent(Character(String(idx + 1))), modifiers: [])
}
SecondaryButton("Другое…") {
if qi < showOther.count { showOther[qi] = true }
}
}
} else {
ChipFlowLayout(spacing: 6) {
ForEach(Array(item.options.enumerated()), id: \.offset) { idx, opt in
@@ -15,6 +15,7 @@ final class IslandWindowController: NSWindowController {
private var frameTimer: Timer?
private var keyMonitor: Any?
private var viewSubscription: AnyCancellable?
private var autoCloseSubscription: AnyCancellable?
// Confused recovery timer (set by handleDizzy)
private var confusedRecoveryTimer: DispatchWorkItem?
@@ -163,6 +164,11 @@ final class IslandWindowController: NSWindowController {
// MARK: - FSM wiring
private func wireFSM() {
// Apply the persisted auto-close preference immediately and keep live edits in sync.
autoCloseSubscription = state.$autoCloseInterval.sink { [weak self] delay in
self?.fsm.homeToPetitDelay = delay
}
fsm.onTransition = { [weak self] from, to in
guard let self else { return }
switch to {
@@ -387,15 +393,19 @@ final class IslandWindowController: NSWindowController {
state.lastActivity = .now
}
/// `byUser`: the ⌃ button or the toggle hotkey — folds a chat/terminal tab away too;
/// only a pending approval still keeps the island open.
func collapse(byUser: Bool = false) {
if byUser {
guard state.pendingApproval == nil else { return }
} else {
/// `byUser`: the ⌃ button or the toggle hotkey — folds a chat/terminal tab away too.
/// `allowPendingApproval`: the notch's own Escape, jump-to-terminal — may fold the
/// approval card (but not a chat/terminal tab).
/// Folding a pending approval never answers it: the request stays pending, the island
/// stays compact (held open) and a click or ⌃⌥A brings the card back.
func collapse(byUser: Bool = false, allowPendingApproval: Bool = false) {
let onTallTab = state.mode == .expanded && state.view.isTall
let keepsApprovalPending = state.pendingApproval != nil
&& (byUser || (allowPendingApproval && !onTallTab))
if !byUser && !keepsApprovalPending {
guard fsm.isHeldOpen?() != true else { return }
}
state.isPinned = false
if !keepsApprovalPending { state.isPinned = false }
finishedPinTimer?.cancel()
// Keep the FSM in step with what is on screen (home/coucou → petit now).
fsm.collapse()
@@ -418,6 +428,7 @@ final class IslandWindowController: NSWindowController {
collapse(byUser: true)
} else {
islandPanel.makeKey()
fsm.openedExternally()
expand(to: defaultView())
}
@@ -428,6 +439,7 @@ final class IslandWindowController: NSWindowController {
case .goToAlert:
if state.pendingApproval != nil {
islandPanel.makeKey()
fsm.openedExternally()
expand(to: .approval)
} else if state.pendingQuestion != nil {
islandPanel.makeKey()
@@ -535,8 +547,9 @@ final class IslandWindowController: NSWindowController {
// ⎋ Escape — focused views (.onExitCommand) have first crack; fall back to collapse
if event.keyCode == 53 && raw.isEmpty {
let consumed = NSApp.sendAction(Selector(("cancelOperation:")), to: nil, from: nil)
if !consumed && state.mode == .expanded && !state.isPinned {
collapse()
let canCollapse = !state.isPinned || state.pendingApproval != nil
if !consumed && state.mode == .expanded && canCollapse {
collapse(allowPendingApproval: true)
}
return true
}
@@ -590,7 +603,7 @@ final class IslandWindowController: NSWindowController {
NSWorkspace.shared.open(
URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app"))
}
collapse()
collapse(allowPendingApproval: true)
}
private func performAttachFrontWindow() {
@@ -615,6 +628,8 @@ final class IslandWindowController: NSWindowController {
Task { @MainActor in
guard let self = self else { return }
if event.keyCode == 53 { // Escape
// Escape typed in another app (Claude Code's own interrupt, an editor…)
// never folds a pending approval away: only Escape in the notch does.
if self.state.mode == .expanded && !self.state.isPinned {
self.collapse()
}
@@ -1187,6 +1202,10 @@ func islandSize(mode: IslandMode, view: IslandView,
let layout = IslandConst.viewLayouts[view]!
// BroV: the chat has its own width (Settings → Чат).
if view.isTall { return (AppState.shared.chatWidth, layout.height) }
// The question card grows to fit the full question and option descriptions.
if view == .question, let h = QuestionLayout.height {
return (IslandConst.expandedWidth, h)
}
return (IslandConst.expandedWidth, layout.height)
}
}
+81 -107
View File
@@ -3,8 +3,8 @@ import AppKit
// MARK: - Networks (globe in the header)
//
// BroV is the remote for the network core (mihomo). It talks to the core's local REST
// API (address + secret in ~/Library/Application Support/NotchBuddy/netcore.json):
// BroV is the remote for the network core (mihomo, root). It never touches the core's
// config or API secret: every action goes through the narrow root helper netctl.py:
// • left column — the internet exit: group "ai-out" (Авто / Амнезия / each VLESS node)
// • right column — client networks: groups "<client>-sw" switched between REJECT and
// the client's WireGuard tunnel.
@@ -45,64 +45,87 @@ final class NetCore: ObservableObject {
/// Delays measured by the group test (covers the subscription nodes too).
private var measured: [String: Int] = [:]
private var base = ""
private var secret = ""
static let clientInfo: [String: (title: String, subnet: String)] = [
"saga": ("Сага", "192.168.8.0/24"),
"planet9": ("Planet9", "192.168.68.0/24"),
]
private func loadEndpoint() -> Bool {
let url = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/NotchBuddy/netcore.json")
guard let data = try? Data(contentsOf: url),
let j = try? JSONSerialization.jsonObject(with: data) as? [String: String],
let c = j["controller"], let s = j["secret"] else { return false }
base = c; secret = s
return true
// MARK: Root helper (netctl.py)
//
// The core, its config, keys and API secret are root-only. BroV only talks to the
// narrow helper over /var/run/brov-netctl.sock (owner: this user, 0600): state, select,
// delay, tun, add_key, remove_key — nothing that could rewrite the core config.
private nonisolated static let socketPath = "/var/run/brov-netctl.sock"
private func call(_ req: [String: Any], timeout: Int = 12) async -> [String: Any]? {
guard let body = try? JSONSerialization.data(withJSONObject: req) else { return nil }
// Raw bytes cross threads (Sendable); JSON is parsed back here.
let reply: Data? = await withCheckedContinuation { cont in
DispatchQueue.global(qos: .userInitiated).async {
cont.resume(returning: Self.callSync(body, timeout: timeout))
}
}
guard let reply else { return nil }
return try? JSONSerialization.jsonObject(with: reply) as? [String: Any]
}
private func request(_ path: String, method: String = "GET", body: [String: Any]? = nil,
timeout: TimeInterval = 4) async -> Any? {
guard !base.isEmpty || loadEndpoint(),
let url = URL(string: base + path) else { return nil }
var r = URLRequest(url: url, timeoutInterval: timeout)
r.httpMethod = method
r.setValue("Bearer \(secret)", forHTTPHeaderField: "Authorization")
if let body {
r.setValue("application/json", forHTTPHeaderField: "Content-Type")
r.httpBody = try? JSONSerialization.data(withJSONObject: body)
private nonisolated static func callSync(_ body: Data, timeout: Int) -> Data? {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return nil }
defer { close(fd) }
var tv = timeval(tv_sec: timeout, tv_usec: 0)
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
withUnsafeMutablePointer(to: &addr.sun_path) {
$0.withMemoryRebound(to: CChar.self, capacity: 104) { _ = strncpy($0, socketPath, 103) }
}
guard let (data, resp) = try? await URLSession.shared.data(for: r),
let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { return nil }
return data.isEmpty ? [:] : (try? JSONSerialization.jsonObject(with: data)) ?? [:]
let connected = withUnsafePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
connect(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
guard connected == 0 else { return nil }
var data = body
data.append(0x0A)
let sent = data.withUnsafeBytes { send(fd, $0.baseAddress, data.count, 0) }
guard sent == data.count else { return nil }
var out = Data()
var buf = [UInt8](repeating: 0, count: 65536)
while !out.contains(0x0A) {
let n = recv(fd, &buf, buf.count, 0)
if n <= 0 { break }
out.append(contentsOf: buf[0..<n])
}
guard let line = out.split(separator: 0x0A).first else { return nil }
return Data(line)
}
/// Reads groups and last known delays.
func refresh() async {
guard let all = await request("/proxies") as? [String: Any],
let proxies = all["proxies"] as? [String: [String: Any]] else {
guard let st = await call(["cmd": "state"]), st["ok"] as? Bool == true,
let proxies = st["proxies"] as? [String: [String: Any]] else {
running = false
return
}
running = true
if let cfg = await request("/configs") as? [String: Any], let tun = cfg["tun"] as? [String: Any] {
tunOn = tun["enable"] as? Bool ?? false
}
tunOn = st["tun"] as? Bool ?? false
// Provider proxies (VLESS nodes, Amnezia connections) keep their history in the
// provider, not in /proxies.
var providerDelay: [String: Int] = [:]
var awgNames: [String] = []
for prov in ["vless-cluster", "amnezia-keys"] {
guard let p = await request("/providers/proxies/\(prov)") as? [String: Any],
let list = p["proxies"] as? [[String: Any]] else { continue }
let providers = st["providers"] as? [String: [[String: Any]]] ?? [:]
for (prov, list) in providers {
for x in list {
guard let n = x["name"] as? String else { continue }
if prov == "amnezia-keys" { awgNames.append(n) }
if let h = x["history"] as? [[String: Any]], let d = h.last?["delay"] as? Int { providerDelay[n] = d }
}
}
awgNames.sort()
func lastDelay(_ name: String) -> Int? {
if let d = measured[name] { return d }
if let h = proxies[name]?["history"] as? [[String: Any]], let d = h.last?["delay"] as? Int { return d }
@@ -141,108 +164,63 @@ final class NetCore: ObservableObject {
/// Measures every exit and client tunnel (in parallel, inside the core).
func measure() async {
busy = true
let test = "url=https://www.gstatic.com/generate_204&timeout=5000"
if let m = await request("/group/ai-out/delay?\(test)", timeout: 8) as? [String: Int] {
if let m = (await call(["cmd": "delay", "group": "ai-out"], timeout: 15))?["delays"] as? [String: Int] {
measured = m
// Members that didn't answer are missing from the map: mark them dead.
for e in exits where e.id != "auto" && m[e.id] == nil { measured[e.id] = 0 }
}
if let m = await request("/group/amnezia/delay?\(test)", timeout: 8) as? [String: Int] {
if let m = (await call(["cmd": "delay", "group": "amnezia"], timeout: 15))?["delays"] as? [String: Int] {
for (k, v) in m { measured[k] = v }
for c in amneziaConns where c.id != "amnezia-auto" && m[c.id] == nil { measured[c.id] = 0 }
}
for c in clients { _ = await request("/proxies/\(c.id)/delay?\(test)", timeout: 8) }
for c in clients where c.on {
if let m = (await call(["cmd": "delay", "proxy": c.id], timeout: 15))?["delays"] as? [String: Int] {
for (k, v) in m { measured[k] = v }
}
}
await refresh()
busy = false
}
func select(exit name: String) async {
currentExit = name
_ = await request("/proxies/ai-out", method: "PUT", body: ["name": name])
_ = await call(["cmd": "select", "group": "ai-out", "name": name])
SoundEngine.shared.play("blip")
await refresh()
}
func set(client id: String, on: Bool) async {
if let i = clients.firstIndex(where: { $0.id == id }) { clients[i].on = on }
_ = await request("/proxies/\(id)-sw", method: "PUT", body: ["name": on ? id : "REJECT"])
_ = await call(["cmd": "select", "group": "\(id)-sw", "name": on ? id : "REJECT"])
SoundEngine.shared.play(on ? "pop" : "close")
if on { _ = await request("/proxies/\(id)/delay?url=https://www.gstatic.com/generate_204&timeout=5000", timeout: 8) }
if on, let m = (await call(["cmd": "delay", "proxy": id], timeout: 15))?["delays"] as? [String: Int] {
for (k, v) in m { measured[k] = v }
}
await refresh()
}
func select(amnezia name: String) async {
amneziaNow = name
_ = await request("/proxies/amnezia", method: "PUT", body: ["name": name])
if currentExit != "amnezia" { _ = await request("/proxies/ai-out", method: "PUT", body: ["name": "amnezia"]) }
_ = await call(["cmd": "select", "group": "amnezia", "name": name])
if currentExit != "amnezia" { _ = await call(["cmd": "select", "group": "ai-out", "name": "amnezia"]) }
SoundEngine.shared.play("blip")
await refresh()
}
static var coreDir: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/NotchBuddy/netcore")
}
/// Runs gen.py (the single converter for keys → core config) in the core folder.
private nonisolated static func gen(_ args: [String]) async -> String {
await withCheckedContinuation { cont in
DispatchQueue.global(qos: .userInitiated).async {
let p = Process()
p.executableURL = URL(fileURLWithPath: "/usr/bin/python3")
p.arguments = ["gen.py"] + args
p.currentDirectoryURL = coreDir
let out = Pipe()
p.standardOutput = out
p.standardError = out
guard (try? p.run()) != nil else { cont.resume(returning: ""); return }
let data = out.fileHandleForReading.readDataToEndOfFile()
p.waitUntilExit()
cont.resume(returning: String(data: data, encoding: .utf8) ?? "")
}
}
}
/// Checks a pasted vpn:// key, stores it, rebuilds the Amnezia provider and tests it.
/// Returns a message for the add panel.
/// Sends a pasted vpn:// key to the helper (it checks, stores and tests it as root).
func addAmneziaKey(_ text: String) async -> (ok: Bool, message: String) {
let key = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard key.hasPrefix("vpn://") else { return (false, "Ключ должен начинаться с vpn://") }
let keys = Self.coreDir.appendingPathComponent("keys")
let pending = keys.appendingPathComponent(".pending.vpnkey")
do {
try key.write(to: pending, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: pending.path)
} catch { return (false, "Не удалось сохранить ключ: \(error.localizedDescription)") }
let out = await Self.gen(["--check", pending.path])
guard let line = out.split(separator: "\n").last,
let j = try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any],
j["ok"] as? Bool == true else {
try? FileManager.default.removeItem(at: pending)
let err = ((try? JSONSerialization.jsonObject(with: Data((out.split(separator: "\n").last ?? "").utf8))) as? [String: Any])?["error"] as? String
return (false, "Ключ не подходит: \(err ?? "не удалось разобрать")")
guard let r = await call(["cmd": "add_key", "text": key], timeout: 30) else {
return (false, "Помощник сетевого ядра не отвечает.")
}
let name = (j["name"] as? String) ?? "amnezia"
let server = "\(j["server"] as? String ?? "?"):\(j["port"] as? Int ?? 0)"
// File name from the connection name; never overwrite an existing one.
var slug = name.lowercased().map { $0.isLetter || $0.isNumber ? String($0) : "-" }.joined()
if slug.isEmpty { slug = "amnezia" }
var dest = keys.appendingPathComponent("\(slug).vpnkey")
var n = 2
while FileManager.default.fileExists(atPath: dest.path) {
dest = keys.appendingPathComponent("\(slug)-\(n).vpnkey"); n += 1
guard r["ok"] as? Bool == true else {
return (false, "Ключ не подходит: \(r["error"] as? String ?? "неизвестная ошибка")")
}
do { try FileManager.default.moveItem(at: pending, to: dest) }
catch { return (false, "Не удалось сохранить ключ: \(error.localizedDescription)") }
_ = await Self.gen([])
// Live: re-read only the Amnezia provider, the core keeps running.
_ = await request("/providers/proxies/amnezia-keys", method: "PUT", timeout: 8)
let proxy = "AWG " + dest.deletingPathExtension().lastPathComponent
let enc = proxy.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? proxy
let hc = await request("/providers/proxies/amnezia-keys/\(enc)/healthcheck?url=https://www.gstatic.com/generate_204&timeout=6000",
timeout: 10) as? [String: Any]
await refresh()
if let d = hc?["delay"] as? Int, d > 0 {
let name = r["name"] as? String ?? "?"
let server = r["server"] as? String ?? "?"
if let d = r["delay"] as? Int, d > 0 {
SoundEngine.shared.play("finish")
return (true, "✓ «\(name)» подхватился · \(server) · \(d) мс")
}
@@ -250,11 +228,7 @@ final class NetCore: ObservableObject {
}
func removeAmnezia(_ proxyName: String) async {
let file = String(proxyName.dropFirst(4)) + ".vpnkey"
try? FileManager.default.removeItem(at: Self.coreDir.appendingPathComponent("keys").appendingPathComponent(file))
if amneziaNow == proxyName { _ = await request("/proxies/amnezia", method: "PUT", body: ["name": "amnezia-auto"]) }
_ = await Self.gen([])
_ = await request("/providers/proxies/amnezia-keys", method: "PUT", timeout: 8)
_ = await call(["cmd": "remove_key", "name": proxyName])
SoundEngine.shared.play("close")
await refresh()
}
@@ -272,7 +246,7 @@ final class NetCore: ObservableObject {
}
lastError = nil
tunOn = on
_ = await request("/configs", method: "PATCH", body: ["tun": ["enable": on]], timeout: 8)
_ = await call(["cmd": "tun", "on": on])
SoundEngine.shared.play(on ? "pop" : "close")
try? await Task.sleep(for: .seconds(1))
await refresh()
@@ -359,7 +333,7 @@ struct NetPanel: View {
}
if !net.running {
Text("Ядро не отвечает. Если служба ещё не установлена — выключи AmneziaVPN и один раз выполни в Терминале:\nsudo sh ~/Documents/brov-secrets/install-netd.sh\nДальше ядро будет запускаться само при включении Мака.")
Text("Ядро не отвечает. Если служба ещё не установлена — выключи AmneziaVPN и один раз выполни в Терминале:\nsudo sh ~/Documents/work/macbookbrov/brov/scripts/netcore/install.sh\nДальше ядро будет запускаться само при включении Мака.")
.font(.system(size: 11.5))
.foregroundColor(Color(hex: "#B0B5BE"))
.textSelection(.enabled)
@@ -656,7 +656,6 @@ struct SettingsView: View {
Text("Спрашивать в вырезе").tag("default")
Text("Принимать правки").tag("acceptEdits")
Text("Только план").tag("plan")
Text("Без проверок (опасно)").tag("bypassPermissions")
}
TextField("Путь к claude (пусто = авто)", text: $state.claudeBinaryPath)
.textFieldStyle(.roundedBorder)
+5 -1
View File
@@ -158,8 +158,12 @@ enum SelectionGrabber {
}
guard pb.changeCount != before else { return nil }
let text = pb.string(forType: .string)
// Restore what the user had copied.
// Restore what the user had copied — except secrets: password managers mark them
// concealed/transient and clear them on their own; putting them back would defeat that.
pb.clearContents()
let secretTypes: Set<String> = ["org.nspasteboard.ConcealedType", "org.nspasteboard.TransientType",
"org.nspasteboard.AutoGeneratedType", "com.agilebits.onepassword"]
if saved.contains(where: { $0.keys.contains { secretTypes.contains($0.rawValue) } }) { return text }
let items = saved.map { dict -> NSPasteboardItem in
let it = NSPasteboardItem()
for (t, v) in dict { it.setData(v, forType: t) }
+5 -1
View File
@@ -14,7 +14,11 @@ configs:
settings:
base:
SWIFT_VERSION: "6.0"
ENABLE_HARDENED_RUNTIME: NO
# Hardened runtime, no get-task-allow: other processes can't inject code into BroV
# and borrow its Accessibility / Apple Events permissions.
ENABLE_HARDENED_RUNTIME: YES
CODE_SIGN_INJECT_BASE_ENTITLEMENTS: NO
CODE_SIGN_ENTITLEMENTS: Resources/BroV.entitlements
OTHER_SWIFT_FLAGS: "-strict-concurrency=complete"
# Ad-hoc signature: runs locally, no Apple Developer account needed.
CODE_SIGN_STYLE: Manual