From 8086a9142ffb2d6bf20d62f3bff5dcea8941532c Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sun, 30 Aug 2026 03:45:20 +0800 Subject: [PATCH] Demo --- Scripts/run-screenshot-demo.sh | 47 +++ Termind/App/ScreenshotDemo.swift | 398 +++++++++++------- Termind/App/TermindApp.swift | 67 +-- .../FeatureModels/HostLibraryModel+Demo.swift | 88 ++-- Termind/Features/AI/ConversationModel.swift | 18 +- TermindTests/ScreenshotDemoTests.swift | 137 ++++++ 6 files changed, 535 insertions(+), 220 deletions(-) create mode 100755 Scripts/run-screenshot-demo.sh create mode 100644 TermindTests/ScreenshotDemoTests.swift diff --git a/Scripts/run-screenshot-demo.sh b/Scripts/run-screenshot-demo.sh new file mode 100755 index 00000000..d29f9e89 --- /dev/null +++ b/Scripts/run-screenshot-demo.sh @@ -0,0 +1,47 @@ +#!/bin/zsh + +set -euo pipefail + +usage() { + print -u2 "Usage: Scripts/run-screenshot-demo.sh " +} + +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" diff --git a/Termind/App/ScreenshotDemo.swift b/Termind/App/ScreenshotDemo.swift index f2a1cf95..2672a52c 100644 --- a/Termind/App/ScreenshotDemo.swift +++ b/Termind/App/ScreenshotDemo.swift @@ -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=:." + 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 diff --git a/Termind/App/TermindApp.swift b/Termind/App/TermindApp.swift index db6d508c..cc5f0dfa 100644 --- a/Termind/App/TermindApp.swift +++ b/Termind/App/TermindApp.swift @@ -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 { diff --git a/Termind/Application/FeatureModels/HostLibraryModel+Demo.swift b/Termind/Application/FeatureModels/HostLibraryModel+Demo.swift index f1656868..979d6ba0 100644 --- a/Termind/Application/FeatureModels/HostLibraryModel+Demo.swift +++ b/Termind/Application/FeatureModels/HostLibraryModel+Demo.swift @@ -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 diff --git a/Termind/Features/AI/ConversationModel.swift b/Termind/Features/AI/ConversationModel.swift index de670f50..d7a4f528 100644 --- a/Termind/Features/AI/ConversationModel.swift +++ b/Termind/Features/AI/ConversationModel.swift @@ -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, ] diff --git a/TermindTests/ScreenshotDemoTests.swift b/TermindTests/ScreenshotDemoTests.swift new file mode 100644 index 00000000..d8ecb6c7 --- /dev/null +++ b/TermindTests/ScreenshotDemoTests.swift @@ -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