657 lines
23 KiB
Swift
657 lines
23 KiB
Swift
import Combine
|
||
import Foundation
|
||
|
||
struct VPNEvent: Decodable {
|
||
let event: String
|
||
var ip: String?
|
||
var expires: String?
|
||
var remaining_sec: Int?
|
||
var attempt: Int?
|
||
var delay: Int?
|
||
var reason: String?
|
||
var message: String?
|
||
var tier: Int?
|
||
var modules: ModuleStatus?
|
||
}
|
||
|
||
struct VPNStatusResponse: Decodable {
|
||
var modules: ModuleStatus?
|
||
}
|
||
|
||
struct ModuleStatus: Decodable {
|
||
struct Core: Decodable {
|
||
var openconnect: Bool
|
||
var openconnect_lite: Bool
|
||
var config: Bool
|
||
var openconnect_lite_config: Bool
|
||
}
|
||
|
||
struct ToggleModule: Decodable {
|
||
var enabled: Bool
|
||
var installed: Bool
|
||
}
|
||
|
||
struct Keychain: Decodable {
|
||
var password: Bool
|
||
var totp_seed: Bool
|
||
}
|
||
|
||
struct DnsCleanup: Decodable {
|
||
var installed: Bool
|
||
}
|
||
|
||
struct Patches: Decodable {
|
||
var active: Bool
|
||
var backup: Bool
|
||
}
|
||
|
||
struct AppModule: Decodable {
|
||
var installed: Bool
|
||
var autostart: Bool
|
||
}
|
||
|
||
var core: Core
|
||
var bitwarden: ToggleModule
|
||
var touchid: ToggleModule
|
||
var keychain: Keychain
|
||
var dns_cleanup: DnsCleanup
|
||
var patches: Patches
|
||
var app: AppModule?
|
||
|
||
var hasWarnings: Bool {
|
||
let coreReady = core.openconnect && core.openconnect_lite && core.openconnect_lite_config
|
||
let bitwardenReady = !bitwarden.enabled || bitwarden.installed
|
||
let touchReady = !touchid.enabled || touchid.installed
|
||
let keychainReady = keychain.password && keychain.totp_seed
|
||
let appReady = app?.installed ?? true
|
||
|
||
return !coreReady
|
||
|| !bitwardenReady
|
||
|| !touchReady
|
||
|| !keychainReady
|
||
|| !dns_cleanup.installed
|
||
|| !patches.active
|
||
|| !appReady
|
||
}
|
||
|
||
var systemImage: String {
|
||
hasWarnings ? "exclamationmark.triangle" : "checkmark.circle"
|
||
}
|
||
|
||
var summary: String {
|
||
let coreState = core.openconnect && core.openconnect_lite && core.openconnect_lite_config ? "✅ core" : "⚠️ core"
|
||
let bwState = bitwarden.enabled ? (bitwarden.installed ? "✅ bw" : "⚠️ bw") : "⏭️ bw"
|
||
let touchState = touchid.enabled ? (touchid.installed ? "✅ touch" : "⚠️ touch") : "⏭️ touch"
|
||
let dnsState = dns_cleanup.installed ? "✅ dns" : "⚠️ dns"
|
||
let appState = app.map { $0.installed ? "✅ app" : "⚠️ app" } ?? "❔ app"
|
||
let autostartState = app.map { $0.autostart ? "✅ autostart" : "⏭️ autostart" } ?? "❔ autostart"
|
||
let patchState = patches.active ? "✅ patches" : "⚠️ patches"
|
||
let keychainState = "\(keychain.password && keychain.totp_seed ? "✅" : "⚠️") kc \(keychain.password ? "pass" : "-")/\(keychain.totp_seed ? "totp" : "-")"
|
||
return [coreState, bwState, touchState, dnsState, appState, autostartState, patchState, keychainState].joined(separator: " | ")
|
||
}
|
||
}
|
||
|
||
enum VPNState: Equatable {
|
||
case disconnected
|
||
case unlocking(tier: String)
|
||
case connecting
|
||
case connected(ip: String, expiresAt: Date?, remainingSec: Int)
|
||
case reconnecting(attempt: Int)
|
||
case error(message: String)
|
||
|
||
var label: String {
|
||
switch self {
|
||
case .disconnected: return "disconnected"
|
||
case .unlocking(let t): return "unlocking(\(t))"
|
||
case .connecting: return "connecting"
|
||
case .connected(let ip, _, let r): return "connected(ip=\(ip), remaining=\(r)s)"
|
||
case .reconnecting(let a): return "reconnecting(#\(a))"
|
||
case .error(let m): return "error(\(m))"
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
class VPNManager: ObservableObject {
|
||
@Published var state: VPNState = .disconnected
|
||
@Published var lastError: String?
|
||
@Published var tunnelHealthy: Bool = true
|
||
@Published var moduleSummary: String = "modules loading..."
|
||
@Published var moduleStatusSystemImage: String = "hourglass"
|
||
|
||
private var process: Process?
|
||
private var outputPipe: Pipe?
|
||
private var stderrPipe: Pipe?
|
||
private var timer: Timer?
|
||
private var healthTimer: Timer?
|
||
private var healthCheckTarget: String?
|
||
private var sessionExpiresAt: Date?
|
||
private var currentIP: String = ""
|
||
private var userInitiatedDisconnect: Bool = false
|
||
private var autoReconnectAttempts: Int = 0
|
||
private var reconnectTimer: Timer?
|
||
private var consecutiveHealthFailures: Int = 0
|
||
|
||
private let healthCheckInterval: TimeInterval = 10
|
||
private let maxAutoReconnectAttempts: Int = 3
|
||
private let maxHealthFailuresBeforeKill: Int = 3
|
||
private let autoReconnectDelay: TimeInterval = 5
|
||
|
||
private let scriptPath: String = {
|
||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||
return "\(home)/bin/vpn-lemanapro.sh"
|
||
}()
|
||
|
||
init() {
|
||
refreshStatus()
|
||
}
|
||
|
||
var isRunning: Bool {
|
||
process?.isRunning ?? false
|
||
}
|
||
|
||
var remainingTime: String {
|
||
guard let expires = sessionExpiresAt else { return "" }
|
||
let remaining = Int(expires.timeIntervalSinceNow)
|
||
guard remaining > 0 else { return "expired" }
|
||
let h = remaining / 3600
|
||
let m = (remaining % 3600) / 60
|
||
return String(format: "%d:%02d", h, m)
|
||
}
|
||
|
||
var statusBarTitle: String {
|
||
switch state {
|
||
case .connected:
|
||
return remainingTime
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func refreshStatus() {
|
||
let proc = Process()
|
||
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
|
||
proc.arguments = ["-l", scriptPath, "--status", "--json"]
|
||
proc.environment = processEnvironment()
|
||
|
||
let pipe = Pipe()
|
||
proc.standardOutput = pipe
|
||
proc.standardError = FileHandle.nullDevice
|
||
|
||
proc.terminationHandler = { [weak self] _ in
|
||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||
guard let self = self else { return }
|
||
let text = String(data: data, encoding: .utf8) ?? ""
|
||
let lastLine = text.split(separator: "\n").map(String.init).last ?? ""
|
||
|
||
Task { @MainActor in
|
||
guard !lastLine.isEmpty, let jsonData = lastLine.data(using: .utf8) else {
|
||
self.moduleSummary = "modules unavailable"
|
||
self.moduleStatusSystemImage = "questionmark.circle"
|
||
self.log("[modules] status refresh returned no JSON output")
|
||
return
|
||
}
|
||
|
||
let response: VPNStatusResponse
|
||
do {
|
||
response = try JSONDecoder().decode(VPNStatusResponse.self, from: jsonData)
|
||
} catch {
|
||
self.moduleSummary = "modules unavailable"
|
||
self.moduleStatusSystemImage = "exclamationmark.triangle"
|
||
let compact = text.replacingOccurrences(of: "\n", with: "\\n")
|
||
let preview = compact.count > 500 ? String(compact.prefix(500)) + "..." : compact
|
||
self.log("[modules] status decode failed: \(error.localizedDescription); output=\(preview)")
|
||
return
|
||
}
|
||
|
||
guard let modules = response.modules else {
|
||
self.moduleSummary = "modules unavailable: update CLI"
|
||
self.moduleStatusSystemImage = "exclamationmark.triangle"
|
||
self.log("[modules] status has no modules field; reinstall CLI with install.sh")
|
||
return
|
||
}
|
||
|
||
self.moduleSummary = modules.summary
|
||
self.moduleStatusSystemImage = modules.systemImage
|
||
}
|
||
}
|
||
|
||
do {
|
||
try proc.run()
|
||
} catch {
|
||
log("[modules] status refresh failed: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
|
||
func connect() {
|
||
guard !isRunning else {
|
||
log("connect() called but process already running")
|
||
return
|
||
}
|
||
log("-- VPN connect requested --")
|
||
refreshStatus()
|
||
state = .connecting
|
||
lastError = nil
|
||
sessionExpiresAt = nil
|
||
currentIP = ""
|
||
userInitiatedDisconnect = false
|
||
reconnectTimer?.invalidate()
|
||
reconnectTimer = nil
|
||
|
||
let proc = Process()
|
||
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
|
||
proc.arguments = ["-l", scriptPath, "--json"]
|
||
proc.environment = processEnvironment()
|
||
|
||
let stdoutPipe = Pipe()
|
||
proc.standardOutput = stdoutPipe
|
||
|
||
let errPipe = Pipe()
|
||
proc.standardError = errPipe
|
||
|
||
self.process = proc
|
||
self.outputPipe = stdoutPipe
|
||
self.stderrPipe = errPipe
|
||
|
||
stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
||
let data = handle.availableData
|
||
guard !data.isEmpty else {
|
||
handle.readabilityHandler = nil
|
||
return
|
||
}
|
||
if let text = String(data: data, encoding: .utf8) {
|
||
let lines = text.split(separator: "\n").map(String.init)
|
||
Task { @MainActor in
|
||
for jsonLine in lines {
|
||
self?.parseEvent(jsonLine)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
errPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
||
let data = handle.availableData
|
||
guard !data.isEmpty else {
|
||
handle.readabilityHandler = nil
|
||
return
|
||
}
|
||
if let text = String(data: data, encoding: .utf8) {
|
||
let lines = text.split(separator: "\n", omittingEmptySubsequences: true).map(String.init)
|
||
Task { @MainActor in
|
||
for line in lines {
|
||
self?.log("[script] \(line)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
proc.terminationHandler = { [weak self] proc in
|
||
Task { @MainActor in
|
||
self?.handleTermination(exitCode: proc.terminationStatus)
|
||
}
|
||
}
|
||
|
||
do {
|
||
try proc.run()
|
||
log("Process started (pid=\(proc.processIdentifier))")
|
||
} catch {
|
||
log("Process launch failed: \(error.localizedDescription)")
|
||
state = .error(message: error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
func disconnect() {
|
||
guard let proc = process, proc.isRunning else {
|
||
log("disconnect() called but no running process")
|
||
reconnectTimer?.invalidate()
|
||
reconnectTimer = nil
|
||
if case .reconnecting = state { state = .disconnected }
|
||
return
|
||
}
|
||
userInitiatedDisconnect = true
|
||
log("-- VPN disconnect requested (SIGINT -> pid \(proc.processIdentifier)) --")
|
||
proc.interrupt()
|
||
}
|
||
|
||
func quit() {
|
||
log("-- App quit --")
|
||
userInitiatedDisconnect = true
|
||
reconnectTimer?.invalidate()
|
||
reconnectTimer = nil
|
||
disconnect()
|
||
stopTimer()
|
||
stopHealthCheck()
|
||
}
|
||
|
||
private func parseEvent(_ jsonString: String) {
|
||
guard let data = jsonString.data(using: .utf8),
|
||
let event = try? JSONDecoder().decode(VPNEvent.self, from: data) else {
|
||
log("[stdout] (non-JSON) \(jsonString)")
|
||
return
|
||
}
|
||
|
||
if let modules = event.modules {
|
||
moduleSummary = modules.summary
|
||
moduleStatusSystemImage = modules.systemImage
|
||
}
|
||
|
||
log("[event] \(event.event)" + {
|
||
var extras: [String] = []
|
||
if let ip = event.ip { extras.append("ip=\(ip)") }
|
||
if let exp = event.expires { extras.append("expires=\(exp)") }
|
||
if let r = event.remaining_sec { extras.append("remaining=\(r)s") }
|
||
if let a = event.attempt { extras.append("attempt=\(a)") }
|
||
if let m = event.message { extras.append("msg=\(m)") }
|
||
if let r = event.reason { extras.append("reason=\(r)") }
|
||
return extras.isEmpty ? "" : " (\(extras.joined(separator: ", ")))"
|
||
}())
|
||
|
||
Task { @MainActor in
|
||
let prevState = state.label
|
||
|
||
switch event.event {
|
||
case "modules":
|
||
return
|
||
case "bw_cached":
|
||
state = .unlocking(tier: "cached")
|
||
case "bw_touchid":
|
||
state = .unlocking(tier: "Touch ID")
|
||
case "bw_manual":
|
||
state = .unlocking(tier: "manual")
|
||
case "bw_synced":
|
||
log(" Credentials synced to Keychain")
|
||
return
|
||
case "keychain_ready":
|
||
log(" LDAP credentials are ready in macOS Keychain")
|
||
return
|
||
case "keychain_required":
|
||
log(" LDAP credentials are missing or incomplete")
|
||
return
|
||
case "connecting":
|
||
state = .connecting
|
||
case "connected":
|
||
currentIP = event.ip ?? ""
|
||
if let isoStr = event.expires {
|
||
sessionExpiresAt = ISO8601DateFormatter().date(from: isoStr)
|
||
}
|
||
let remaining = event.remaining_sec ?? Int(sessionExpiresAt?.timeIntervalSinceNow ?? 0)
|
||
state = .connected(ip: currentIP, expiresAt: sessionExpiresAt, remainingSec: remaining)
|
||
autoReconnectAttempts = 0
|
||
startTimer()
|
||
detectVPNDNS()
|
||
case "session_info":
|
||
currentIP = event.ip ?? currentIP
|
||
if let isoStr = event.expires {
|
||
sessionExpiresAt = ISO8601DateFormatter().date(from: isoStr)
|
||
}
|
||
let remaining = event.remaining_sec ?? Int(sessionExpiresAt?.timeIntervalSinceNow ?? 0)
|
||
state = .connected(ip: currentIP, expiresAt: sessionExpiresAt, remainingSec: remaining)
|
||
case "disconnected":
|
||
state = .disconnected
|
||
stopTimer()
|
||
stopHealthCheck()
|
||
case "reconnecting":
|
||
state = .reconnecting(attempt: event.attempt ?? 1)
|
||
case "dns_cleanup":
|
||
log(" DNS cleanup in progress")
|
||
return
|
||
case "error":
|
||
lastError = event.message
|
||
state = .error(message: event.message ?? "Unknown error")
|
||
default:
|
||
log(" Unknown event type, ignoring")
|
||
return
|
||
}
|
||
|
||
if prevState != state.label {
|
||
log("[state] \(prevState) -> \(state.label)")
|
||
}
|
||
}
|
||
}
|
||
|
||
private func handleTermination(exitCode: Int32) {
|
||
log("-- Process terminated (exit=\(exitCode), userInitiated=\(userInitiatedDisconnect)) --")
|
||
stopTimer()
|
||
stopHealthCheck()
|
||
process = nil
|
||
outputPipe = nil
|
||
stderrPipe = nil
|
||
refreshStatus()
|
||
|
||
let prevState = state.label
|
||
|
||
if userInitiatedDisconnect {
|
||
state = .disconnected
|
||
userInitiatedDisconnect = false
|
||
autoReconnectAttempts = 0
|
||
} else if [0, 2, 130, 143].contains(exitCode) {
|
||
scheduleAutoReconnect(reason: "session ended (exit \(exitCode))")
|
||
} else {
|
||
scheduleAutoReconnect(reason: "process crashed (exit \(exitCode))")
|
||
}
|
||
|
||
if prevState != state.label {
|
||
log("[state] \(prevState) -> \(state.label)")
|
||
}
|
||
}
|
||
|
||
private func scheduleAutoReconnect(reason: String) {
|
||
autoReconnectAttempts += 1
|
||
if autoReconnectAttempts > maxAutoReconnectAttempts {
|
||
log("Auto-reconnect limit reached (\(maxAutoReconnectAttempts) attempts). Giving up.")
|
||
state = .error(message: "Не удалось переподключиться (\(autoReconnectAttempts - 1) попыток)")
|
||
autoReconnectAttempts = 0
|
||
return
|
||
}
|
||
|
||
log("Auto-reconnect #\(autoReconnectAttempts)/\(maxAutoReconnectAttempts) in \(Int(autoReconnectDelay))s (reason: \(reason))")
|
||
state = .reconnecting(attempt: autoReconnectAttempts)
|
||
|
||
reconnectTimer?.invalidate()
|
||
reconnectTimer = Timer.scheduledTimer(withTimeInterval: autoReconnectDelay, repeats: false) { [weak self] _ in
|
||
Task { @MainActor in
|
||
guard let self = self else { return }
|
||
self.reconnectTimer = nil
|
||
guard case .reconnecting = self.state else {
|
||
self.log("Auto-reconnect cancelled (state changed)")
|
||
return
|
||
}
|
||
self.connect()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func startTimer() {
|
||
stopTimer()
|
||
timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
|
||
Task { @MainActor in
|
||
guard let self = self else { return }
|
||
if case .connected = self.state {
|
||
let remaining = Int(self.sessionExpiresAt?.timeIntervalSinceNow ?? 0)
|
||
self.state = .connected(
|
||
ip: self.currentIP,
|
||
expiresAt: self.sessionExpiresAt,
|
||
remainingSec: remaining
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func stopTimer() {
|
||
timer?.invalidate()
|
||
timer = nil
|
||
}
|
||
|
||
private func detectVPNDNS() {
|
||
log("[health] Detecting VPN DNS via scutil --dns...")
|
||
let proc = Process()
|
||
proc.executableURL = URL(fileURLWithPath: "/usr/sbin/scutil")
|
||
proc.arguments = ["--dns"]
|
||
|
||
let pipe = Pipe()
|
||
proc.standardOutput = pipe
|
||
proc.standardError = FileHandle.nullDevice
|
||
|
||
proc.terminationHandler = { [weak self] _ in
|
||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||
guard let output = String(data: data, encoding: .utf8) else { return }
|
||
|
||
var dnsServer: String?
|
||
let resolvers = output.components(separatedBy: "resolver #")
|
||
for resolver in resolvers {
|
||
guard resolver.contains("Supplemental") else { continue }
|
||
for line in resolver.components(separatedBy: "\n") {
|
||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||
if trimmed.hasPrefix("nameserver[0]"),
|
||
let ip = trimmed.components(separatedBy: " : ").last?
|
||
.trimmingCharacters(in: .whitespaces),
|
||
ip.hasPrefix("10.") {
|
||
dnsServer = ip
|
||
break
|
||
}
|
||
}
|
||
if dnsServer != nil { break }
|
||
}
|
||
|
||
Task { @MainActor in
|
||
guard let self = self else { return }
|
||
self.healthCheckTarget = dnsServer
|
||
if let dns = dnsServer {
|
||
self.log("[health] VPN DNS discovered: \(dns)")
|
||
self.startHealthCheck()
|
||
} else {
|
||
self.log("[health] No VPN DNS found (no Supplemental 10.x resolver)")
|
||
}
|
||
}
|
||
}
|
||
|
||
do {
|
||
try proc.run()
|
||
} catch {
|
||
log("[health] scutil failed: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
|
||
private func startHealthCheck() {
|
||
healthTimer?.invalidate()
|
||
healthTimer = nil
|
||
tunnelHealthy = true
|
||
consecutiveHealthFailures = 0
|
||
guard let target = healthCheckTarget else { return }
|
||
log("[health] Started: ping \(target) every \(Int(healthCheckInterval))s")
|
||
healthTimer = Timer.scheduledTimer(withTimeInterval: healthCheckInterval, repeats: true) { [weak self] _ in
|
||
Task { @MainActor in
|
||
self?.checkTunnelHealth()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func stopHealthCheck() {
|
||
if healthTimer != nil {
|
||
log("[health] Stopped")
|
||
}
|
||
healthTimer?.invalidate()
|
||
healthTimer = nil
|
||
healthCheckTarget = nil
|
||
tunnelHealthy = true
|
||
consecutiveHealthFailures = 0
|
||
}
|
||
|
||
private func checkTunnelHealth() {
|
||
guard case .connected = state, let target = healthCheckTarget else { return }
|
||
|
||
let proc = Process()
|
||
proc.executableURL = URL(fileURLWithPath: "/sbin/ping")
|
||
proc.arguments = ["-c", "1", "-W", "2000", target]
|
||
proc.standardOutput = FileHandle.nullDevice
|
||
proc.standardError = FileHandle.nullDevice
|
||
|
||
let wasHealthy = tunnelHealthy
|
||
proc.terminationHandler = { [weak self] p in
|
||
Task { @MainActor in
|
||
guard let self = self, case .connected = self.state else { return }
|
||
let nowHealthy = p.terminationStatus == 0
|
||
|
||
if nowHealthy {
|
||
self.consecutiveHealthFailures = 0
|
||
if !wasHealthy {
|
||
self.log("[health] Recovered - ping \(target)")
|
||
}
|
||
} else {
|
||
self.consecutiveHealthFailures += 1
|
||
if wasHealthy {
|
||
self.log("[health] Unreachable - ping \(target) (fail #\(self.consecutiveHealthFailures))")
|
||
} else {
|
||
self.log("[health] Still unreachable (fail #\(self.consecutiveHealthFailures)/\(self.maxHealthFailuresBeforeKill))")
|
||
}
|
||
|
||
if self.consecutiveHealthFailures >= self.maxHealthFailuresBeforeKill {
|
||
self.log("[health] Tunnel dead after \(self.consecutiveHealthFailures) failures - killing process for reconnect")
|
||
self.stopHealthCheck()
|
||
if let proc = self.process, proc.isRunning {
|
||
proc.terminate()
|
||
}
|
||
return
|
||
}
|
||
}
|
||
self.tunnelHealthy = nowHealthy
|
||
}
|
||
}
|
||
|
||
do {
|
||
try proc.run()
|
||
} catch {
|
||
consecutiveHealthFailures += 1
|
||
if tunnelHealthy {
|
||
log("[health] ping failed to launch: \(error.localizedDescription)")
|
||
}
|
||
tunnelHealthy = false
|
||
}
|
||
}
|
||
|
||
private func processEnvironment() -> [String: String] {
|
||
var env = ProcessInfo.processInfo.environment
|
||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||
let extraPaths = [
|
||
"\(home)/bin",
|
||
"\(home)/.local/bin",
|
||
"/opt/homebrew/bin",
|
||
"/opt/homebrew/sbin",
|
||
"/usr/local/bin",
|
||
"/usr/local/sbin"
|
||
]
|
||
let existingPath = env["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin"
|
||
env["PATH"] = (extraPaths + [existingPath]).joined(separator: ":")
|
||
env["HOME"] = home
|
||
return env
|
||
}
|
||
|
||
private static let logURL: URL = {
|
||
FileManager.default.homeDirectoryForCurrentUser
|
||
.appendingPathComponent("Library/Logs/LemanaVPN.log")
|
||
}()
|
||
|
||
private static let tsFormatter: DateFormatter = {
|
||
let f = DateFormatter()
|
||
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||
f.locale = Locale(identifier: "en_US_POSIX")
|
||
return f
|
||
}()
|
||
|
||
func log(_ message: String) {
|
||
let ts = Self.tsFormatter.string(from: Date())
|
||
let line = "[\(ts)] \(message)\n"
|
||
|
||
let path = Self.logURL.path
|
||
if !FileManager.default.fileExists(atPath: path) {
|
||
FileManager.default.createFile(atPath: path, contents: nil)
|
||
}
|
||
if let handle = FileHandle(forWritingAtPath: path) {
|
||
handle.seekToEndOfFile()
|
||
handle.write(line.data(using: .utf8) ?? Data())
|
||
handle.closeFile()
|
||
}
|
||
}
|
||
}
|