diff --git a/.gitignore b/.gitignore index a475ef7..30680da 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,11 @@ DerivedData/ # Local only .claude/ -# Secrets never live in the repo (kept in ~/Documents/brov-secrets) +# Secrets never live in the repo (root core folder, ~/.brov-secrets) *.conf guide-*.md brov-secrets/ +*.vpnkey +api.secret +netcore.json +config.yaml diff --git a/CLAUDE.md b/CLAUDE.md index 3205d0e..330b8ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,10 +19,17 @@ DerivedData must stay outside ~/Documents (iCloud adds Finder attributes and cod ## Rules - Personal use only: never publish to GitHub or any public place. The original Coucou name, Mochi character and icon are not used in BroV. -- Swift 6, SwiftUI + AppKit, no third-party dependencies. The character is drawn in code (`Canvas` + `TimelineView`). -- Secrets live in the Keychain, never on disk or in git. +- Swift 6, SwiftUI + AppKit. One dependency: SwiftTerm (term.macOS tabs). The character is 11 Memoji images (`Resources/memoji`) moved by `BotEngine` at 30 fps. +- Secrets live in the Keychain or the root-only network core folder, never in git. - Never block Claude Code: if the app doesn't answer, the hook exits immediately. - Never overwrite `~/.claude/settings.json`: dated backup, merge, write only after the user confirms. - Never approve a Claude Code permission without an explicit click. - When spawning `claude`, strip `CLAUDECODE` from the environment and close stdin. - Pill IDs are stable contract values: never rename an existing pill ID. + +## Network core (globe 🌐 in the header) +- mihomo runs as root: LaunchDaemon `local.maksar.brov.netd`, binary + config + keys + API secret in `/Library/Application Support/BroV/` (root, 0700). Nothing user-writable is read by root. +- BroV never sees the config or the API secret: it talks to the narrow root helper `scripts/netcore/netctl.py` (LaunchDaemon `local.maksar.brov.netctl`, socket `/var/run/brov-netctl.sock`, only the installing user) — commands: state, select, delay, tun, add_key, remove_key. +- `scripts/netcore/gen.py` builds the config (Amnezia keys → provider `keys/amnezia.yaml`, WireGuard clients, VLESS subscription). Install/update: `sudo sh scripts/netcore/install.sh` (verifies the mihomo SHA256, migrates keys). Never commit keys, configs or the guide (`~/.brov-secrets`). +- Reloading the whole core config drops every connection (including this chat); group switches and provider reloads don't. + diff --git a/NotchBuddy/Resources/BroV.entitlements b/NotchBuddy/Resources/BroV.entitlements new file mode 100644 index 0000000..36756c8 --- /dev/null +++ b/NotchBuddy/Resources/BroV.entitlements @@ -0,0 +1,9 @@ + + + + + + com.apple.security.automation.apple-events + + + diff --git a/NotchBuddy/Sources/App/AppDelegate.swift b/NotchBuddy/Sources/App/AppDelegate.swift index df080ff..45084aa 100644 --- a/NotchBuddy/Sources/App/AppDelegate.swift +++ b/NotchBuddy/Sources/App/AppDelegate.swift @@ -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) } diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index b8679e8..65a8df1 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -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 diff --git a/NotchBuddy/Sources/App/AskQuestion.swift b/NotchBuddy/Sources/App/AskQuestion.swift index e3e7266..c599268 100644 --- a/NotchBuddy/Sources/App/AskQuestion.swift +++ b/NotchBuddy/Sources/App/AskQuestion.swift @@ -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? { diff --git a/NotchBuddy/Sources/App/ClaudeCodeCLI.swift b/NotchBuddy/Sources/App/ClaudeCodeCLI.swift index a627431..3d330ec 100644 --- a/NotchBuddy/Sources/App/ClaudeCodeCLI.swift +++ b/NotchBuddy/Sources/App/ClaudeCodeCLI.swift @@ -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 = ["acceptEdits", "plan"] + if allowedModes.contains(permissionMode) { args += ["--permission-mode", permissionMode] } let p = Process() p.executableURL = URL(fileURLWithPath: binary) diff --git a/NotchBuddy/Sources/App/ClaudeSettingsFile.swift b/NotchBuddy/Sources/App/ClaudeSettingsFile.swift new file mode 100644 index 0000000..41f48d7 --- /dev/null +++ b/NotchBuddy/Sources/App/ClaudeSettingsFile.swift @@ -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 + } +} diff --git a/NotchBuddy/Sources/App/DangerCheck.swift b/NotchBuddy/Sources/App/DangerCheck.swift index fab51b5..0d4d332 100644 --- a/NotchBuddy/Sources/App/DangerCheck.swift +++ b/NotchBuddy/Sources/App/DangerCheck.swift @@ -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) } } diff --git a/NotchBuddy/Sources/App/HookServer.swift b/NotchBuddy/Sources/App/HookServer.swift index 2497802..7655f2a 100644 --- a/NotchBuddy/Sources/App/HookServer.swift +++ b/NotchBuddy/Sources/App/HookServer.swift @@ -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 diff --git a/NotchBuddy/Sources/App/IslandStateMachine.swift b/NotchBuddy/Sources/App/IslandStateMachine.swift index a8fe331..178dcf0 100644 --- a/NotchBuddy/Sources/App/IslandStateMachine.swift +++ b/NotchBuddy/Sources/App/IslandStateMachine.swift @@ -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. diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift index eb0869b..404cacc 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -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 diff --git a/NotchBuddy/Sources/App/IslandWindowController.swift b/NotchBuddy/Sources/App/IslandWindowController.swift index a1bfa5e..8135844 100644 --- a/NotchBuddy/Sources/App/IslandWindowController.swift +++ b/NotchBuddy/Sources/App/IslandWindowController.swift @@ -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) } } diff --git a/NotchBuddy/Sources/App/NetPanel.swift b/NotchBuddy/Sources/App/NetPanel.swift index d896c90..303db09 100644 --- a/NotchBuddy/Sources/App/NetPanel.swift +++ b/NotchBuddy/Sources/App/NetPanel.swift @@ -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 "-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.size)) + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout.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.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.. 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) diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index a8063e9..fc8d65a 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -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) diff --git a/NotchBuddy/Sources/App/Translator.swift b/NotchBuddy/Sources/App/Translator.swift index 7c150e1..96229ff 100644 --- a/NotchBuddy/Sources/App/Translator.swift +++ b/NotchBuddy/Sources/App/Translator.swift @@ -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 = ["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) } diff --git a/NotchBuddy/project.yml b/NotchBuddy/project.yml index 41bb6a1..9dc0665 100644 --- a/NotchBuddy/project.yml +++ b/NotchBuddy/project.yml @@ -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 diff --git a/scripts/netcore/gen.py b/scripts/netcore/gen.py new file mode 100755 index 0000000..3a15ab5 --- /dev/null +++ b/scripts/netcore/gen.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""BroV network core prototype: builds core/config.yaml for mihomo from src/*. + +keys/*.vpnkey one vpn:// key per Amnezia connection (AmneziaWG 2/3); BroV adds them + and rewrites keys/amnezia.yaml itself, gen.py does the same on a full build +src/saga.conf WireGuard client config -> only 192.168.8.0/24 +src/planet9.conf WireGuard client config -> only 192.168.68.0/24 +src/vless.sub 3x-ui subscription URL (all VLESS nodes) + +Secrets stay in this private folder; nothing here goes to git. +""" +import base64, json, os, re, secrets, zlib, configparser + +HERE = os.path.dirname(os.path.abspath(__file__)) +SRC = os.path.join(HERE, "src") +KEYS = os.path.join(HERE, "keys") +# Installed next to its inputs in /Library/Application Support/BroV/netcore (root, 0700): +# the root core reads its config from here, nothing user-writable is involved. +CORE = HERE + +HOME_NET = "192.168.10.0/24" +CLIENTS = { # name: (conf file, routed subnets) + "saga": ("saga.conf", ["192.168.8.0/24", "10.0.0.0/24"]), + "planet9": ("planet9.conf", ["192.168.68.0/24", "172.3.3.0/24"]), +} +AI_DOMAINS = ["anthropic.com", "claude.ai", "claude.com", "openai.com", "chatgpt.com", + "oaistatic.com", "oaiusercontent.com", "github.com", "githubusercontent.com"] + + +def mid(v, default): + """'100-120' -> 110 (mihomo takes single ints for timers).""" + if v is None or v == "": + return default + m = re.match(r"^\s*(\d+)\s*-\s*(\d+)\s*$", str(v)) + return (int(m.group(1)) + int(m.group(2))) // 2 if m else int(v) + + +def amnezia(path, name): + key = open(path).read().strip()[len("vpn://"):] + key += "=" * (-len(key) % 4) + j = json.loads(zlib.decompress(base64.urlsafe_b64decode(key)[4:])) + awg = j["containers"][0]["awg"] + c = awg["last_config"] if isinstance(awg["last_config"], dict) else json.loads(awg["last_config"]) + ver = 3 if c.get("HeaderProtectionKey") else 2 + opt = {"version": ver, "jc": int(c["Jc"]), "jmin": int(c["Jmin"]), "jmax": int(c["Jmax"]), + "s1": int(c["S1"]), "s2": int(c["S2"])} + for k in ("S3", "S4"): + if c.get(k): + opt[k.lower()] = int(c[k]) + for k in ("H1", "H2", "H3", "H4"): + v = c[k] + opt[k.lower()] = int(v) if str(v).isdigit() else v + for k in ("I1", "I2", "I3", "I4", "I5"): + if c.get(k): + opt[k.lower()] = c[k] + if ver == 3: + opt.update({ + "header-protection-key": c["HeaderProtectionKey"], + "content-padding-addition": c.get("ContentPaddingAddition", "0"), + "rekey-after-time": mid(c.get("RekeyAfterTime"), 120), + "rekey-timeout": mid(c.get("RekeyTimeout"), 5), + "reject-after-time": mid(c.get("RejectAfterTime"), 180), + "keepalive-timeout": mid(c.get("KeepaliveTimeout"), 10), + "max-handshake-attempts": mid(c.get("MaxHandshakeAttempts"), 18), + "random-trailers": c.get("RandomTrailers") == "on", + "disable-cookies": c.get("DisableCookies") == "on", + }) + return { + "name": name, "type": "wireguard", "server": c["hostName"], "port": int(c["port"]), + "ip": c["client_ip"], "private-key": c["client_priv_key"], "public-key": c["server_pub_key"], + "pre-shared-key": c.get("psk_key") or None, "mtu": int(c.get("mtu", 1376)), "udp": True, + "persistent-keepalive": mid(c.get("persistent_keep_alive"), 25), + "amnezia-wg-option": opt, + } + + +def wg(name, path): + p = configparser.ConfigParser() + p.optionxform = str + p.read(os.path.join(SRC, path)) + i, peer = p["Interface"], p["Peer"] + host, port = peer["Endpoint"].rsplit(":", 1) + out = {"name": name, "type": "wireguard", "server": host, "port": int(port), + "ip": i["Address"].split("/")[0], "private-key": i["PrivateKey"], + "public-key": peer["PublicKey"], "mtu": int(i.get("MTU", 1420)), "udp": True} + if peer.get("PresharedKey"): + out["pre-shared-key"] = peer["PresharedKey"] + if peer.get("PersistentKeepalive"): + out["persistent-keepalive"] = int(peer["PersistentKeepalive"]) + return out + + +def y(v, ind=0): + """Tiny YAML emitter (no PyYAML dependency).""" + pad = " " * ind + if isinstance(v, dict): + lines = [] + for k, x in v.items(): + if x is None: + continue + if isinstance(x, (dict, list)) and x: + lines.append(f"{pad}{k}:\n{y(x, ind + 1)}") + else: + lines.append(f"{pad}{k}: {scalar(x)}") + return "\n".join(lines) + if isinstance(v, list): + lines = [] + for x in v: + if isinstance(x, dict): + body = y(x, ind + 1).lstrip() + lines.append(f"{pad}- {body}") + else: + lines.append(f"{pad}- {scalar(x)}") + return "\n".join(lines) + return pad + scalar(v) + + +def scalar(x): + if isinstance(x, bool): + return "true" if x else "false" + if isinstance(x, (int, float)): + return str(x) + if isinstance(x, list) and not x: + return "[]" + return json.dumps(str(x), ensure_ascii=False) + + +def main(): + os.makedirs(CORE, exist_ok=True) + secret_file = os.path.join(CORE, "api.secret") + if not os.path.exists(secret_file): + fd = os.open(secret_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as f: + f.write(secrets.token_urlsafe(24)) + api_secret = open(secret_file).read().strip() + + # Amnezia connections live in their own provider file so BroV can add keys live. + keyfiles = sorted(f for f in os.listdir(KEYS) if f.endswith(".vpnkey")) + awg = [amnezia(os.path.join(KEYS, f), "AWG " + f[:-len(".vpnkey")]) for f in keyfiles] + prov = os.path.join(KEYS, "amnezia.yaml") + with open(os.open(prov, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as f: + f.write(y({"proxies": awg}) + "\n") + proxies = [wg(n, f) for n, (f, _) in CLIENTS.items()] + sub = open(os.path.join(SRC, "vless.sub")).read().strip() + + rules = [f"IP-CIDR,{HOME_NET},DIRECT,no-resolve", "IP-CIDR,127.0.0.0/8,DIRECT,no-resolve", + # Home's own public IP (RustDesk, Gitea): never via a foreign exit. + "IP-CIDR,79.111.14.0/32,DIRECT,no-resolve", "DOMAIN-SUFFIX,sanjeev.ru,DIRECT"] + for name, (_, nets) in CLIENTS.items(): + # Through a switch group: BroV turns client networks on/off without a reload. + rules += [f"IP-CIDR,{n},{name}-sw,no-resolve" for n in nets] + rules += [f"DOMAIN-SUFFIX,{d},ai-out" for d in AI_DOMAINS] + rules += ["DOMAIN-SUFFIX,ru,DIRECT", "DOMAIN-SUFFIX,su,DIRECT", "DOMAIN-SUFFIX,xn--p1ai,DIRECT", + "MATCH,ai-out"] + + cfg = { + "mixed-port": 7890, "allow-lan": False, "mode": "rule", "log-level": "error", "ipv6": False, + "external-controller": "127.0.0.1:9097", "secret": api_secret, "unified-delay": True, + "find-process-mode": "strict", + "profile": {"store-selected": True}, + "tun": {"enable": True, "stack": "mixed", "auto-route": True, "auto-detect-interface": True, + "dns-hijack": ["any:53"], "mtu": 1400}, + "dns": {"enable": True, "ipv6": False, "enhanced-mode": "fake-ip", "fake-ip-range": "198.18.0.1/16", + "fake-ip-filter": ["*.lan", "*.local", "+.duckdns.org"], + "default-nameserver": ["77.88.8.8", "1.1.1.1"], + "proxy-server-nameserver": ["77.88.8.8", "1.1.1.1"], + "nameserver": ["https://1.1.1.1/dns-query#ai-out", "https://8.8.8.8/dns-query#ai-out"], + "direct-nameserver": ["77.88.8.8", "77.88.8.1"]}, + "proxies": proxies, + "proxy-providers": { + "amnezia-keys": {"type": "file", "path": "./keys/amnezia.yaml", + "health-check": {"enable": True, "url": "https://www.gstatic.com/generate_204", + "interval": 300}}, + "vless-cluster": { + # Fetch the list directly: the nodes themselves are dialled directly anyway. + "type": "http", "url": sub, "interval": 43200, "path": "./providers/vless.yaml", "proxy": "DIRECT", + "health-check": {"enable": True, "url": "https://www.gstatic.com/generate_204", "interval": 300}}}, + "proxy-groups": [ + # What BroV's globe panel switches: "auto", the Amnezia group, or one VLESS node. + {"name": "ai-out", "type": "select", "proxies": ["auto", "amnezia"], "use": ["vless-cluster"]}, + # Fastest alive exit among every Amnezia connection and every VLESS node; + # switches only when another one is 100+ ms faster. + {"name": "auto", "type": "url-test", "use": ["amnezia-keys", "vless-cluster"], + "url": "https://www.gstatic.com/generate_204", "interval": 120, "tolerance": 100, "lazy": False}, + # Amnezia: "amnezia-auto" (fastest connection) or one fixed connection. + {"name": "amnezia", "type": "select", "proxies": ["amnezia-auto"], "use": ["amnezia-keys"]}, + {"name": "amnezia-auto", "type": "url-test", "use": ["amnezia-keys"], + "url": "https://www.gstatic.com/generate_204", "interval": 120, "tolerance": 100, "lazy": False}, + ] + [ + # Client networks: off (REJECT) until switched on in BroV. + {"name": f"{n}-sw", "type": "select", "proxies": ["REJECT", n]} for n in CLIENTS + ], + "rules": rules, + } + # The API secret never leaves this root-only folder: BroV goes through netctl.py. + path = os.path.join(CORE, "config.yaml") + with open(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as f: + f.write("# Generated by gen.py — do not edit by hand, do not share.\n" + y(cfg) + "\n") + print("wrote", path, "|", len(proxies), "proxies + vless subscription |", len(rules), "rules") + + +def check(path): + """--check : is this a usable Amnezia key? Prints JSON for BroV.""" + try: + raw = open(path).read().strip() + if not raw.startswith("vpn://"): + raise ValueError("ключ должен начинаться с vpn://") + key = raw[len("vpn://"):] + key += "=" * (-len(key) % 4) + j = json.loads(zlib.decompress(base64.urlsafe_b64decode(key)[4:])) + cont = j["containers"][0] + if "awg" not in cont: + raise ValueError("это не AmneziaWG (контейнер %s) — пока поддерживается только AmneziaWG" % cont.get("container")) + p = amnezia(path, "check") + print(json.dumps({"ok": True, "name": j.get("description") or p["server"], "server": p["server"], + "port": p["port"], "version": p["amnezia-wg-option"]["version"]}, ensure_ascii=False)) + except Exception as e: + print(json.dumps({"ok": False, "error": str(e) or e.__class__.__name__}, ensure_ascii=False)) + + +if __name__ == "__main__": + import sys + if len(sys.argv) == 3 and sys.argv[1] == "--check": + check(sys.argv[2]) + else: + main() diff --git a/scripts/netcore/install.sh b/scripts/netcore/install.sh new file mode 100644 index 0000000..d697033 --- /dev/null +++ b/scripts/netcore/install.sh @@ -0,0 +1,140 @@ +#!/bin/sh +# BroV network core — install / update as root services (run by hand, once per update): +# sudo sh scripts/netcore/install.sh +# +# Layout after install (everything root-owned, nothing a user process can change): +# /Library/Application Support/BroV/mihomo the core, checked against the release SHA256 +# /Library/Application Support/BroV/netctl.py narrow helper BroV talks to +# /Library/Application Support/BroV/netcore/ config, gen.py, keys, API secret (0700) +# /Library/LaunchDaemons/local.maksar.brov.netd.plist the core, at boot, restarted on crash +# /Library/LaunchDaemons/local.maksar.brov.netctl.plist the helper (socket /var/run/brov-netctl.sock, +# only your user may connect) +# First run migrates keys from ~/Library/Application Support/NotchBuddy/netcore and then +# deletes that user-writable copy (it is what made root trust user files). +# +# Undo: sudo sh scripts/netcore/uninstall.sh + +set -e + +[ "$(id -u)" -eq 0 ] || { echo "Запусти через sudo: sudo sh $0"; exit 1; } +USER_NAME="${SUDO_USER:?запусти через sudo из своей учётной записи}" +USER_UID=$(id -u "$USER_NAME") +USER_HOME=$(dscl . -read "/Users/$USER_NAME" NFSHomeDirectory | awk '{print $2}') + +HERE=$(cd "$(dirname "$0")" && pwd) +ROOT="/Library/Application Support/BroV" +CORE="$ROOT/netcore" +OLD="$USER_HOME/Library/Application Support/NotchBuddy/netcore" +BIN="$ROOT/mihomo" +CORE_LABEL="local.maksar.brov.netd" +CTL_LABEL="local.maksar.brov.netctl" + +MIHOMO_VERSION="v1.19.32" +MIHOMO_GZ_SHA="3312a6780652c622890fd4357c6a853bbf865464fd047ac7b7f52dab8de18652" +MIHOMO_BIN_SHA="94a386ec0149080deadd86b1f667363bde3c70f7489dba3258e52c56fc9a6d66" + +if pgrep -qx AmneziaVPN; then + echo "Приложение AmneziaVPN запущено — закрой его (только приложение, служба не мешает)." + exit 1 +fi + +umask 077 +install -d -m 755 -o root -g wheel "$ROOT" +install -d -m 700 -o root -g wheel "$CORE" "$CORE/keys" "$CORE/src" "$CORE/providers" +install -d -m 755 -o root -g wheel /Library/Logs/BroV + +# 1. The core binary: keep the installed one if it matches, else fetch and verify. +if [ "$(shasum -a 256 "$BIN" 2>/dev/null | cut -d' ' -f1)" = "$MIHOMO_BIN_SHA" ]; then + echo "✓ mihomo $MIHOMO_VERSION на месте, контрольная сумма совпадает" +else + echo "→ Скачиваю mihomo $MIHOMO_VERSION с GitHub и проверяю SHA256" + TMP=$(mktemp -d) + curl -fsSL -o "$TMP/m.gz" "https://github.com/MetaCubeX/mihomo/releases/download/$MIHOMO_VERSION/mihomo-darwin-arm64-$MIHOMO_VERSION.gz" + [ "$(shasum -a 256 "$TMP/m.gz" | cut -d' ' -f1)" = "$MIHOMO_GZ_SHA" ] || { echo "✗ архив не совпал с официальной суммой"; rm -rf "$TMP"; exit 1; } + gunzip -c "$TMP/m.gz" > "$TMP/mihomo" + install -m 755 -o root -g wheel "$TMP/mihomo" "$BIN" + rm -rf "$TMP" +fi + +# 2. Helper scripts from the repo (no secrets in them). +install -m 700 -o root -g wheel "$HERE/gen.py" "$CORE/gen.py" +install -m 755 -o root -g wheel "$HERE/netctl.py" "$ROOT/netctl.py" + +# 3. Migrate secrets from the old user folder — regular files only, never symlinks. +copy_regular() { # src dst + [ -f "$1" ] && [ ! -L "$1" ] && install -m 600 -o root -g wheel "$1" "$2" + return 0 +} +if [ -d "$OLD" ] && [ ! -L "$OLD" ]; then + echo "→ Переношу ключи и настройки в $CORE" + for f in "$OLD"/keys/*.vpnkey; do copy_regular "$f" "$CORE/keys/$(basename "$f")"; done + for f in "$OLD"/src/*; do copy_regular "$f" "$CORE/src/$(basename "$f")"; done + [ -f "$CORE/api.secret" ] || copy_regular "$OLD/api.secret" "$CORE/api.secret" + [ -f "$CORE/cache.db" ] || copy_regular "$OLD/cache.db" "$CORE/cache.db" + copy_regular "$OLD/providers/vless.yaml" "$CORE/providers/vless.yaml" +fi +ls "$CORE"/keys/*.vpnkey >/dev/null 2>&1 || { echo "✗ нет ни одного ключа Амнезии в $CORE/keys"; exit 1; } +for f in saga.conf planet9.conf vless.sub; do + [ -f "$CORE/src/$f" ] || { echo "✗ нет $CORE/src/$f"; exit 1; } +done +chown -R root:wheel "$CORE" +chmod -R go-rwx "$CORE" + +# 4. Build and check the config as root. +echo "→ Собираю конфиг" +(cd "$CORE" && /usr/bin/python3 gen.py) +"$BIN" -t -d "$CORE" -f "$CORE/config.yaml" >/dev/null +: > /Library/Logs/BroV/netcore.log +chmod 600 /Library/Logs/BroV/netcore.log + +# 5. Services. +write_plist() { # label, then program arguments + label=$1; shift + { + echo '' + echo '' + echo '' + echo " Label$label" + echo ' ProgramArguments' + for a in "$@"; do echo " $a"; done + echo ' ' + echo ' RunAtLoad' + echo ' KeepAlive' + echo ' ThrottleInterval5' + echo " StandardOutPath/Library/Logs/BroV/$label.log" + echo " StandardErrorPath/Library/Logs/BroV/$label.log" + echo '' + } > "/Library/LaunchDaemons/$label.plist" + chown root:wheel "/Library/LaunchDaemons/$label.plist" + chmod 644 "/Library/LaunchDaemons/$label.plist" + plutil -lint "/Library/LaunchDaemons/$label.plist" >/dev/null +} +write_plist "$CORE_LABEL" "$BIN" -d "$CORE" -f "$CORE/config.yaml" +write_plist "$CTL_LABEL" /usr/bin/python3 "$ROOT/netctl.py" "$USER_UID" +rm -f /Library/Logs/BroV/netcore.log + +echo "→ Запускаю службы" +for label in "$CORE_LABEL" "$CTL_LABEL"; do + launchctl bootout "system/$label" 2>/dev/null || true +done +sleep 1 +for label in "$CORE_LABEL" "$CTL_LABEL"; do + launchctl bootstrap system "/Library/LaunchDaemons/$label.plist" 2>/dev/null || launchctl kickstart -k "system/$label" +done + +i=0 +until [ -S /var/run/brov-netctl.sock ] || [ $i -ge 20 ]; do sleep 0.5; i=$((i+1)); done +if launchctl print "system/$CORE_LABEL" | grep -q 'state = running' && [ -S /var/run/brov-netctl.sock ]; then + echo "✓ Ядро и помощник работают." +else + echo "⚠ Что-то не поднялось. Логи: /Library/Logs/BroV/" + exit 1 +fi + +# 6. Remove the old user-writable copy (keys, secret, binary) — root no longer reads it. +if [ -d "$OLD" ] && [ ! -L "$OLD" ]; then + rm -rf "$OLD" + echo "✓ Старая копия ключей в ~/Library/Application Support/NotchBuddy/netcore удалена" +fi +rm -f "$USER_HOME/Library/Application Support/NotchBuddy/netcore.json" +echo "Готово. Управление — глобус 🌐 в чёлке BroV." diff --git a/scripts/netcore/netctl.py b/scripts/netcore/netctl.py new file mode 100644 index 0000000..505b4b2 --- /dev/null +++ b/scripts/netcore/netctl.py @@ -0,0 +1,235 @@ +#!/usr/bin/python3 +"""BroV network core — narrow root helper (LaunchDaemon local.maksar.brov.netctl). + +The core (mihomo) runs as root with its config, keys and API secret in +/Library/Application Support/BroV/netcore (root, 0700). BroV never sees those: it talks +to this helper over a Unix socket that only the installing user may open, and the helper +allows exactly these operations: + + state groups, provider nodes with delays, TUN on/off + select {group, name} ai-out / amnezia / saga-sw / planet9-sw, name must be a member + delay {group}|{proxy} speed test of ai-out / amnezia, or of the saga / planet9 tunnel + tun {on} traffic capture on/off + add_key {text} vpn:// Amnezia key: checked by gen.py, stored, provider reloaded + remove_key {name} "AWG " connection + +One JSON object per line in, one per line out. Nothing here can rewrite the core config +or point traffic elsewhere. +""" +import json +import os +import re +import socket +import struct +import subprocess +import sys +import threading +import urllib.error +import urllib.parse +import urllib.request + +ROOT = "/Library/Application Support/BroV" +CORE = os.path.join(ROOT, "netcore") +KEYS = os.path.join(CORE, "keys") +SOCK = "/var/run/brov-netctl.sock" +API = "http://127.0.0.1:9097" +TEST_URL = "https://www.gstatic.com/generate_204" + +SELECT_GROUPS = {"ai-out", "amnezia", "saga-sw", "planet9-sw"} +DELAY_GROUPS = {"ai-out", "amnezia"} +CLIENT_TUNNELS = {"saga", "planet9"} +MAX_REQUEST = 64 * 1024 + +ALLOWED_UID = int(sys.argv[1]) if len(sys.argv) > 1 else -1 +gen_lock = threading.Lock() + + +def secret(): + with open(os.path.join(CORE, "api.secret")) as f: + return f.read().strip() + + +def api(method, path, body=None, timeout=8): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(API + path, method=method, data=data, headers={ + "Authorization": "Bearer " + secret(), "Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=timeout) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def q(name): + return urllib.parse.quote(name, safe="") + + +def gen(*args): + return subprocess.run(["/usr/bin/python3", os.path.join(CORE, "gen.py"), *args], cwd=CORE, + capture_output=True, text=True, timeout=60).stdout + + +# MARK: - Commands + +def cmd_state(_): + proxies = api("GET", "/proxies").get("proxies", {}) + keep = {} + for name, p in proxies.items(): + keep[name] = {"now": p.get("now"), "all": p.get("all"), "history": (p.get("history") or [])[-1:]} + providers = {} + for prov in ("vless-cluster", "amnezia-keys"): + try: + lst = api("GET", "/providers/proxies/" + prov).get("proxies", []) + except Exception: + lst = [] + providers[prov] = [{"name": x.get("name"), "history": (x.get("history") or [])[-1:]} for x in lst] + tun = api("GET", "/configs").get("tun", {}).get("enable", False) + return {"ok": True, "proxies": keep, "providers": providers, "tun": tun} + + +def cmd_select(r): + group, name = r.get("group"), r.get("name") + if group not in SELECT_GROUPS or not isinstance(name, str): + return {"ok": False, "error": "группа не разрешена"} + members = api("GET", "/proxies/" + q(group)).get("all", []) + if name not in members: + return {"ok": False, "error": "такого варианта нет в группе"} + api("PUT", "/proxies/" + q(group), {"name": name}) + return {"ok": True} + + +def cmd_delay(r): + test = "url=" + q(TEST_URL) + "&timeout=5000" + if r.get("group") in DELAY_GROUPS: + return {"ok": True, "delays": api("GET", "/group/%s/delay?%s" % (q(r["group"]), test), timeout=10)} + if r.get("proxy") in CLIENT_TUNNELS: + try: + d = api("GET", "/proxies/%s/delay?%s" % (q(r["proxy"]), test), timeout=10).get("delay", 0) + except Exception: + d = 0 + return {"ok": True, "delays": {r["proxy"]: d}} + return {"ok": False, "error": "замер не разрешён"} + + +def cmd_tun(r): + on = r.get("on") + if not isinstance(on, bool): + return {"ok": False, "error": "нужно on: true/false"} + api("PATCH", "/configs", {"tun": {"enable": on}}) + return {"ok": True} + + +def provider_reload_and_test(proxy): + api("PUT", "/providers/proxies/amnezia-keys") + try: + hc = api("GET", "/providers/proxies/amnezia-keys/%s/healthcheck?url=%s&timeout=6000" + % (q(proxy), q(TEST_URL)), timeout=10) + return hc.get("delay", 0) + except Exception: + return 0 + + +def cmd_add_key(r): + text = r.get("text") + if not isinstance(text, str) or not text.strip().startswith("vpn://") or len(text) > 20000: + return {"ok": False, "error": "ключ должен начинаться с vpn://"} + with gen_lock: + pending = os.path.join(KEYS, ".pending.vpnkey") + fd = os.open(pending, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "w") as f: + f.write(text.strip()) + try: + check = json.loads(gen("--check", pending).strip().splitlines()[-1]) + except Exception: + check = {"ok": False, "error": "не удалось разобрать ключ"} + if not check.get("ok"): + os.remove(pending) + return {"ok": False, "error": check.get("error", "ключ не подходит")} + slug = re.sub(r"[^a-z0-9]+", "-", str(check.get("name", "amnezia")).lower()).strip("-") or "amnezia" + dest, n = os.path.join(KEYS, slug + ".vpnkey"), 2 + while os.path.exists(dest): + dest, n = os.path.join(KEYS, "%s-%d.vpnkey" % (slug, n)), n + 1 + os.rename(pending, dest) + gen() + proxy = "AWG " + os.path.basename(dest)[:-len(".vpnkey")] + return {"ok": True, "name": check.get("name"), "server": "%s:%s" % (check.get("server"), check.get("port")), + "proxy": proxy, "delay": provider_reload_and_test(proxy)} + + +def cmd_remove_key(r): + name = r.get("name", "") + m = re.fullmatch(r"AWG ([a-z0-9-]+)", name) if isinstance(name, str) else None + if not m: + return {"ok": False, "error": "неверное имя подключения"} + path = os.path.join(KEYS, m.group(1) + ".vpnkey") + if not os.path.isfile(path) or os.path.islink(path): + return {"ok": False, "error": "такого подключения нет"} + with gen_lock: + if api("GET", "/proxies/amnezia").get("now") == name: + api("PUT", "/proxies/amnezia", {"name": "amnezia-auto"}) + os.remove(path) + gen() + api("PUT", "/providers/proxies/amnezia-keys") + return {"ok": True} + + +COMMANDS = {"state": cmd_state, "select": cmd_select, "delay": cmd_delay, "tun": cmd_tun, + "add_key": cmd_add_key, "remove_key": cmd_remove_key} + + +# MARK: - Socket server + +def peer_uid(conn): + # LOCAL_PEERCRED (SOL_LOCAL=0, opt=1) → struct xucred { u_int cr_version; uid_t cr_uid; … } + cred = conn.getsockopt(0, 1, 76) + return struct.unpack_from("I", cred, 4)[0] + + +def handle(conn): + try: + if peer_uid(conn) not in (0, ALLOWED_UID): + return + conn.settimeout(30) + buf = b"" + while b"\n" not in buf and len(buf) < MAX_REQUEST: + chunk = conn.recv(8192) + if not chunk: + break + buf += chunk + req = json.loads(buf.split(b"\n", 1)[0] or b"{}") + fn = COMMANDS.get(req.get("cmd")) + if fn is None: + resp = {"ok": False, "error": "неизвестная команда"} + else: + try: + resp = fn(req) + except urllib.error.URLError: + resp = {"ok": False, "error": "ядро не отвечает"} + except Exception as e: + resp = {"ok": False, "error": str(e) or e.__class__.__name__} + conn.sendall((json.dumps(resp, ensure_ascii=False) + "\n").encode()) + except Exception: + pass + finally: + conn.close() + + +def main(): + if ALLOWED_UID < 0: + sys.exit("usage: netctl.py ") + try: + os.unlink(SOCK) + except FileNotFoundError: + pass + srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + old = os.umask(0o177) + srv.bind(SOCK) + os.umask(old) + os.chown(SOCK, ALLOWED_UID, -1) + os.chmod(SOCK, 0o600) + srv.listen(8) + while True: + conn, _ = srv.accept() + threading.Thread(target=handle, args=(conn,), daemon=True).start() + + +if __name__ == "__main__": + main() diff --git a/scripts/netcore/uninstall.sh b/scripts/netcore/uninstall.sh new file mode 100644 index 0000000..cc2f977 --- /dev/null +++ b/scripts/netcore/uninstall.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# Removes the BroV network core services (sudo sh scripts/netcore/uninstall.sh). +# Keys stay in /Library/Application Support/BroV/netcore unless you pass --purge. +# After this the Mac goes online by itself; turn AmneziaVPN back on if needed. +set -e +[ "$(id -u)" -eq 0 ] || { echo "Запусти через sudo: sudo sh $0"; exit 1; } +for label in local.maksar.brov.netctl local.maksar.brov.netd; do + launchctl bootout "system/$label" 2>/dev/null || true + rm -f "/Library/LaunchDaemons/$label.plist" +done +rm -f /var/run/brov-netctl.sock +if [ "$1" = "--purge" ]; then + rm -rf "/Library/Application Support/BroV" /Library/Logs/BroV + echo "✓ Службы, ядро и ключи удалены." +else + echo "✓ Службы ядра BroV остановлены и удалены. Ключи остались в /Library/Application Support/BroV/netcore (root)." +fi