Files
Termind/Frameworks/TermindDomain/Shell/ShellHistory.swift
T
Kaede Akino 1ab7ed3da3 feat: isolate tailscale connections and harden history
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.
2026-10-03 09:46:09 +08:00

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
}
}