This commit is contained in:
Kaede Akino committed 2026-08-30 03:45:20 +08:00
1 parent 3f0dd9a7f2
commit 8086a9142f
6 files changed
+535 -220

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
#!/bin/zsh
set -euo pipefail
usage() {
print -u2 "Usage: Scripts/run-screenshot-demo.sh <hosts|workspace|agent|command> <en|zh>"
}
if (( $# != 2 )); then
usage
exit 64
fi
scene="$1"
language="$2"
case "$scene" in
hosts|workspace|agent|command) ;;
*)
print -u2 "Unknown Demo scene: $scene"
usage
exit 64
;;
esac
case "$language" in
en|zh) ;;
*)
print -u2 "Unknown Demo language: $language"
usage
exit 64
;;
esac
script_directory="${0:A:h}"
repository_directory="${script_directory:h}"
derived_data="$repository_directory/.build/screenshot-demo"
application="$derived_data/Build/Products/Debug/Termind.app"
xcodebuild \
-project "$repository_directory/Termind.xcodeproj" \
-scheme Termind \
-configuration Debug \
-derivedDataPath "$derived_data" \
build
open -n "$application" --args "--termind-demo=$scene:$language"
+237 -161
View File
@@ -1,175 +1,251 @@
import Foundation
#if DEBUG
import Foundation
/// Explicit, isolated launch scenes used to capture product images for the
/// website. Nothing here is reachable in an ordinary launch.
enum ScreenshotDemoMode: String {
case workspace
case agent
case command
static var active: ScreenshotDemoMode? {
ProcessInfo.processInfo.environment["TERMIND_SCREENSHOT_DEMO"]
.flatMap(ScreenshotDemoMode.init(rawValue:))
}
static var language: AppLanguage? {
switch ProcessInfo.processInfo.environment["TERMIND_SCREENSHOT_LANGUAGE"] {
case "en": .english
case "zh": .simplifiedChineseChina
default: nil
}
}
var windowSize: CGSize {
switch self {
case .command:
CGSize(width: 1_022, height: 640)
case .workspace, .agent:
CGSize(
width: Metrics.Window.defaultWidth,
height: Metrics.Window.defaultHeight
)
}
}
func makeWindow(hosts: HostLibraryModel) -> WindowModel {
switch self {
case .workspace:
return workspaceWindow(hosts: hosts, showsCommandResult: false)
case .command:
return workspaceWindow(hosts: hosts, showsCommandResult: true)
case .agent:
return agentWindow(hosts: hosts)
}
}
private func agentWindow(hosts: HostLibraryModel) -> WindowModel {
guard let host = hosts.hosts.first else {
preconditionFailure("Screenshot demo requires its bundled host")
/// An explicit, isolated launch configuration used to capture product
/// images. The scene and language travel as one value so a capture cannot
/// accidentally combine demo content in one language with UI in another.
struct ScreenshotDemoConfiguration: Equatable {
enum Scene: String, CaseIterable {
case hosts
case workspace
case agent
case command
}
let session = SSHSessionModel.demo(host: host)
let pane = Pane(.sshSession(session))
pane.customTitle = "nginx diagnostics"
let workspace = Workspace(pane: pane)
workspace.customTitle = "Production diagnostics"
workspace.focusedPaneID = pane.id
enum ParseError: Error, Equatable, CustomStringConvertible {
case duplicateArguments
case invalidFormat(String)
case unknownScene(String)
case unknownLanguage(String)
let conversation = ConversationModel.demo()
let library = AssistantLibraryModel()
library.insertAndSelect(conversation)
let window = WindowModel(workspaces: [workspace], assistantLibrary: library)
window.showsSidebar = true
window.sidebarMode = .assistant
window.openAssistantInSidebar(conversation.id)
return window
}
private func workspaceWindow(
hosts: HostLibraryModel, showsCommandResult: Bool
) -> WindowModel {
let saved = hosts.hosts
precondition(saved.count >= 2, "Screenshot demo requires its bundled hosts")
let firstSession: SSHSessionModel
if showsCommandResult {
firstSession = SSHSessionModel.demo(
host: saved[0],
transcript: [
"\u{001B}]7;file://web-01/var/log/nginx\u{0007}",
"\u{001B}[2mLast login: Thu Aug 27 16:42:18 from 10.0.0.12\u{001B}[0m",
"\u{001B}[36mdeploy@web-01\u{001B}[0m "
+ "\u{001B}[34m/var/log/nginx\u{001B}[0m % "
+ "find . -type f -newermt today -printf '%s %p\\n' | sort -nr | head -10 ",
].joined(separator: "\r\n")
)
} else {
firstSession = .demo(
host: saved[0],
transcript: broadcastTranscript(for: saved[0].name, variant: .primary)
)
var description: String {
switch self {
case .duplicateArguments:
return "Pass --termind-demo only once."
case .invalidFormat(let value):
return "Invalid Demo argument \(value). Expected "
+ "--termind-demo=<hosts|workspace|agent|command>:<en|zh>."
case .unknownScene(let value):
return "Unknown Demo scene \(value). Expected hosts, workspace, agent, or command."
case .unknownLanguage(let value):
return "Unknown Demo language \(value). Expected en or zh."
}
}
}
let first = Pane(.sshSession(firstSession))
first.customTitle = showsCommandResult ? "nginx logs" : "api-01"
let sftpSession = SFTPSessionModel.demo(hostID: saved[0].id)
sftpSession.selection = ["/etc/nginx/nginx.conf"]
let sftp = Pane(.sftpSession(sftpSession))
sftp.customTitle = "nginx config"
let secondSession = showsCommandResult
? SSHSessionModel.demo(host: saved[1])
: SSHSessionModel.demo(
host: saved[1],
transcript: broadcastTranscript(for: saved[1].name, variant: .secondary)
)
let second = Pane(.sshSession(secondSession))
second.customTitle = "worker-02"
private static let option = "--termind-demo"
let workspace = Workspace(pane: first)
if showsCommandResult {
workspace.layout = .leaf(first)
} else {
workspace.layout = .split(
.init(
axis: .horizontal,
fraction: 0.5,
first: .leaf(first),
second: .split(
.init(
axis: .vertical,
fraction: 0.5,
first: .leaf(sftp),
second: .leaf(second)
let scene: Scene
let language: AppLanguage
static var active: Self? {
do {
return try parse(arguments: ProcessInfo.processInfo.arguments)
} catch {
fatalError("Unable to launch Termind Demo: \(error)")
}
}
static func parse(arguments: [String]) throws -> Self? {
let candidates = arguments.filter {
$0 == option || $0.hasPrefix("\(option)=")
}
guard candidates.count <= 1 else { throw ParseError.duplicateArguments }
guard let argument = candidates.first else { return nil }
let prefix = "\(option)="
guard argument.hasPrefix(prefix) else {
throw ParseError.invalidFormat(argument)
}
let value = String(argument.dropFirst(prefix.count))
let components = value.split(separator: ":", omittingEmptySubsequences: false)
guard components.count == 2 else {
throw ParseError.invalidFormat(argument)
}
let sceneValue = String(components[0])
guard let scene = Scene(rawValue: sceneValue) else {
throw ParseError.unknownScene(sceneValue)
}
let languageValue = String(components[1])
let language: AppLanguage
switch languageValue {
case "en": language = .english
case "zh": language = .simplifiedChineseChina
default: throw ParseError.unknownLanguage(languageValue)
}
return Self(scene: scene, language: language)
}
var windowSize: CGSize {
switch scene {
case .command:
CGSize(width: 1_022, height: 640)
case .workspace, .agent, .hosts:
CGSize(
width: Metrics.Window.defaultWidth,
height: Metrics.Window.defaultHeight
)
}
}
func makeWindow(hosts: HostLibraryModel) -> WindowModel {
switch scene {
case .workspace:
return workspaceWindow(hosts: hosts, showsCommandResult: false)
case .command:
return workspaceWindow(hosts: hosts, showsCommandResult: true)
case .agent:
return agentWindow(hosts: hosts)
case .hosts:
return hostsWindow()
}
}
private func hostsWindow() -> WindowModel {
let window = WindowModel(vaultDestination: .hosts)
window.showsSidebar = true
window.sidebarMode = .navigation
return window
}
private func agentWindow(hosts: HostLibraryModel) -> WindowModel {
guard let host = hosts.hosts.first else {
preconditionFailure("Screenshot demo requires its bundled host")
}
let session = SSHSessionModel.demo(host: host)
let pane = Pane(.sshSession(session))
pane.customTitle = text(en: "nginx diagnostics", zh: "Nginx 诊断")
let workspace = Workspace(pane: pane)
workspace.customTitle = text(en: "Production diagnostics", zh: "生产环境诊断")
workspace.focusedPaneID = pane.id
let conversation = ConversationModel.demo(language: language)
let library = AssistantLibraryModel()
library.insertAndSelect(conversation)
let window = WindowModel(workspaces: [workspace], assistantLibrary: library)
window.showsSidebar = true
window.sidebarMode = .assistant
window.openAssistantInSidebar(conversation.id)
return window
}
private func workspaceWindow(
hosts: HostLibraryModel, showsCommandResult: Bool
) -> WindowModel {
let saved = hosts.hosts
precondition(saved.count >= 2, "Screenshot demo requires its bundled hosts")
let firstSession: SSHSessionModel
if showsCommandResult {
firstSession = SSHSessionModel.demo(
host: saved[0],
transcript: [
"\u{001B}]7;file://web-01/var/log/nginx\u{0007}",
"\u{001B}[2mLast login: Thu Aug 27 16:42:18 from 10.0.0.12\u{001B}[0m",
"\u{001B}[36mdeploy@web-01\u{001B}[0m "
+ "\u{001B}[34m/var/log/nginx\u{001B}[0m % "
+ "find . -type f -newermt today -printf '%s %p\\n' | sort -nr | head -10 ",
].joined(separator: "\r\n")
)
} else {
firstSession = .demo(
host: saved[0],
transcript: broadcastTranscript(for: saved[0].name, variant: .primary)
)
}
let first = Pane(.sshSession(firstSession))
first.customTitle = showsCommandResult
? text(en: "nginx logs", zh: "Nginx 日志")
: "api-01"
let sftpSession = SFTPSessionModel.demo(hostID: saved[0].id)
sftpSession.selection = ["/etc/nginx/nginx.conf"]
let sftp = Pane(.sftpSession(sftpSession))
sftp.customTitle = text(en: "nginx config", zh: "Nginx 配置")
let secondSession = showsCommandResult
? SSHSessionModel.demo(host: saved[1])
: SSHSessionModel.demo(
host: saved[1],
transcript: broadcastTranscript(for: saved[1].name, variant: .secondary)
)
let second = Pane(.sshSession(secondSession))
second.customTitle = "worker-02"
let workspace = Workspace(pane: first)
if showsCommandResult {
workspace.layout = .leaf(first)
} else {
workspace.layout = .split(
.init(
axis: .horizontal,
fraction: 0.5,
first: .leaf(first),
second: .split(
.init(
axis: .vertical,
fraction: 0.5,
first: .leaf(sftp),
second: .leaf(second)
)
)
)
)
)
}
workspace.focusedPaneID = first.id
workspace.customTitle = showsCommandResult ? "Log investigation" : "Production diagnostics"
workspace.broadcastsInput = !showsCommandResult
if showsCommandResult {
workspace.commandGeneratorState = .inserted(targetCount: 1)
}
workspace.focusedPaneID = first.id
workspace.customTitle = showsCommandResult
? text(en: "Log investigation", zh: "日志调查")
: text(en: "Production diagnostics", zh: "生产环境诊断")
workspace.broadcastsInput = !showsCommandResult
if showsCommandResult {
workspace.commandGeneratorState = .inserted(targetCount: 1)
}
let window = WindowModel(workspaces: [workspace])
window.showsSidebar = showsCommandResult
if showsCommandResult {
window.sidebarMode = .navigation
}
return window
}
let window = WindowModel(workspaces: [workspace])
window.showsSidebar = showsCommandResult
if showsCommandResult {
window.sidebarMode = .navigation
private enum BroadcastTranscriptVariant { case primary, secondary }
private func broadcastTranscript(
for host: String, variant: BroadcastTranscriptVariant
) -> String {
let prompt =
"\u{001B}[36mdeploy@\(host)\u{001B}[0m "
+ "\u{001B}[34m/var/log/nginx\u{001B}[0m %"
let history: [String]
switch variant {
case .primary:
history = [
prompt + " systemctl status nginx --no-pager",
"● nginx.service - A high performance web server",
" Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled)",
" Active: \u{001B}[32mactive (running)\u{001B}[0m since Thu 2026-08-27 09:14:03 UTC",
]
case .secondary:
history = [
prompt + " journalctl -u nginx -n 2 --no-pager",
"Aug 27 09:13:58 web-02 nginx[1842]: configuration test is successful",
"Aug 27 09:14:03 web-02 systemd[1]: Started nginx.service",
]
}
return ([
"\u{001B}]7;file://\(host)/var/log/nginx\u{0007}",
"\u{001B}[2mLast login: Thu Aug 27 16:42:18 from 10.0.0.12\u{001B}[0m",
] + history + [
prompt + " tail -f /var/log/nginx/access.log",
]).joined(separator: "\r\n")
}
private func text(en: String, zh: String) -> String {
language == .simplifiedChineseChina ? zh : en
}
return window
}
private enum BroadcastTranscriptVariant { case primary, secondary }
private func broadcastTranscript(
for host: String, variant: BroadcastTranscriptVariant
) -> String {
let prompt =
"\u{001B}[36mdeploy@\(host)\u{001B}[0m "
+ "\u{001B}[34m/var/log/nginx\u{001B}[0m %"
let history: [String]
switch variant {
case .primary:
history = [
prompt + " systemctl status nginx --no-pager",
"● nginx.service - A high performance web server",
" Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled)",
" Active: \u{001B}[32mactive (running)\u{001B}[0m since Thu 2026-08-27 09:14:03 UTC",
]
case .secondary:
history = [
prompt + " journalctl -u nginx -n 2 --no-pager",
"Aug 27 09:13:58 web-02 nginx[1842]: configuration test is successful",
"Aug 27 09:14:03 web-02 systemd[1]: Started nginx.service",
]
}
return ([
"\u{001B}]7;file://\(host)/var/log/nginx\u{0007}",
"\u{001B}[2mLast login: Thu Aug 27 16:42:18 from 10.0.0.12\u{001B}[0m",
] + history + [
prompt + " tail -f /var/log/nginx/access.log",
]).joined(separator: "\r\n")
}
}
#endif
+40 -27
View File
@@ -17,14 +17,23 @@ struct TermindApp: App {
init() {
ContextAttachmentOpener.cleanPreviousSession()
ScrubberWebClient.setup()
let screenshotDemo = ScreenshotDemoMode.active
launchLanguage = ScreenshotDemoMode.language ?? AppLanguage.stored()
#if DEBUG
let screenshotDemo = ScreenshotDemoConfiguration.active
launchLanguage = screenshotDemo?.language ?? AppLanguage.stored()
#else
launchLanguage = AppLanguage.stored()
#endif
do {
let isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"]
!= nil
#if DEBUG
let usesInMemoryStore = isRunningTests || screenshotDemo != nil
#else
let usesInMemoryStore = isRunningTests
#endif
let configuration = ModelConfiguration(
"Termind", schema: HostPersistence.schema,
isStoredInMemoryOnly: isRunningTests || screenshotDemo != nil,
isStoredInMemoryOnly: usesInMemoryStore,
cloudKitDatabase: .none
)
let container = try ModelContainer(
@@ -33,16 +42,18 @@ struct TermindApp: App {
configurations: [configuration]
)
modelContainer = container
if let screenshotDemo {
let hosts = HostLibraryModel.demo()
let app = AppModel(
hosts: hosts, ai: .demo(), proAccess: .unrestrictedForTesting(),
onboarding: OnboardingController(automaticPresentationEnabled: false)
)
_app = State(initialValue: app)
launchWindow = screenshotDemo.makeWindow(hosts: hosts)
return
}
#if DEBUG
if let screenshotDemo {
let hosts = HostLibraryModel.demo(language: screenshotDemo.language)
let app = AppModel(
hosts: hosts, ai: .demo(), proAccess: .unrestrictedForTesting(),
onboarding: OnboardingController(automaticPresentationEnabled: false)
)
_app = State(initialValue: app)
launchWindow = screenshotDemo.makeWindow(hosts: hosts)
return
}
#endif
let secrets = KeychainSecretStore()
LegacyWebSearchSettings.remove { reference in
try secrets.setString(nil, for: reference, storage: .thisDevice)
@@ -169,21 +180,23 @@ struct TermindApp: App {
// frame from macOS state restoration. Each demo owns its capture
// size so tightly focused scenes do not inherit the larger
// workspace composition.
if let screenshotDemo = ScreenshotDemoMode.active {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
guard let window = NSApp.windows.first(where: \.isVisible) else { return }
window.isRestorable = false
window.contentMinSize = .zero
window.setFrame(
NSRect(
origin: window.frame.origin,
size: screenshotDemo.windowSize
),
display: true
)
window.center()
#if DEBUG
if let screenshotDemo = ScreenshotDemoConfiguration.active {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
guard let window = NSApp.windows.first(where: \.isVisible) else { return }
window.isRestorable = false
window.contentMinSize = .zero
window.setFrame(
NSRect(
origin: window.frame.origin,
size: screenshotDemo.windowSize
),
display: true
)
window.center()
}
}
}
#endif
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
@@ -5,21 +5,33 @@ import Foundation
extension HostLibraryModel {
/// Seed data for P0, so the sidebar and workspace list can be exercised before any
/// persistence or networking exists.
static func demo() -> HostLibraryModel {
let production = HostGroup(name: "Production", sortIndex: 0)
let staging = HostGroup(name: "Staging", sortIndex: 1)
let homelab = HostGroup(name: "Homelab", sortIndex: 2)
let edge = HostGroup(name: "Edge", parentID: production.id, sortIndex: 0)
static func demo(language: AppLanguage = .english) -> HostLibraryModel {
func text(_ en: String, _ zh: String) -> String {
language == .simplifiedChineseChina ? zh : en
}
func distribution(_ id: String) -> DetectedDistribution {
guard let distribution = DetectedDistribution(id: id) else {
preconditionFailure("Invalid bundled Demo distribution: \(id)")
}
return distribution
}
let production = HostGroup(name: text("Production", "生产环境"), sortIndex: 0)
let staging = HostGroup(name: text("Staging", "预发布环境"), sortIndex: 1)
let homelab = HostGroup(name: text("Homelab", "家庭实验室"), sortIndex: 2)
let edge = HostGroup(
name: text("Edge", "边缘节点"), parentID: production.id, sortIndex: 0
)
let now = Date(timeIntervalSinceReferenceDate: 776_000_000)
let personalKey = SSHKeyRef(
label: "Personal", algorithm: .ed25519,
label: text("Personal", "个人"), algorithm: .ed25519,
fingerprint: "SHA256:9pKQ2vC0nJ7hR4mXbL1sT8yWfE3dA6uZgN5qOiVxHkM",
credentialRef: "termind.key.personal", colorTag: .blue
)
let workKey = SSHKeyRef(
label: "Work (no sync)", algorithm: .ecdsa256,
label: text("Work (no sync)", "工作(不同步)"), algorithm: .ecdsa256,
fingerprint: "SHA256:3tBn8xLpQ2wZ7yKvR1mHcE9dS4fJ6uA0gT5iNqOxVbY",
credentialRef: "termind.key.work",
storage: .thisDevice, colorTag: .purple
@@ -29,23 +41,24 @@ extension HostLibraryModel {
// One of each shape, so all four combinations of the two secrets are on
// screen at P0: key only, key *and* password, password only, neither.
let deploy = Identity(
name: "Deploy", username: "deploy", keyID: personalKey.id,
notes: "Production web tier", lastUsedAt: now, colorTag: .green
name: text("Deploy", "部署账号"), username: "deploy", keyID: personalKey.id,
notes: text("Production web tier", "生产环境 Web 层"),
lastUsedAt: now, colorTag: .green
)
let postgres = Identity(
name: "Postgres admin", username: "postgres",
name: text("Postgres admin", "Postgres 管理员"), username: "postgres",
credentialRef: "termind.password.postgres", keyID: workKey.id,
notes: "Key to get in, password for sudo",
notes: text("Key to get in, password for sudo", "使用密钥登录,密码用于 sudo"),
lastUsedAt: now.addingTimeInterval(-7_200), colorTag: .orange
)
let consoleRoot = Identity(
name: "Console root", username: "root",
name: text("Console root", "控制台 root"), username: "root",
credentialRef: "termind.password.console-root",
notes: "Out-of-band only", colorTag: .red
notes: text("Out-of-band only", "仅限带外管理"), colorTag: .red
)
let unfinished = Identity(
name: "New admin", username: "admin",
notes: "No secret chosen yet"
name: text("New admin", "新管理员"), username: "admin",
notes: text("No secret chosen yet", "尚未选择凭据")
)
// Half the fleet is left without an identity on purpose, so the "not set
@@ -59,65 +72,82 @@ extension HostLibraryModel {
Host(
name: "web-01", host: "10.0.1.5", identityID: deploy.id,
groupID: production.id, tags: ["nginx", "prod"], colorTag: .red,
detectedDistribution: distribution("ubuntu"),
sortIndex: 0
),
Host(
name: "web-02", host: "10.0.1.6", identityID: deploy.id,
groupID: production.id, tags: ["nginx", "prod"], colorTag: .red,
detectedDistribution: distribution("debian"),
sortIndex: 1
),
Host(
name: "db-primary", host: "10.0.2.10", identityID: postgres.id,
groupID: production.id, tags: ["postgres"], colorTag: .purple,
detectedDistribution: distribution("rocky"),
sortIndex: 2
),
Host(
name: "cdn-edge-tokyo", host: "203.0.113.7", port: 2222, identityID: consoleRoot.id,
groupID: edge.id, tags: ["varnish", "apac"], colorTag: .orange,
detectedDistribution: distribution("alpine"),
sortIndex: 0
),
Host(
name: "staging-app", host: "staging.internal",
groupID: staging.id, tags: ["staging"], colorTag: .yellow,
detectedDistribution: distribution("fedora"),
sortIndex: 0
),
Host(
name: "nas", host: "192.168.1.20",
groupID: homelab.id, tags: ["zfs"], colorTag: .teal, sortIndex: 0
groupID: homelab.id, tags: ["zfs"], colorTag: .teal,
detectedDistribution: distribution("nixos"), sortIndex: 0
),
Host(
name: "pi-hole", host: "192.168.1.21",
groupID: homelab.id, tags: ["dns"], colorTag: .green, sortIndex: 1
groupID: homelab.id, tags: ["dns"], colorTag: .green,
detectedDistribution: distribution("raspberrypios"), sortIndex: 1
),
Host(
name: "scratch-box", host: "172.16.0.9",
detectedDistribution: distribution("opensuse-tumbleweed"),
sortIndex: 0
),
]
let diagnostics = SnippetPackage(name: "Diagnostics", sortIndex: 0)
let diagnostics = SnippetPackage(
name: text("Diagnostics", "诊断"), sortIndex: 0
)
let webOperations = SnippetPackage(
name: "Web Operations", parentID: diagnostics.id, sortIndex: 0
name: text("Web Operations", "Web 运维"),
parentID: diagnostics.id, sortIndex: 0
)
let snippets = [
Snippet(
title: "Disk usage by directory", body: "du -sh /* | sort -h",
title: text("Disk usage by directory", "按目录查看磁盘占用"),
body: "du -sh /* | sort -h",
packageID: diagnostics.id, targetHostIDs: [hosts[0].id],
sortIndex: 0
),
Snippet(
title: "Top memory consumers", body: "ps aux --sort=-%mem | head -20",
title: text("Top memory consumers", "内存占用最高的进程"),
body: "ps aux --sort=-%mem | head -20",
packageID: diagnostics.id,
targetHostIDs: [hosts[0].id, hosts[1].id],
sortIndex: 1
),
Snippet(
title: "Tail nginx errors", body: "tail -f /var/log/nginx/error.log",
title: text("Tail nginx errors", "持续查看 Nginx 错误"),
body: "tail -f /var/log/nginx/error.log",
packageID: webOperations.id,
targetHostIDs: [hosts[0].id, hosts[1].id],
sortIndex: 0
),
Snippet(title: "Listening ports", body: "ss -tulpn", sortIndex: 3),
Snippet(
title: text("Listening ports", "监听中的端口"),
body: "ss -tulpn", sortIndex: 3
),
]
let knownHosts = [
@@ -151,11 +181,12 @@ extension HostLibraryModel {
let portForwardings = [
PortForwardingRule(
hostID: db.id, kind: .local, bindPort: 5432,
targetHost: "localhost", targetPort: 5432, label: "Postgres"
targetHost: "localhost", targetPort: 5432,
label: text("Postgres", "Postgres 数据库")
),
PortForwardingRule(
hostID: web01.id, kind: .dynamic, bindPort: 1080,
label: "SOCKS proxy"
label: text("SOCKS proxy", "SOCKS 代理")
),
]
@@ -177,7 +208,8 @@ extension HostLibraryModel {
SFTPTransfer(
hostID: web01.id, direction: .upload,
localPath: "~/Config/nginx.conf", remotePath: "/etc/nginx/nginx.conf",
byteCount: 8_192, state: .failed(String(localized: "Permission denied")),
byteCount: 8_192,
state: .failed(text("Permission denied", "权限被拒绝")),
queuedAt: now.addingTimeInterval(-3_600)
),
SFTPTransfer(
@@ -205,7 +237,9 @@ extension HostLibraryModel {
store.setStatus(.connected, for: web01.id)
store.setStatus(.connecting, for: db.id)
if let staging = hosts.first(where: { $0.name == "staging-app" }) {
store.setStatus(.failed("Connection refused"), for: staging.id)
store.setStatus(
.failed(text("Connection refused", "连接被拒绝")), for: staging.id
)
}
return store
+13 -5
View File
@@ -2427,23 +2427,31 @@ extension WeakAgentUserInteraction: AgentUserInteractionHandling {}
// MARK: - Demo data
extension ConversationModel {
static func demo() -> ConversationModel {
static func demo(language: AppLanguage = .english) -> ConversationModel {
func text(_ en: String, _ zh: String) -> String {
language == .simplifiedChineseChina ? zh : en
}
let now = Date(timeIntervalSinceReferenceDate: 809_020_800)
let user = ChatMessage(
role: .user,
text: "Find the cause of the 502 responses. Inspect only—do not change configuration.",
text: text(
"Find the cause of the 502 responses. Inspect only—do not change configuration.",
"查明 502 响应的原因。仅检查,不要更改配置。"
),
createdAt: now.addingTimeInterval(-18)
)
let answer = ChatMessage(
role: .assistant,
text:
text: text(
"The upstream service is not listening on `127.0.0.1:8080`. Nginx itself is healthy, and no configuration was changed. Start the upstream service, then verify `/health` before considering a reload.",
"上游服务没有监听 `127.0.0.1:8080`。Nginx 本身运行正常,且没有更改任何配置。请先启动上游服务并验证 `/health`,再考虑重新加载。"
),
createdAt: now
)
let runID = UUID()
let descriptor = AgentToolDescriptor(
name: "ssh_exec",
summary: "Run a command on a remote host.",
summary: text("Run a command on a remote host.", "在远程主机上运行命令。"),
inputSchema: .object([:]), target: .host, safety: .locallyReadOnly,
concurrency: .parallel,
presentation: AgentToolDescriptor.Presentation(
@@ -2471,7 +2479,7 @@ extension ConversationModel {
)
}
let conversation = ConversationModel(messages: [user, answer])
conversation.customTitle = "Diagnose 502 responses"
conversation.customTitle = text("Diagnose 502 responses", "诊断 502 响应")
conversation.messages = [
user, answer,
]
+137
View File
@@ -0,0 +1,137 @@
#if DEBUG
import Testing
@testable import Termind
@MainActor
struct ScreenshotDemoTests {
@Test
func hostsIsTheFirstDemoScene() {
#expect(ScreenshotDemoConfiguration.Scene.allCases.first == .hosts)
}
@Test
func parsesEverySceneAndLanguageCombination() throws {
let languages: [(argument: String, language: AppLanguage)] = [
("en", .english),
("zh", .simplifiedChineseChina),
]
for scene in ScreenshotDemoConfiguration.Scene.allCases {
for language in languages {
let parsed = try ScreenshotDemoConfiguration.parse(arguments: [
"Termind", "--termind-demo=\(scene.rawValue):\(language.argument)",
])
let configuration = try #require(parsed)
#expect(configuration.scene == scene)
#expect(configuration.language == language.language)
}
}
}
@Test
func noDemoArgumentUsesOrdinaryLaunch() throws {
#expect(
try ScreenshotDemoConfiguration.parse(arguments: ["Termind"]) == nil
)
}
@Test
func rejectsIncompleteAndUnknownArguments() {
#expect(throws: ScreenshotDemoConfiguration.ParseError.invalidFormat(
"--termind-demo"
)) {
try ScreenshotDemoConfiguration.parse(arguments: [
"Termind", "--termind-demo",
])
}
#expect(throws: ScreenshotDemoConfiguration.ParseError.invalidFormat(
"--termind-demo=hosts"
)) {
try ScreenshotDemoConfiguration.parse(arguments: [
"Termind", "--termind-demo=hosts",
])
}
#expect(throws: ScreenshotDemoConfiguration.ParseError.unknownScene("vault")) {
try ScreenshotDemoConfiguration.parse(arguments: [
"Termind", "--termind-demo=vault:en",
])
}
#expect(throws: ScreenshotDemoConfiguration.ParseError.unknownLanguage("fr")) {
try ScreenshotDemoConfiguration.parse(arguments: [
"Termind", "--termind-demo=hosts:fr",
])
}
#expect(throws: ScreenshotDemoConfiguration.ParseError.duplicateArguments) {
try ScreenshotDemoConfiguration.parse(arguments: [
"Termind", "--termind-demo=hosts:en", "--termind-demo=hosts:zh",
])
}
}
@Test
func hostsSceneOpensHostsWithNavigationVisible() {
let configuration = ScreenshotDemoConfiguration(
scene: .hosts, language: .english
)
let window = configuration.makeWindow(hosts: .demo())
#expect(window.selectedDestination == .vault)
#expect(window.vaultDestination == .hosts)
#expect(window.showsSidebar)
#expect(window.sidebarMode == .navigation)
}
@Test
func workspaceScenesBuildExpectedWindowState() {
let hosts = HostLibraryModel.demo()
let workspace = ScreenshotDemoConfiguration(
scene: .workspace, language: .english
).makeWindow(hosts: hosts)
let command = ScreenshotDemoConfiguration(
scene: .command, language: .english
).makeWindow(hosts: hosts)
let agent = ScreenshotDemoConfiguration(
scene: .agent, language: .english
).makeWindow(hosts: hosts)
#expect(workspace.selectedWorkspace != nil)
#expect(!workspace.showsSidebar)
#expect(command.selectedWorkspace != nil)
#expect(command.showsSidebar)
#expect(command.sidebarMode == .navigation)
#expect(agent.selectedWorkspace != nil)
#expect(agent.showsSidebar)
#expect(agent.sidebarMode == .assistant)
}
@Test
func generatedDemoContentFollowsExplicitLanguage() throws {
let englishHosts = HostLibraryModel.demo(language: .english)
let chineseHosts = HostLibraryModel.demo(language: .simplifiedChineseChina)
#expect(englishHosts.rootGroups.first?.name == "Production")
#expect(chineseHosts.rootGroups.first?.name == "生产环境")
let englishConversation = ConversationModel.demo(language: .english)
let chineseConversation = ConversationModel.demo(
language: .simplifiedChineseChina
)
#expect(englishConversation.customTitle == "Diagnose 502 responses")
#expect(chineseConversation.customTitle == "诊断 502 响应")
#expect(try #require(englishConversation.messages.first).text.hasPrefix("Find"))
#expect(try #require(chineseConversation.messages.first).text.hasPrefix("查明"))
}
@Test
func demoHostsUseEightDistinctBundledDistributionIcons() throws {
let hosts = HostLibraryModel.demo().hosts
let distributions = try hosts.map {
try #require($0.detectedDistribution)
}
#expect(Set(distributions.map(\.id)).count == 8)
for distribution in distributions {
#expect(LinuxDistributionIconCatalog.entry(for: distribution) != nil)
}
}
}
#endif