Bind independent Tailnet connections to group IDs while preserving legacy host identities through the V3 sync format. Persist profile cleanup retries, isolate test storage, and reject stale dialers after re-enrollment. Recover quarantined sync records safely, refresh metrics compatibility, fix multiline shell history, and remove obsolete mobile history gates. Pin libtailscale-spm to 1.0.1 alongside its submodule revision. Validation: default macOS suites (2679 tests), targeted iOS suites (26 tests), strict Swift formatting, shared AppStrings checks, and Chinese localization checks passed. Both subagent reviews approved.
194 lines
7.4 KiB
Swift
194 lines
7.4 KiB
Swift
import Foundation
|
|
|
|
public nonisolated struct ShellHistorySource: Sendable, Equatable {
|
|
public enum Shell: String, Sendable { case bash, zsh, fish }
|
|
public var shell: Shell
|
|
/// nil asks for the shell's default; empty explicitly disables file history.
|
|
public var path: String?
|
|
|
|
public init(shell: Shell, path: String? = nil) {
|
|
self.shell = shell
|
|
self.path = path
|
|
}
|
|
}
|
|
|
|
public nonisolated struct ShellHistoryRecord: Sendable, Equatable {
|
|
public var command: String
|
|
public var executedAt: Date?
|
|
public var duration: TimeInterval?
|
|
|
|
public init(command: String, executedAt: Date? = nil, duration: TimeInterval? = nil) {
|
|
self.command = command
|
|
self.executedAt = executedAt
|
|
self.duration = duration
|
|
}
|
|
}
|
|
|
|
public nonisolated enum ShellHistoryFilter {
|
|
public static func isIntegrationCommand(_ command: String) -> Bool {
|
|
let text = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
// Anchored signatures, not substring matches: echo/grep of these names is user work.
|
|
if text.hasPrefix("command true '__TRMD_"), text.contains("_PROBE__'") { return true }
|
|
if text.hasPrefix("if [[ ") || text.hasPrefix("if not set -q "),
|
|
text.contains("__termind_integration_token"),
|
|
text.contains("__termind_report_prompt"), text.contains("_DONE__")
|
|
{
|
|
return true
|
|
}
|
|
let name = text.prefix { $0.isLetter || $0.isNumber || $0 == "_" }
|
|
return [
|
|
"__termind_report_command", "__termind_report_prompt", "__termind_report_cwd",
|
|
"__termind_report_finished", "__termind_prompt_begin", "__termind_prompt_end",
|
|
"__termind_compose_draft", "__termind_compose_clear",
|
|
].contains(String(name))
|
|
}
|
|
}
|
|
|
|
/// Parses complete records in file order. It never invents timestamps or evaluates shell text.
|
|
public nonisolated enum ShellHistoryParser {
|
|
public static func parse(
|
|
_ text: String, shell: ShellHistorySource.Shell, truncatedStart: Bool = false
|
|
) -> [ShellHistoryRecord] {
|
|
var lines = text.components(separatedBy: "\n")
|
|
// A file being appended may end halfway through a record. Only newline-terminated text is complete.
|
|
if !lines.isEmpty { lines.removeLast() }
|
|
if truncatedStart, !lines.isEmpty { lines.removeFirst() }
|
|
let records: [ShellHistoryRecord]
|
|
switch shell {
|
|
case .bash: records = bash(lines, truncatedStart: truncatedStart)
|
|
case .zsh: records = zsh(lines, truncatedStart: truncatedStart)
|
|
case .fish: records = fish(lines)
|
|
}
|
|
return records.filter {
|
|
!$0.command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
&& !ShellHistoryFilter.isIntegrationCommand($0.command)
|
|
}
|
|
}
|
|
|
|
private static func timestamp(_ text: String) -> Date? {
|
|
guard !text.isEmpty, text.allSatisfy(\.isNumber), let seconds = Double(text),
|
|
seconds.isFinite, seconds > 0
|
|
else { return nil }
|
|
return Date(timeIntervalSince1970: seconds)
|
|
}
|
|
|
|
private static func bash(_ lines: [String], truncatedStart: Bool) -> [ShellHistoryRecord] {
|
|
let hasMarkers = lines.contains { $0.hasPrefix("#") && timestamp(String($0.dropFirst())) != nil }
|
|
if !hasMarkers { return plain(lines) }
|
|
var records: [ShellHistoryRecord] = []
|
|
var date: Date?
|
|
var command: [String] = []
|
|
var accepts = !truncatedStart
|
|
func flush() {
|
|
if accepts, !command.isEmpty {
|
|
records.append(ShellHistoryRecord(command: command.joined(separator: "\n"), executedAt: date))
|
|
}
|
|
command.removeAll()
|
|
}
|
|
for line in lines {
|
|
if line.hasPrefix("#"), let next = timestamp(String(line.dropFirst())) {
|
|
flush()
|
|
date = next
|
|
accepts = true
|
|
} else {
|
|
command.append(line)
|
|
}
|
|
}
|
|
flush()
|
|
return records
|
|
}
|
|
|
|
private static func plain(_ lines: [String]) -> [ShellHistoryRecord] {
|
|
var result: [ShellHistoryRecord] = []
|
|
var pending = ""
|
|
for line in lines {
|
|
pending += line
|
|
if trailingBackslashes(line).isMultiple(of: 2) {
|
|
result.append(ShellHistoryRecord(command: pending))
|
|
pending = ""
|
|
} else {
|
|
pending += "\n"
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private static func trailingBackslashes(_ line: String) -> Int {
|
|
line.reversed().prefix { $0 == "\\" }.count
|
|
}
|
|
|
|
private static func zsh(_ lines: [String], truncatedStart: Bool) -> [ShellHistoryRecord] {
|
|
var result: [ShellHistoryRecord] = []
|
|
var pending: ShellHistoryRecord?
|
|
var continuing = false
|
|
let extended = lines.contains { header($0) != nil }
|
|
var accepts = !truncatedStart || !extended
|
|
for line in lines {
|
|
if !continuing {
|
|
if let parsed = header(line) {
|
|
pending = parsed
|
|
accepts = true
|
|
} else if accepts {
|
|
pending = ShellHistoryRecord(command: line)
|
|
}
|
|
} else {
|
|
pending?.command += "\n" + line
|
|
}
|
|
continuing = line.hasSuffix("\\")
|
|
if continuing {
|
|
// Zsh uses backslash + physical newline to serialize a literal newline.
|
|
if pending != nil { pending!.command.removeLast() }
|
|
} else if let record = pending {
|
|
result.append(record)
|
|
pending = nil
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private static func header(_ line: String) -> ShellHistoryRecord? {
|
|
guard line.hasPrefix(": "), let semicolon = line.firstIndex(of: ";") else { return nil }
|
|
let fields = line[line.index(line.startIndex, offsetBy: 2)..<semicolon].split(separator: ":")
|
|
guard fields.count == 2, let date = timestamp(String(fields[0])),
|
|
let duration = Double(fields[1]), duration.isFinite, duration >= 0
|
|
else { return nil }
|
|
return ShellHistoryRecord(
|
|
command: String(line[line.index(after: semicolon)...]), executedAt: date, duration: duration)
|
|
}
|
|
|
|
private static func fish(_ lines: [String]) -> [ShellHistoryRecord] {
|
|
var records: [ShellHistoryRecord] = []
|
|
var current: ShellHistoryRecord?
|
|
for line in lines {
|
|
if line.hasPrefix("- cmd: ") {
|
|
if let current { records.append(current) }
|
|
current = ShellHistoryRecord(command: fishUnescape(String(line.dropFirst(7))))
|
|
} else if line.hasPrefix(" when: ") {
|
|
current?.executedAt = timestamp(String(line.dropFirst(8)))
|
|
}
|
|
}
|
|
if let current { records.append(current) }
|
|
return records
|
|
}
|
|
|
|
private static func fishUnescape(_ value: String) -> String {
|
|
var result = ""
|
|
var iterator = value.makeIterator()
|
|
while let character = iterator.next() {
|
|
if character == "\\", let next = iterator.next() {
|
|
if next == "n" {
|
|
result.append("\n")
|
|
} else if next == "\\" {
|
|
result.append("\\")
|
|
} else {
|
|
result.append("\\")
|
|
result.append(next)
|
|
}
|
|
} else {
|
|
result.append(character)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
}
|